From 630488e0a1741e2467a37244fa68cb463abdbe39 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 1 Oct 2026 07:09:32 +0300 Subject: [PATCH] Share opt-in provider diagnostic traces --- README.md | 10 ++ docs/privacy-and-data-flow.md | 46 +++++- docs/security-model.md | 5 +- src/chrome/src/agent/agent.js | 39 ++++- src/chrome/src/providers/openai.js | 25 ++++ src/chrome/src/trace/webbrain-share-outbox.js | 141 +++++++++++++++--- src/chrome/src/ui/locales/en.js | 4 +- src/chrome/src/ui/locales/tr.js | 4 +- src/firefox/src/agent/agent.js | 39 ++++- src/firefox/src/providers/openai.js | 32 ++++ .../src/trace/webbrain-share-outbox.js | 141 +++++++++++++++--- src/firefox/src/ui/locales/en.js | 4 +- src/firefox/src/ui/locales/tr.js | 4 +- test/run.js | 111 +++++++++++++- 14 files changed, 539 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index fbc0950c15..66d430b712 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,16 @@ including an endpoint-free local WebGPU option with the tested LFM2.5 2.6B preset and an experimental custom Hugging Face ONNX repository option — see the [full catalog](docs/providers-and-models.md#extended-provider-catalog). +For a local or bring-your-own provider, the per-provider **Share queries for +research** switch remains off by default. When enabled, it now shares a +bounded, content-free diagnostic timeline for that provider's model attempts +(including failed runs) alongside the existing scrubbed prompt/response share. +The timeline includes tool names, outcomes, error codes, and timings, but not +tool arguments, page content, or screenshots. If a run fails before a normal +generation share, its bounded model-facing request and final blocker accompany +the diagnostic record. No second sharing switch is +required; turning the existing switch off also purges queued diagnostics. + ## Features - **Reads any page** — text, links, forms, tables, PDFs, and interactive diff --git a/docs/privacy-and-data-flow.md b/docs/privacy-and-data-flow.md index d8cc4cd3f5..e105d9fa15 100644 --- a/docs/privacy-and-data-flow.md +++ b/docs/privacy-and-data-flow.md @@ -47,7 +47,7 @@ inference stay on-device. The user chooses their provider in Settings. Options include: - **WebBrain Compass**: requests go through `api.webbrain.one`; selected interactions may be retained and used for evaluation, improvement, fine-tuning, and training while Help Improve WebBrain is enabled -- **Bring-your-own cloud providers**: OpenAI, Anthropic, Google Gemini, Mistral, DeepSeek, xAI, Groq, OpenRouter, etc. — requests go directly to the provider using the user's credentials and are never collected by WebBrain +- **Bring-your-own cloud providers**: OpenAI, Anthropic, Google Gemini, Mistral, DeepSeek, xAI, Groq, OpenRouter, etc. — requests go directly to the provider using the user's credentials; WebBrain receives a separate research copy only if the user enables that provider's **Share queries for research** switch - **Local model runtimes**: llama.cpp, Ollama, LM Studio, Jan, vLLM, SGLang, LocalAI, GPT4All, and Unsloth Studio — inference requests stay on the user's machine when Studio is configured with its loopback URL @@ -57,7 +57,7 @@ The user chooses their provider in Settings. Options include: local gateway, but the gateway may forward the request context to an upstream account. Its configuration and privacy policy determine where data goes. -Local-model and bring-your-own API requests are never collected by WebBrain. WebBrain Compass requests are processed and may be retained as described below. +Local-model and bring-your-own API requests do not pass through WebBrain Compass. A separate, per-provider research-sharing switch is off by default; when enabled, bounded copies and diagnostic metadata are sent to WebBrain as described below. WebBrain Compass requests are processed and may be retained separately. ### Optional research escalation to ChatGPT @@ -173,8 +173,9 @@ routed through an OpenRouter workspace where content logging is disabled. This does not prevent the minimal metadata-only operational logging required to provide the service, enforce quotas, prevent abuse, maintain security, or debug failures. Requests sent to local models or directly to providers using the -user's own credentials never pass through WebBrain Compass and are never eligible -for WebBrain training. +user's own credentials never pass through WebBrain Compass for inference. They +are not collected by WebBrain unless the user separately opts in to that +provider's research sharing. For eligible completed generations, MySQL is WebBrain's canonical store. The service strips media, compresses the request/response payload, encrypts it with @@ -197,6 +198,30 @@ selected for improvement are retained for no longer than 12 months before deletion or de-identification. De-identified datasets may be retained for up to 5 years for model development, evaluation, security, and reproducibility. +### Voluntary external/local provider research sharing + +**Share queries for research** is off by default for each local or bring-your-own +provider. If the user turns it on, WebBrain sends a bounded copy of that +provider's model-facing request and response to the Compass improvement service. +It also sends a bounded diagnostic timeline for model-attempt runs, including +failed ones: steps, tool names, outcomes, error codes, and timings. A failed run +may include its bounded model-facing request and displayed blocker even when no +response was produced. Screenshot and other binary bytes are stripped; raw tool +arguments and results are not included in the diagnostic timeline. Text in the +shared conversation can still contain sensitive personal information after +truncation and automated de-identification, so users should not enable this +switch for content they do not want to share. + +The extension records a local trace for an opted-in run even if the separate +Record traces switch is off. The upload projects only metadata from that trace, +including when the user independently selected local lossless tracing. Research +shares use a durable, revocable local outbox and are retried after temporary +delivery failures; the provider's live sharing consent is checked before each +send. The Compass service admits these records only under explicit +share-session consent, de-identifies and encrypts them, and applies its +improvement-data retention rules. They are not counted as Compass inference +requests. + --- ## What Stays in the Browser @@ -216,7 +241,10 @@ the stored copies are not separately synced to WebBrain. ### Trace Recorder When enabled (Settings → Display → "Record traces"), every agent run is written -to the local `webbrain_traces` IndexedDB database in one of two privacy tiers: +to the local `webbrain_traces` IndexedDB database in one of two privacy tiers. +An external/local provider with **Share queries for research** enabled also +records its run locally for the bounded diagnostic upload, even when this +separate Record traces setting is off: - **Default metadata-only tier.** The `runs` store keeps run identifiers and lineage, model/provider identifiers, token and event totals, timestamps, @@ -400,8 +428,9 @@ support this path. ## Telemetry / Analytics -The extension does not include an analytics SDK, crash-reporting SDK, or a -separate product-telemetry endpoint. When WebBrain Compass is selected, the model +The extension does not include an analytics SDK or crash-reporting SDK. Opt-in +research sharing uses separate improvement endpoints; it is not general product +telemetry. When WebBrain Compass is selected, the model request itself goes to `api.webbrain.one` and is subject to the Compass data-use terms above. Operational request metadata is retained separately for quota, security, abuse prevention, and debugging. @@ -415,6 +444,7 @@ The only outbound HTTP requests are: 6. **User memory extraction calls** (only if auto-learn is enabled; sent to the configured LLM provider after a completed turn) 7. **Encrypted Cloud Sync calls** to `https://api.webbrain.one/v1/sync` (only after a subscriber explicitly enables sync; vault content is encrypted before upload) 8. **Slash-driven tab/screen recording** creates no outbound traffic (the .webm is saved to the Downloads folder via `chrome.downloads.download`) +9. **Voluntary research shares** to `https://api.webbrain.one/v1/improvement/generations` and `/v1/improvement/diagnostic-traces` (only for a local or bring-your-own provider with its separate sharing switch enabled) The `webRequest` API shortcut observer is on by default and does not create outbound requests; it observes replay metadata for requests @@ -646,7 +676,7 @@ CDP capture → JPEG/PNG data URL | Provider selection | Choose which LLM receives the data, or run locally | | Provider prompt/tool tier | Choose Compact, Mid, or Full tool exposure for non-cloud providers | | Ask / Act / Dev mode | Choose read-only, normal action, or developer/page-inspection mode | -| Tracing toggle | Prevents any trace data from being stored | +| Tracing toggle | Controls ordinary local trace recording; a separately opted-in provider research share records a run for metadata-only diagnostic upload even when this toggle is off | | Screenshot fallback | Controls whether page images are sent to the LLM | | Auto-screenshot mode | Controls how frequently viewport captures are sent | | Strict secret handling | Keeps credentials out of assistant text and completion summaries: an instruction to the model, plus exact-match redaction in cloud runs of anything it typed, sent, or read from a labelled field | diff --git a/docs/security-model.md b/docs/security-model.md index 899305f1ba..1ff43c1d0d 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -194,7 +194,10 @@ persistent setting, which remains active until the user turns it off. ## Trace Data Isolation The trace recorder (`trace/recorder.js`) writes to IndexedDB on the user's -machine only when explicitly enabled (Settings → Display → "Record traces"). +machine when explicitly enabled (Settings → Display → "Record traces") or +when a local/bring-your-own provider's separate **Share queries for research** +switch is enabled for that run. The latter forces a local record so a bounded, +content-free diagnostic timeline can be uploaded under that explicit consent. The default tier is metadata-only: run records omit user and final assistant text; event records keep allowlisted counts, timings, usage, status/error codes, tool names/outcome status, and screenshot markers while omitting raw model diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 991b32eaf7..f4b9b0a061 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -80,7 +80,7 @@ import { isPdfHandlerTabUrl, pdfUrlFromTabUrl } from './pdf-extraction.js'; import { normalizePdfOcrResult, PDF_OCR_SYSTEM_PROMPT } from './pdf-ocr.js'; import * as trace from '../trace/recorder.js'; import { buildTerminalRuntimeEvent, enqueueCloudRuntimeEvent, flushCloudRuntimeOutbox } from '../trace/cloud-runtime-outbox.js'; -import { buildShareGenerationItem, enqueueShareGeneration, flushShareOutbox, purgeShareGenerations } from '../trace/webbrain-share-outbox.js'; +import { buildShareGenerationItem, buildShareDiagnosticItem, enqueueShareGeneration, enqueueShareDiagnostic, flushShareOutbox, purgeShareGenerations } from '../trace/webbrain-share-outbox.js'; import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; import { tracesToMarkdown } from './trace-export.js'; import { hcaptchaParamError } from './captcha-hcaptcha-providers.js'; @@ -19512,7 +19512,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // passes the origin ids so the trace can attribute the derived run. parentRunId: runOptions?.parentRunId || null, parentSessionId: runOptions?.parentSessionId || null, - force: runOptions?.cloudRun === true, + // Research consent also records a local run when Tracing is off. The + // share builder projects metadata before upload even if a separate + // local lossless-trace preference is enabled. + force: runOptions?.cloudRun === true || (provider?.config?.shareQueriesForResearch === true + && String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud'), }); } catch { this.pendingAdapterMatchTraces.delete(tabId); @@ -19611,11 +19615,6 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } catch {} } - // Retry delivery of previously queued voluntary shares on every run end, - // mirroring the Compass runtime outbox pattern. Revoked entries are - // purged first so opt-out is honored immediately before delivery. - try { await this._purgeRevokedShareGenerations(); } catch {} - void flushShareOutbox(shareTransport, (entry) => this._shareEntryConsented(entry)); if (runId) { await this._flushAdapterMatchTraceRun(runId); try { @@ -19624,9 +19623,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d await this._persistNow(tabId); } } catch {} + if (shareTransport && provider?.config?.shareQueriesForResearch === true + && String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud') { + try { + const sessionId = this._shareSessionId(this.conversationIds.get(tabId) || null); + if (sessionId) { + const item = buildShareDiagnosticItem({ + runId, + events: await trace.getRunEvents(runId), + status, + model: provider?.model, + mode, + browserTarget: 'chrome', + extensionVersion: chrome.runtime.getManifest().version || '', + provider: String(provider?.config?.providerName || '').toLowerCase(), + provider_name: String(provider?.config?.label || provider?.name || ''), + provider_id: String(provider?.config?._providerId || ''), + messages: Array.isArray(shareRequest) ? shareRequest : null, + finalContent, + }); + if (item) await enqueueShareDiagnostic({ session_id: sessionId, ...item }); + } + } catch {} + } this.currentRunId.delete(tabId); this.adapterMatchTraceKeys.delete(runId); } + // Both generation and diagnostic records use the same revocable outbox. + try { await this._purgeRevokedShareGenerations(); } catch {} + void flushShareOutbox(shareTransport, (entry) => this._shareEntryConsented(entry)); // Stash before deleting so an app-owned trusted continuation (Continue // after max_steps) can reuse the same task's proofs. Independent tasks // mint fresh at the next _startTraceRun and discard the stash there, so diff --git a/src/chrome/src/providers/openai.js b/src/chrome/src/providers/openai.js index 4444623634..729c0103b0 100644 --- a/src/chrome/src/providers/openai.js +++ b/src/chrome/src/providers/openai.js @@ -274,6 +274,31 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } } + async sendShareDiagnostic(sessionId, diagnostic, { timeoutMs = 4000 } = {}) { + if (String(this.config.providerName || '').toLowerCase() !== 'webbrain-cloud') { + return { ok: false, retryable: false, status: 0 }; + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), Math.max(250, timeoutMs)) : null; + try { + const response = await fetchWithFallback(`${this.baseUrl}/improvement/diagnostic-traces`, { + method: 'POST', + headers: this._headers({ helpImprove: '1' }), + body: JSON.stringify({ session_id: String(sessionId || ''), diagnostic }), + ...(controller ? { signal: controller.signal } : {}), + }); + if (response.ok) return { ok: true, retryable: false, status: response.status }; + try { await response.text(); } catch {} + // Keep the durable outbox entry while an older Cloud deployment lacks + // this newer endpoint; it will be retried after the server rolls out. + return { ok: false, retryable: response.status === 404 || response.status === 408 || response.status === 429 || response.status >= 500, status: response.status }; + } catch { + return { ok: false, retryable: true, status: 0 }; + } finally { + if (timer != null) clearTimeout(timer); + } + } + /** * Newer OpenAI models (gpt-5 and the o-series) reject `max_tokens` and any * non-default `temperature`, requiring `max_completion_tokens`. Detected by diff --git a/src/chrome/src/trace/webbrain-share-outbox.js b/src/chrome/src/trace/webbrain-share-outbox.js index 67bab8b047..58d56cafd2 100644 --- a/src/chrome/src/trace/webbrain-share-outbox.js +++ b/src/chrome/src/trace/webbrain-share-outbox.js @@ -6,11 +6,15 @@ // provider instance (its base URL hosts the backend), independent of which // provider produced the run. +import { projectTraceEventData } from './privacy.js'; + const STORAGE_KEY = 'webbrainShareOutboxV1'; const MAX_OUTBOX_ITEMS = 100; const MAX_MESSAGE_CHARS = 10_000; const MAX_REQUEST_BUDGET = 150_000; +const MAX_DIAGNOSTIC_REQUEST_BUDGET = 80_000; const MAX_RESPONSE_CHARS = 40_000; +const MAX_DIAGNOSTIC_EVENTS = 80; const BINARY_DATA_URL = /data:(image|audio|video|application|font|model)\/[^\s;,]+(?:\s*;[^,]*)?\s*,/i; let storageQueue = Promise.resolve(); let flushQueue = Promise.resolve(); @@ -200,7 +204,7 @@ function scrubMessage(message) { return copy; } -function scrubMessages(messages) { +function scrubMessages(messages, maxBudget = MAX_REQUEST_BUDGET) { if (!Array.isArray(messages)) return null; // Scrub every message first (per-message work is order-independent). const scrubbedAll = []; @@ -216,7 +220,7 @@ function scrubMessages(messages) { const hasSystemPrompt = scrubbedAll[0].copy.role === 'system'; // A single clamped message always fits in practice; guard anyway so one // pathological turn cannot blow the whole-request budget on its own. - if (hasSystemPrompt && scrubbedAll[0].size > MAX_REQUEST_BUDGET) { + if (hasSystemPrompt && scrubbedAll[0].size > maxBudget) { return [{ role: 'system', content: '[earlier shared messages omitted]' }]; } // Preserve the tail: the newest user/tool turns directly produced the @@ -227,7 +231,7 @@ function scrubMessages(messages) { // context. const MAX_SCRUBBED_MESSAGES = 200; const kept = []; - let budget = MAX_REQUEST_BUDGET; + let budget = maxBudget; if (hasSystemPrompt) budget = Math.max(0, budget - scrubbedAll[0].size); let startIndex = scrubbedAll.length; const reserve = (entry) => { @@ -255,7 +259,7 @@ function scrubMessages(messages) { let outJson = ''; try { outJson = JSON.stringify(out); } catch { outJson = ''; } let trimmed = false; - while ((out.length > MAX_SCRUBBED_MESSAGES || (outJson && outJson.length > MAX_REQUEST_BUDGET)) + while ((out.length > MAX_SCRUBBED_MESSAGES || (outJson && outJson.length > maxBudget)) && out.length > wrapperFloor) { out.splice(wrapperFloor, 1); trimmed = true; @@ -265,7 +269,7 @@ function scrubMessages(messages) { out.splice(hasSystemPrompt ? 1 : 0, 0, omissionMarker); if (out.length > MAX_SCRUBBED_MESSAGES) out.splice(hasSystemPrompt ? 2 : 1, 1); try { outJson = JSON.stringify(out); } catch { /* keep best effort */ } - while (outJson && outJson.length > MAX_REQUEST_BUDGET && out.length > (hasSystemPrompt ? 2 : 1)) { + while (outJson && outJson.length > maxBudget && out.length > (hasSystemPrompt ? 2 : 1)) { out.splice(hasSystemPrompt ? 2 : 1, 1); try { outJson = JSON.stringify(out); } catch { break; } } @@ -311,6 +315,99 @@ export function buildShareGenerationItem({ }; } +// Share the metadata tier of the local trace, never its lossless payloads. +// This record adds the execution timeline needed to diagnose failures and +// tool routing; failed runs also carry their scrubbed model request. +export function buildShareDiagnosticItem({ + runId, events, status, model, mode, browserTarget, extensionVersion, + provider, provider_name, provider_id, messages = null, finalContent = null, +}) { + if (!runId || !Array.isArray(events) || !events.some(event => + event?.kind === 'llm_request' || event?.kind === 'llm_response')) return null; + const allowedKinds = new Set([ + 'llm_request', 'llm_response', 'tool', 'error', 'streaming', 'note', + 'terminal_runtime', 'turn_start', 'turn_end', 'step_start', 'step_end', + 'vision_route', 'vision_sub_call', 'screenshot', + ]); + const projected = events.filter(event => allowedKinds.has(event?.kind)).map(event => { + // The default recorder tier already projects tool results. Projecting a + // second time would turn its resultStatus into "unknown" because the raw + // result field is intentionally absent. Lossless events still need the + // projection here so their args/results can never leave the browser. + const data = event.kind === 'tool' && !Object.hasOwn(event.data || {}, 'result') + && ['success', 'error', 'unknown'].includes(event.data?.resultStatus) + ? event.data + : projectTraceEventData(event.kind, event.data, { includeContent: false }); + const fields = { + llm_request: ['step', 'phase', 'attempt', 'repair'], + llm_response: ['step', 'phase', 'latencyMs', 'finishReason', 'toolCallCount', 'contentChars'], + tool: ['step', 'name', 'latencyMs', 'resultStatus', 'resultErrorCode'], + error: ['step', 'phase', 'code'], + streaming: ['step', 'status', 'protocol', 'reason', 'errorCode', 'durationMs', 'toolCallCount'], + note: ['step', 'note'], + terminal_runtime: ['step', 'status', 'toolName', 'errorCode', 'durationMs', 'success'], + screenshot: ['step'], + vision_route: ['step', 'visionRoute', 'fallbackReason'], + vision_sub_call: ['step', 'visionRoute', 'latencyMs', 'errorCode', 'recoveryOutcome'], + }[event.kind] || ['step', 'ok', 'status', 'code', 'durationMs']; + const safe = {}; + for (const field of fields) { + const value = data?.[field]; + if (typeof value === 'boolean') safe[field] = value; + else if (typeof value === 'number' && Number.isFinite(value)) safe[field] = Math.max(0, Math.min(120_000, Math.floor(value))); + else if (typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value)) safe[field] = value; + } + if (event.kind === 'note' && data?.note === 'adapter_match') { + for (const field of ['adapter', 'revision', 'notesInjected']) { + const value = data.extra?.[field]; + if (typeof value === 'boolean') safe[field] = value; + else if (typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value)) safe[field] = value; + } + } + return { seq: Number.isSafeInteger(event.seq) ? event.seq : 0, kind: event.kind, ...safe }; + }); + const selected = projected.length > MAX_DIAGNOSTIC_EVENTS + ? [...projected.slice(0, 16), ...projected.slice(-(MAX_DIAGNOSTIC_EVENTS - 16))] + : projected; + const eventId = `diag_${String(runId).replace(/[^A-Za-z0-9._:-]/g, '').slice(0, 180)}`; + return { + id: eventId, + kind: 'diagnostic', + provider_id: String(provider_id || ''), + provider: String(provider || '').slice(0, 64), + provider_name: String(provider_name || '').slice(0, 128), + model: String(model || '').slice(0, 255), + mode: String(mode || '').slice(0, 32), + diagnostic: { + event_id: eventId, + event: { + kind: 'diagnostic_trace', + runId: String(runId).slice(0, 200), + seq: 1, + ts: Date.now(), + data: { + status: String(status || 'unknown').slice(0, 32), + model: String(model || '').slice(0, 255), + mode: String(mode || '').slice(0, 32), + browser_target: String(browserTarget || '').slice(0, 32), + extension_version: String(extensionVersion || '').slice(0, 64), + provider: String(provider || '').slice(0, 64), + provider_name: String(provider_name || '').slice(0, 128), + dropped_events: projected.length - selected.length, + events: selected, + // Successful runs already have a generation share. Failed runs may + // have no completion, so retain only their actual model-facing + // request and bounded final blocker for useful diagnosis. + ...(status !== 'done' && Array.isArray(messages) && messages.length + ? { request_messages: scrubMessages(requestMessages(messages, finalContent), MAX_DIAGNOSTIC_REQUEST_BUDGET) } : {}), + ...(status !== 'done' && typeof finalContent === 'string' && finalContent.trim() + ? { final_content: scrubText(finalContent, 10_000) } : {}), + }, + }, + }, + }; +} + function localStorageArea() { const api = (typeof browser !== 'undefined' && browser?.storage) ? browser @@ -346,6 +443,15 @@ export async function enqueueShareGeneration(item) { return true; } +export async function enqueueShareDiagnostic(item) { + if (!item?.id || item.kind !== 'diagnostic' || !item.diagnostic?.event) return false; + await updateOutbox(current => { + if (current.some(entry => entry?.id === item.id)) return current; + return [...current, { ...item, queued_at: Date.now() }]; + }); + return true; +} + // Drop queued entries the predicate no longer consents to (e.g. the user // revoked "share queries for research" after an offline/failed run queued an // entry). Run before every flush so revocation is honored immediately before @@ -395,18 +501,19 @@ async function flushShareOutboxNow(transportProvider, shouldSend) { } let result; try { - result = await transportProvider.sendShareGeneration(entry.session_id, { - client_share_id: entry.id, - provider: entry.provider, - provider_name: entry.provider_name, - model: entry.model, - mode: entry.mode, - // The Compass intake contract reserves request for an object; persist - // the compact message array in the existing outbox shape, then wrap it - // only at delivery so queued entries stay backward-compatible. - request: { messages: entry.request }, - response: entry.response, - }); + result = entry.kind === 'diagnostic' + ? await transportProvider.sendShareDiagnostic(entry.session_id, entry.diagnostic) + : await transportProvider.sendShareGeneration(entry.session_id, { + client_share_id: entry.id, + provider: entry.provider, + provider_name: entry.provider_name, + model: entry.model, + mode: entry.mode, + // The Compass intake contract reserves request for an object; + // retain the compact array in old queued entries until delivery. + request: { messages: entry.request }, + response: entry.response, + }); } catch { result = { ok: false, retryable: true }; } diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 03e0a1d03d..7ffc3d9060 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -776,8 +776,8 @@ export default { 'st.providers.compat.value.developer': 'Developer', 'st.providers.webbrain_data_use.body': 'Free daily WebBrain Compass usage is included. While Help Improve WebBrain is on by default, selected Compass conversations may be retained and used for evaluation, improvement, fine-tuning, and training. Turn it off in General → Advanced to exclude future Compass interactions from those uses. Local-model and bring-your-own API requests are only collected by WebBrain when you turn on the per-provider “Share queries for research” option. {privacyLink}. For more usage, subscribe at {subscribeLink}. Manage billing at {accountLink}.', 'st.providers.share_research.label': 'Share queries for research', - 'st.providers.share_research.hint': 'Send prompts and responses from this provider to WebBrain for evaluation and improvement, including the provider and model used. Images and binary attachments are stripped and text is truncated before sharing; remaining text is sent as-is.', - 'st.providers.share_research.confirm': 'Share queries from this provider with WebBrain for research?\n\nWhen on, your prompts, responses, and tool interactions with this provider will be sent to WebBrain for evaluation and improvement, together with the provider and model name. Text is sent as-is after stripping images and truncating long content, so avoid sharing sensitive personal data. You can turn this off at any time to stop future sharing.', + 'st.providers.share_research.hint': 'Send bounded prompts, responses, tool interactions, and diagnostic trace metadata (steps, tool names, statuses, errors, and timings) from this provider to WebBrain for evaluation and improvement. Images and binary attachments are stripped; text is truncated before sharing.', + 'st.providers.share_research.confirm': 'Share queries and diagnostic traces from this provider with WebBrain for research?\n\nWhen on, your prompts, responses, tool interactions, and bounded diagnostic trace metadata—including failed runs, tool names, statuses, errors, and timings—will be sent to WebBrain with the provider and model name. Screenshots and binary attachments are not uploaded. Other text is sent as-is after truncation, so avoid sharing sensitive personal data. You can turn this off at any time to stop future sharing.', 'st.providers.webbrain_note.body': 'Free daily WebBrain Compass usage is included. Requests go through api.webbrain.one; by default we log metadata for quota and debugging, not prompt text, page content, screenshots, or model responses. {privacyLink}. For more usage, subscribe at {subscribeLink}. Manage billing at {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Privacy policy', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index 526861eb92..e05c73aff0 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -1046,8 +1046,8 @@ export default { "st.display.help_improve.desc_html": "Seçili WebBrain Compass etkileşimlerinin saklanmasına ve değerlendirme, iyileştirme, ince ayar ve eğitim için kullanılmasına izin verin. Varsayılan olarak açıktır. Gelecekteki Compass etkileşimlerinin bu amaçlarla kullanılmasını önlemek için kapatın. WebBrain, yerel model ve kendi API isteklerinizi yalnızca “Araştırma için sorguları paylaş” seçeneğini açtığınız sağlayıcılardan toplar. Gizlilik politikası →", "st.providers.webbrain_data_use.body": "Ücretsiz günlük WebBrain Compass kullanımı dahildir. WebBrain’i İyileştirmeye Yardım Et varsayılan olarak açıkken, seçili Compass konuşmaları değerlendirme, iyileştirme, ince ayar ve eğitim için saklanabilir ve kullanılabilir. Gelecekteki Compass etkileşimlerini bu kullanımların dışında tutmak için Genel → Gelişmiş bölümünden kapatın. WebBrain, yerel model ve kendi API isteklerinizi yalnızca ilgili sağlayıcıda “Araştırma için sorguları paylaş” seçeneğini açtığınızda toplar. {privacyLink}. Daha fazla kullanım için {subscribeLink} adresinden abone olun. Faturalandırmayı {accountLink} adresinden yönetin.", 'st.providers.share_research.label': "Araştırma için sorguları paylaş", - 'st.providers.share_research.hint': "Bu sağlayıcının istemlerini ve yanıtlarını, kullanılan sağlayıcı ve model bilgisiyle birlikte değerlendirme ve iyileştirme amacıyla WebBrain’e gönderir. Görseller ve ikili ekler paylaşılmadan önce kaldırılır, metin kısaltılır; kalan metin olduğu gibi gönderilir.", - 'st.providers.share_research.confirm': "Bu sağlayıcının sorgularını araştırma için WebBrain ile paylaşılsın mı?\n\nAçıkken istemleriniz, yanıtlarınız ve bu sağlayıcıyla araç etkileşimleriniz, sağlayıcı ve model adıyla birlikte değerlendirme ve iyileştirme amacıyla WebBrain’e gönderilir. Metin, görseller kaldırılıp uzun içerikler kısaltıldıktan sonra olduğu gibi gönderilir; bu yüzden hassas kişisel verileri paylaşmaktan kaçının. Gelecekteki paylaşımları durdurmak için bunu istediğiniz zaman kapatabilirsiniz.", + 'st.providers.share_research.hint': "Bu sağlayıcının sınırlı istemlerini, yanıtlarını, araç etkileşimlerini ve tanılama izi metaverilerini (adımlar, araç adları, durumlar, hatalar ve süreler) değerlendirme amacıyla WebBrain’e gönderir. Görseller ve ikili ekler kaldırılır; metin kısaltılır.", + 'st.providers.share_research.confirm': "Bu sağlayıcının sorguları ve tanılama izleri araştırma için WebBrain ile paylaşılsın mı?\n\nAçıkken istemleriniz, yanıtlarınız, araç etkileşimleriniz ve başarısız çalıştırmalar dâhil sınırlı tanılama izi metaverileri (araç adları, durumlar, hatalar ve süreler) sağlayıcı ve model adıyla birlikte gönderilir. Ekran görüntüleri ve ikili ekler yüklenmez. Diğer metin kısaltıldıktan sonra olduğu gibi gönderilir; hassas kişisel veri paylaşmamaya dikkat edin. Gelecekteki paylaşımları durdurmak için bunu istediğiniz zaman kapatabilirsiniz.", 'st.providers.compat.title': 'Gelişmiş model uyumluluğu', 'st.providers.compat.blurb': 'Model veya uç nokta farklı bir istek sözleşmesi belgelemedikçe bunları Otomatik bırakın.', 'st.providers.compat.preset': 'Uyumluluk ön ayarı', diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 90778f869a..e7f638af8d 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -75,7 +75,7 @@ import { import { normalizePdfOcrResult, PDF_OCR_SYSTEM_PROMPT } from './pdf-ocr.js'; import * as trace from '../trace/recorder.js'; import { buildTerminalRuntimeEvent, enqueueCloudRuntimeEvent, flushCloudRuntimeOutbox } from '../trace/cloud-runtime-outbox.js'; -import { buildShareGenerationItem, enqueueShareGeneration, flushShareOutbox, purgeShareGenerations } from '../trace/webbrain-share-outbox.js'; +import { buildShareGenerationItem, buildShareDiagnosticItem, enqueueShareGeneration, enqueueShareDiagnostic, flushShareOutbox, purgeShareGenerations } from '../trace/webbrain-share-outbox.js'; import { normalizeRuntimeTraceConfig } from '../trace/runtime-config.js'; import { tracesToMarkdown } from './trace-export.js'; import { hcaptchaParamError } from './captcha-hcaptcha-providers.js'; @@ -17306,7 +17306,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // passes the origin ids so the trace can attribute the derived run. parentRunId: runOptions?.parentRunId || null, parentSessionId: runOptions?.parentSessionId || null, - force: runOptions?.cloudRun === true, + // Research consent also records a local run when Tracing is off. The + // share builder projects metadata before upload even if a separate + // local lossless-trace preference is enabled. + force: runOptions?.cloudRun === true || (provider?.config?.shareQueriesForResearch === true + && String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud'), }); } catch { this.pendingAdapterMatchTraces.delete(tabId); @@ -17400,11 +17404,6 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } catch {} } - // Retry delivery of previously queued voluntary shares on every run end, - // mirroring the Compass runtime outbox pattern. Revoked entries are - // purged first so opt-out is honored immediately before delivery. - try { await this._purgeRevokedShareGenerations(); } catch {} - void flushShareOutbox(shareTransport, (entry) => this._shareEntryConsented(entry)); if (runId) { await this._flushAdapterMatchTraceRun(runId); try { @@ -17413,9 +17412,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d await this._persistNow(tabId); } } catch {} + if (shareTransport && provider?.config?.shareQueriesForResearch === true + && String(provider?.config?.providerName || '').toLowerCase() !== 'webbrain-cloud') { + try { + const sessionId = this._shareSessionId(this.conversationIds.get(tabId) || null); + if (sessionId) { + const item = buildShareDiagnosticItem({ + runId, + events: await trace.getRunEvents(runId), + status, + model: provider?.model, + mode, + browserTarget: 'firefox', + extensionVersion: browser.runtime.getManifest().version || '', + provider: String(provider?.config?.providerName || '').toLowerCase(), + provider_name: String(provider?.config?.label || provider?.name || ''), + provider_id: String(provider?.config?._providerId || ''), + messages: Array.isArray(shareRequest) ? shareRequest : null, + finalContent, + }); + if (item) await enqueueShareDiagnostic({ session_id: sessionId, ...item }); + } + } catch {} + } this.currentRunId.delete(tabId); this.adapterMatchTraceKeys.delete(runId); } + // Both generation and diagnostic records use the same revocable outbox. + try { await this._purgeRevokedShareGenerations(); } catch {} + void flushShareOutbox(shareTransport, (entry) => this._shareEntryConsented(entry)); // Stash before deleting so an app-owned trusted continuation (Continue // after max_steps) can reuse the same task's proofs. Independent tasks // mint fresh at the next _startTraceRun and discard the stash there, so diff --git a/src/firefox/src/providers/openai.js b/src/firefox/src/providers/openai.js index 85cd1427bb..8a4873ec25 100644 --- a/src/firefox/src/providers/openai.js +++ b/src/firefox/src/providers/openai.js @@ -284,6 +284,38 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } } + async sendShareDiagnostic(sessionId, diagnostic, { timeoutMs = 4000 } = {}) { + if (String(this.config.providerName || '').toLowerCase() !== 'webbrain-cloud') { + return { ok: false, retryable: false, status: 0 }; + } + try { + if (!await browser.permissions.contains({ data_collection: RESEARCH_DATA_COLLECTION })) { + return { ok: false, retryable: false, status: 0 }; + } + } catch { + return { ok: false, retryable: false, status: 0 }; + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), Math.max(250, timeoutMs)) : null; + try { + const response = await fetchWithTimeout(`${this.baseUrl}/improvement/diagnostic-traces`, { + method: 'POST', + headers: this._headers({ helpImprove: '1' }), + body: JSON.stringify({ session_id: String(sessionId || ''), diagnostic }), + ...(controller ? { signal: controller.signal } : {}), + }); + if (response.ok) return { ok: true, retryable: false, status: response.status }; + try { await response.text(); } catch {} + // Keep the durable outbox entry while an older Cloud deployment lacks + // this newer endpoint; it will be retried after the server rolls out. + return { ok: false, retryable: response.status === 404 || response.status === 408 || response.status === 429 || response.status >= 500, status: response.status }; + } catch { + return { ok: false, retryable: true, status: 0 }; + } finally { + if (timer != null) clearTimeout(timer); + } + } + /** * Newer OpenAI models (gpt-5 and the o-series) reject `max_tokens` and any * non-default `temperature`, requiring `max_completion_tokens`. Detected by diff --git a/src/firefox/src/trace/webbrain-share-outbox.js b/src/firefox/src/trace/webbrain-share-outbox.js index 67bab8b047..58d56cafd2 100644 --- a/src/firefox/src/trace/webbrain-share-outbox.js +++ b/src/firefox/src/trace/webbrain-share-outbox.js @@ -6,11 +6,15 @@ // provider instance (its base URL hosts the backend), independent of which // provider produced the run. +import { projectTraceEventData } from './privacy.js'; + const STORAGE_KEY = 'webbrainShareOutboxV1'; const MAX_OUTBOX_ITEMS = 100; const MAX_MESSAGE_CHARS = 10_000; const MAX_REQUEST_BUDGET = 150_000; +const MAX_DIAGNOSTIC_REQUEST_BUDGET = 80_000; const MAX_RESPONSE_CHARS = 40_000; +const MAX_DIAGNOSTIC_EVENTS = 80; const BINARY_DATA_URL = /data:(image|audio|video|application|font|model)\/[^\s;,]+(?:\s*;[^,]*)?\s*,/i; let storageQueue = Promise.resolve(); let flushQueue = Promise.resolve(); @@ -200,7 +204,7 @@ function scrubMessage(message) { return copy; } -function scrubMessages(messages) { +function scrubMessages(messages, maxBudget = MAX_REQUEST_BUDGET) { if (!Array.isArray(messages)) return null; // Scrub every message first (per-message work is order-independent). const scrubbedAll = []; @@ -216,7 +220,7 @@ function scrubMessages(messages) { const hasSystemPrompt = scrubbedAll[0].copy.role === 'system'; // A single clamped message always fits in practice; guard anyway so one // pathological turn cannot blow the whole-request budget on its own. - if (hasSystemPrompt && scrubbedAll[0].size > MAX_REQUEST_BUDGET) { + if (hasSystemPrompt && scrubbedAll[0].size > maxBudget) { return [{ role: 'system', content: '[earlier shared messages omitted]' }]; } // Preserve the tail: the newest user/tool turns directly produced the @@ -227,7 +231,7 @@ function scrubMessages(messages) { // context. const MAX_SCRUBBED_MESSAGES = 200; const kept = []; - let budget = MAX_REQUEST_BUDGET; + let budget = maxBudget; if (hasSystemPrompt) budget = Math.max(0, budget - scrubbedAll[0].size); let startIndex = scrubbedAll.length; const reserve = (entry) => { @@ -255,7 +259,7 @@ function scrubMessages(messages) { let outJson = ''; try { outJson = JSON.stringify(out); } catch { outJson = ''; } let trimmed = false; - while ((out.length > MAX_SCRUBBED_MESSAGES || (outJson && outJson.length > MAX_REQUEST_BUDGET)) + while ((out.length > MAX_SCRUBBED_MESSAGES || (outJson && outJson.length > maxBudget)) && out.length > wrapperFloor) { out.splice(wrapperFloor, 1); trimmed = true; @@ -265,7 +269,7 @@ function scrubMessages(messages) { out.splice(hasSystemPrompt ? 1 : 0, 0, omissionMarker); if (out.length > MAX_SCRUBBED_MESSAGES) out.splice(hasSystemPrompt ? 2 : 1, 1); try { outJson = JSON.stringify(out); } catch { /* keep best effort */ } - while (outJson && outJson.length > MAX_REQUEST_BUDGET && out.length > (hasSystemPrompt ? 2 : 1)) { + while (outJson && outJson.length > maxBudget && out.length > (hasSystemPrompt ? 2 : 1)) { out.splice(hasSystemPrompt ? 2 : 1, 1); try { outJson = JSON.stringify(out); } catch { break; } } @@ -311,6 +315,99 @@ export function buildShareGenerationItem({ }; } +// Share the metadata tier of the local trace, never its lossless payloads. +// This record adds the execution timeline needed to diagnose failures and +// tool routing; failed runs also carry their scrubbed model request. +export function buildShareDiagnosticItem({ + runId, events, status, model, mode, browserTarget, extensionVersion, + provider, provider_name, provider_id, messages = null, finalContent = null, +}) { + if (!runId || !Array.isArray(events) || !events.some(event => + event?.kind === 'llm_request' || event?.kind === 'llm_response')) return null; + const allowedKinds = new Set([ + 'llm_request', 'llm_response', 'tool', 'error', 'streaming', 'note', + 'terminal_runtime', 'turn_start', 'turn_end', 'step_start', 'step_end', + 'vision_route', 'vision_sub_call', 'screenshot', + ]); + const projected = events.filter(event => allowedKinds.has(event?.kind)).map(event => { + // The default recorder tier already projects tool results. Projecting a + // second time would turn its resultStatus into "unknown" because the raw + // result field is intentionally absent. Lossless events still need the + // projection here so their args/results can never leave the browser. + const data = event.kind === 'tool' && !Object.hasOwn(event.data || {}, 'result') + && ['success', 'error', 'unknown'].includes(event.data?.resultStatus) + ? event.data + : projectTraceEventData(event.kind, event.data, { includeContent: false }); + const fields = { + llm_request: ['step', 'phase', 'attempt', 'repair'], + llm_response: ['step', 'phase', 'latencyMs', 'finishReason', 'toolCallCount', 'contentChars'], + tool: ['step', 'name', 'latencyMs', 'resultStatus', 'resultErrorCode'], + error: ['step', 'phase', 'code'], + streaming: ['step', 'status', 'protocol', 'reason', 'errorCode', 'durationMs', 'toolCallCount'], + note: ['step', 'note'], + terminal_runtime: ['step', 'status', 'toolName', 'errorCode', 'durationMs', 'success'], + screenshot: ['step'], + vision_route: ['step', 'visionRoute', 'fallbackReason'], + vision_sub_call: ['step', 'visionRoute', 'latencyMs', 'errorCode', 'recoveryOutcome'], + }[event.kind] || ['step', 'ok', 'status', 'code', 'durationMs']; + const safe = {}; + for (const field of fields) { + const value = data?.[field]; + if (typeof value === 'boolean') safe[field] = value; + else if (typeof value === 'number' && Number.isFinite(value)) safe[field] = Math.max(0, Math.min(120_000, Math.floor(value))); + else if (typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value)) safe[field] = value; + } + if (event.kind === 'note' && data?.note === 'adapter_match') { + for (const field of ['adapter', 'revision', 'notesInjected']) { + const value = data.extra?.[field]; + if (typeof value === 'boolean') safe[field] = value; + else if (typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value)) safe[field] = value; + } + } + return { seq: Number.isSafeInteger(event.seq) ? event.seq : 0, kind: event.kind, ...safe }; + }); + const selected = projected.length > MAX_DIAGNOSTIC_EVENTS + ? [...projected.slice(0, 16), ...projected.slice(-(MAX_DIAGNOSTIC_EVENTS - 16))] + : projected; + const eventId = `diag_${String(runId).replace(/[^A-Za-z0-9._:-]/g, '').slice(0, 180)}`; + return { + id: eventId, + kind: 'diagnostic', + provider_id: String(provider_id || ''), + provider: String(provider || '').slice(0, 64), + provider_name: String(provider_name || '').slice(0, 128), + model: String(model || '').slice(0, 255), + mode: String(mode || '').slice(0, 32), + diagnostic: { + event_id: eventId, + event: { + kind: 'diagnostic_trace', + runId: String(runId).slice(0, 200), + seq: 1, + ts: Date.now(), + data: { + status: String(status || 'unknown').slice(0, 32), + model: String(model || '').slice(0, 255), + mode: String(mode || '').slice(0, 32), + browser_target: String(browserTarget || '').slice(0, 32), + extension_version: String(extensionVersion || '').slice(0, 64), + provider: String(provider || '').slice(0, 64), + provider_name: String(provider_name || '').slice(0, 128), + dropped_events: projected.length - selected.length, + events: selected, + // Successful runs already have a generation share. Failed runs may + // have no completion, so retain only their actual model-facing + // request and bounded final blocker for useful diagnosis. + ...(status !== 'done' && Array.isArray(messages) && messages.length + ? { request_messages: scrubMessages(requestMessages(messages, finalContent), MAX_DIAGNOSTIC_REQUEST_BUDGET) } : {}), + ...(status !== 'done' && typeof finalContent === 'string' && finalContent.trim() + ? { final_content: scrubText(finalContent, 10_000) } : {}), + }, + }, + }, + }; +} + function localStorageArea() { const api = (typeof browser !== 'undefined' && browser?.storage) ? browser @@ -346,6 +443,15 @@ export async function enqueueShareGeneration(item) { return true; } +export async function enqueueShareDiagnostic(item) { + if (!item?.id || item.kind !== 'diagnostic' || !item.diagnostic?.event) return false; + await updateOutbox(current => { + if (current.some(entry => entry?.id === item.id)) return current; + return [...current, { ...item, queued_at: Date.now() }]; + }); + return true; +} + // Drop queued entries the predicate no longer consents to (e.g. the user // revoked "share queries for research" after an offline/failed run queued an // entry). Run before every flush so revocation is honored immediately before @@ -395,18 +501,19 @@ async function flushShareOutboxNow(transportProvider, shouldSend) { } let result; try { - result = await transportProvider.sendShareGeneration(entry.session_id, { - client_share_id: entry.id, - provider: entry.provider, - provider_name: entry.provider_name, - model: entry.model, - mode: entry.mode, - // The Compass intake contract reserves request for an object; persist - // the compact message array in the existing outbox shape, then wrap it - // only at delivery so queued entries stay backward-compatible. - request: { messages: entry.request }, - response: entry.response, - }); + result = entry.kind === 'diagnostic' + ? await transportProvider.sendShareDiagnostic(entry.session_id, entry.diagnostic) + : await transportProvider.sendShareGeneration(entry.session_id, { + client_share_id: entry.id, + provider: entry.provider, + provider_name: entry.provider_name, + model: entry.model, + mode: entry.mode, + // The Compass intake contract reserves request for an object; + // retain the compact array in old queued entries until delivery. + request: { messages: entry.request }, + response: entry.response, + }); } catch { result = { ok: false, retryable: true }; } diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index 1c3e8f5a95..32d8e22c0f 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -762,8 +762,8 @@ export default { 'st.providers.compat.value.developer': 'Developer', 'st.providers.webbrain_data_use.body': 'Free daily WebBrain Compass usage is included. While Help Improve WebBrain is on by default, selected Compass conversations may be retained and used for evaluation, improvement, fine-tuning, and training. Turn it off in General → Advanced to exclude future Compass interactions from those uses. Local-model and bring-your-own API requests are only collected by WebBrain when you turn on the per-provider “Share queries for research” option. {privacyLink}. For more usage, subscribe at {subscribeLink}. Manage billing at {accountLink}.', 'st.providers.share_research.label': 'Share queries for research', - 'st.providers.share_research.hint': 'Send prompts and responses from this provider to WebBrain for evaluation and improvement, including the provider and model used. Images and binary attachments are stripped and text is truncated before sharing; remaining text is sent as-is.', - 'st.providers.share_research.confirm': 'Share queries from this provider with WebBrain for research?\n\nWhen on, your prompts, responses, and tool interactions with this provider will be sent to WebBrain for evaluation and improvement, together with the provider and model name. Text is sent as-is after stripping images and truncating long content, so avoid sharing sensitive personal data. You can turn this off at any time to stop future sharing.', + 'st.providers.share_research.hint': 'Send bounded prompts, responses, tool interactions, and diagnostic trace metadata (steps, tool names, statuses, errors, and timings) from this provider to WebBrain for evaluation and improvement. Images and binary attachments are stripped; text is truncated before sharing.', + 'st.providers.share_research.confirm': 'Share queries and diagnostic traces from this provider with WebBrain for research?\n\nWhen on, your prompts, responses, tool interactions, and bounded diagnostic trace metadata—including failed runs, tool names, statuses, errors, and timings—will be sent to WebBrain with the provider and model name. Screenshots and binary attachments are not uploaded. Other text is sent as-is after truncation, so avoid sharing sensitive personal data. You can turn this off at any time to stop future sharing.', 'st.providers.webbrain_note.body': 'Free daily WebBrain Compass usage is included. Requests go through api.webbrain.one; by default we log metadata for quota and debugging, not prompt text, page content, screenshots, or model responses. {privacyLink}. For more usage, subscribe at {subscribeLink}. Manage billing at {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Privacy policy', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 35c66d5c98..06d10b2dd8 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -1027,8 +1027,8 @@ export default { "st.display.help_improve.desc_html": "Seçili WebBrain Compass etkileşimlerinin saklanmasına ve değerlendirme, iyileştirme, ince ayar ve eğitim için kullanılmasına izin verin. Varsayılan olarak açıktır. Gelecekteki Compass etkileşimlerinin bu amaçlarla kullanılmasını önlemek için kapatın. WebBrain, yerel model ve kendi API isteklerinizi yalnızca “Araştırma için sorguları paylaş” seçeneğini açtığınız sağlayıcılardan toplar. Gizlilik politikası →", "st.providers.webbrain_data_use.body": "Ücretsiz günlük WebBrain Compass kullanımı dahildir. WebBrain’i İyileştirmeye Yardım Et varsayılan olarak açıkken, seçili Compass konuşmaları değerlendirme, iyileştirme, ince ayar ve eğitim için saklanabilir ve kullanılabilir. Gelecekteki Compass etkileşimlerini bu kullanımların dışında tutmak için Genel → Gelişmiş bölümünden kapatın. WebBrain, yerel model ve kendi API isteklerinizi yalnızca ilgili sağlayıcıda “Araştırma için sorguları paylaş” seçeneğini açtığınızda toplar. {privacyLink}. Daha fazla kullanım için {subscribeLink} adresinden abone olun. Faturalandırmayı {accountLink} adresinden yönetin.", 'st.providers.share_research.label': "Araştırma için sorguları paylaş", - 'st.providers.share_research.hint': "Bu sağlayıcının istemlerini ve yanıtlarını, kullanılan sağlayıcı ve model bilgisiyle birlikte değerlendirme ve iyileştirme amacıyla WebBrain’e gönderir. Görseller ve ikili ekler paylaşılmadan önce kaldırılır, metin kısaltılır; kalan metin olduğu gibi gönderilir.", - 'st.providers.share_research.confirm': "Bu sağlayıcının sorgularını araştırma için WebBrain ile paylaşılsın mı?\n\nAçıkken istemleriniz, yanıtlarınız ve bu sağlayıcıyla araç etkileşimleriniz, sağlayıcı ve model adıyla birlikte değerlendirme ve iyileştirme amacıyla WebBrain’e gönderilir. Metin, görseller kaldırılıp uzun içerikler kısaltıldıktan sonra olduğu gibi gönderilir; bu yüzden hassas kişisel verileri paylaşmaktan kaçının. Gelecekteki paylaşımları durdurmak için bunu istediğiniz zaman kapatabilirsiniz.", + 'st.providers.share_research.hint': "Bu sağlayıcının sınırlı istemlerini, yanıtlarını, araç etkileşimlerini ve tanılama izi metaverilerini (adımlar, araç adları, durumlar, hatalar ve süreler) değerlendirme amacıyla WebBrain’e gönderir. Görseller ve ikili ekler kaldırılır; metin kısaltılır.", + 'st.providers.share_research.confirm': "Bu sağlayıcının sorguları ve tanılama izleri araştırma için WebBrain ile paylaşılsın mı?\n\nAçıkken istemleriniz, yanıtlarınız, araç etkileşimleriniz ve başarısız çalıştırmalar dâhil sınırlı tanılama izi metaverileri (araç adları, durumlar, hatalar ve süreler) sağlayıcı ve model adıyla birlikte gönderilir. Ekran görüntüleri ve ikili ekler yüklenmez. Diğer metin kısaltıldıktan sonra olduğu gibi gönderilir; hassas kişisel veri paylaşmamaya dikkat edin. Gelecekteki paylaşımları durdurmak için bunu istediğiniz zaman kapatabilirsiniz.", 'st.providers.compat.title': 'Gelişmiş model uyumluluğu', 'st.providers.compat.blurb': 'Model veya uç nokta farklı bir istek sözleşmesi belgelemedikçe bunları Otomatik bırakın.', 'st.providers.compat.preset': 'Uyumluluk ön ayarı', diff --git a/test/run.js b/test/run.js index 5abc95376f..4ea955e747 100644 --- a/test/run.js +++ b/test/run.js @@ -13550,6 +13550,90 @@ test('Share-for-research caps count wrapper messages and serialized overhead', ( assert.match(item.request.at(-1).content, /^cap249-/, 'tail lost to cap accounting'); }); +test('research sharing includes a bounded content-free diagnostic timeline for failed model runs', () => { + for (const outbox of [SHARE_OUTBOX_CH, SHARE_OUTBOX_FX]) { + const events = [ + { seq: 1, kind: 'llm_request', data: { step: 1, phase: 'main', messages: [{ role: 'user', content: 'private prompt' }] } }, + { seq: 2, kind: 'tool', data: { step: 1, name: 'click', args: { password: 'secret' }, result: { success: false, error: 'private result', code: 'not_found' }, latencyMs: 12 } }, + { seq: 3, kind: 'screenshot', data: { step: 1, imageData: 'data:image/png;base64,secret' } }, + ]; + const item = outbox.buildShareDiagnosticItem({ + runId: 'run_123', events, status: 'failed', model: 'outside-model', mode: 'act', + browserTarget: 'chrome', provider: 'local-openai-proxy', provider_id: 'instance_1', + }); + assert.equal(item.kind, 'diagnostic'); + assert.equal(item.diagnostic.event.data.status, 'failed'); + assert.equal(item.diagnostic.event.data.events[1].resultStatus, 'error'); + assert.equal(item.diagnostic.event.data.events[1].resultErrorCode, 'not_found'); + const projectedTool = outbox.buildShareDiagnosticItem({ + runId: 'already-projected', status: 'failed', events: [events[0], { + seq: 2, kind: 'tool', data: { step: 1, name: 'click', resultStatus: 'error', resultErrorCode: 'timeout' }, + }], + }); + assert.equal(projectedTool.diagnostic.event.data.events[1].resultStatus, 'error'); + assert.equal(projectedTool.diagnostic.event.data.events[1].resultErrorCode, 'timeout'); + for (const secret of ['private prompt', 'password', 'private result', 'imageData', 'data:image']) { + assert.equal(JSON.stringify(item).includes(secret), false, `diagnostic leaked ${secret}`); + } + assert.equal(outbox.buildShareDiagnosticItem({ runId: 'local', events: [events[1]], status: 'done' }), null, 'local-only paths must not be uploaded'); + const failedWithContext = outbox.buildShareDiagnosticItem({ + runId: 'failed-context', events, status: 'failed', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Please finish this' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,secret' } }] }], + finalContent: 'The provider timed out.', + }); + assert.equal(failedWithContext.diagnostic.event.data.request_messages[0].content.length, 1); + assert.equal(failedWithContext.diagnostic.event.data.final_content, 'The provider timed out.'); + assert.equal(JSON.stringify(failedWithContext).includes('data:image'), false); + const largeFailure = outbox.buildShareDiagnosticItem({ + runId: 'large-failed-context', events, status: 'failed', + messages: Array.from({ length: 140 }, (_, index) => ({ role: 'user', content: `Turn ${index}: ${'detail '.repeat(200)}` })), + }); + assert.ok(JSON.stringify(largeFailure.diagnostic.event.data.request_messages).length <= 80_000, + 'failed diagnostic requests must remain parseable under the Cloud intake limit'); + assert.match(largeFailure.diagnostic.event.data.request_messages.at(-1).content, /^Turn 139:/, + 'diagnostic request budget must preserve the most recent task context'); + const many = Array.from({ length: 200 }, (_, index) => ({ seq: index + 1, kind: 'llm_request', data: { step: index + 1, phase: 'main' } })); + const capped = outbox.buildShareDiagnosticItem({ runId: 'long', events: many, status: 'done' }); + assert.equal(capped.diagnostic.event.data.events.length, 80); + assert.equal(capped.diagnostic.event.data.dropped_events, 120); + assert.equal(capped.diagnostic.event.data.events[0].seq, 1); + assert.equal(capped.diagnostic.event.data.events.at(-1).seq, 200); + } +}); + +test('research diagnostic outbox retries and honors provider consent revocation', async () => { + const originalChrome = globalThis.chrome; + const storage = {}; + globalThis.chrome = { storage: { local: { + async get(keys) { return { [keys[0]]: storage[keys[0]] }; }, + async set(values) { Object.assign(storage, values); }, + } } }; + try { + const entry = SHARE_OUTBOX_CH.buildShareDiagnosticItem({ + runId: 'outbox-run', events: [{ seq: 1, kind: 'llm_request', data: { step: 1 } }], + status: 'failed', provider_id: 'external-instance', + }); + assert.equal(await SHARE_OUTBOX_CH.enqueueShareDiagnostic({ ...entry, session_id: 'share_test' }), true); + let sent = 0; + const transport = { sendShareGeneration: async () => { throw new Error('wrong endpoint'); }, async sendShareDiagnostic(sessionId, payload) { + sent++; + assert.equal(sessionId, 'share_test'); + assert.equal(payload.event.kind, 'diagnostic_trace'); + return sent === 1 ? { ok: false, retryable: true } : { ok: true }; + } }; + assert.equal(await SHARE_OUTBOX_CH.flushShareOutbox(transport, () => true), 0); + assert.equal(await SHARE_OUTBOX_CH.flushShareOutbox(transport, () => true), 1); + assert.equal(sent, 2); + await SHARE_OUTBOX_CH.enqueueShareDiagnostic({ ...entry, session_id: 'share_test' }); + assert.equal(await SHARE_OUTBOX_CH.purgeShareGenerations(item => item.provider_id === 'external-instance'), 1); + assert.equal(await SHARE_OUTBOX_CH.flushShareOutbox(transport, () => true), 0); + assert.equal(sent, 2); + } finally { + if (originalChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = originalChrome; + } +}); + test('Share-for-research outbox persists retryable failures and removes acknowledged or rejected entries', async () => { const originalChrome = globalThis.chrome; const storage = {}; @@ -13863,9 +13947,16 @@ test('Firefox research transport blocks native permission revocation before uplo } } }; globalThis.fetch = async (url, options) => { requests.push({ url, options }); return new Response('', { status: 202 }); }; const result = await provider.sendShareGeneration('share_test', { request: [], response: { content: 'ok' } }); - assert.equal(requests.length, consent === true ? 1 : 0, `${consent}: native consent was bypassed`); + const diagnostic = await provider.sendShareDiagnostic('share_test', { event_id: 'diag_test', event: { kind: 'diagnostic_trace' } }); + assert.equal(requests.length, consent === true ? 2 : 0, `${consent}: native consent was bypassed`); assert.equal(result.ok, consent === true); + assert.equal(diagnostic.ok, consent === true); assert.equal(result.retryable, false); + assert.equal(diagnostic.retryable, false); + if (consent === true) { + assert.match(requests[1].url, /\/improvement\/diagnostic-traces$/); + assert.equal(JSON.parse(requests[1].options.body).session_id, 'share_test'); + } } } finally { globalThis.browser = originalBrowser; @@ -13873,6 +13964,21 @@ test('Firefox research transport blocks native permission revocation before uplo } }); +test('Chrome research diagnostic transport retains 404s for Cloud rollout ordering', async () => { + const originalFetch = globalThis.fetch; + try { + const provider = new OpenAIProviderCh({ providerName: 'webbrain-cloud', baseUrl: 'https://share.example.test/v1' }); + const requests = []; + globalThis.fetch = async (url, options) => { requests.push({ url, options }); return new Response('', { status: 404 }); }; + const result = await provider.sendShareDiagnostic('share_test', { event_id: 'diag_test', event: { kind: 'diagnostic_trace' } }); + assert.equal(requests.length, 1); + assert.match(requests[0].url, /\/improvement\/diagnostic-traces$/); + assert.equal(result.retryable, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('Share-for-research delivery stays opt-in and mirrored across both builds', () => { const chromeOutbox = fs.readFileSync(path.join(ROOT, 'src/chrome/src/trace/webbrain-share-outbox.js'), 'utf8'); const firefoxOutbox = fs.readFileSync(path.join(ROOT, 'src/firefox/src/trace/webbrain-share-outbox.js'), 'utf8'); @@ -13882,6 +13988,8 @@ test('Share-for-research delivery stays opt-in and mirrored across both builds', const settings = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/settings.js`), 'utf8'); const provider = fs.readFileSync(path.join(ROOT, `src/${browser}/src/providers/openai.js`), 'utf8'); assert.match(agent, /status === 'done'[\s\S]*hadProviderCompletion === true[\s\S]*shareQueriesForResearch === true[\s\S]*enqueueShareGeneration/, `${browser}: capture must require a provider completion and the per-provider toggle`); + assert.match(agent, /force: runOptions\?\.cloudRun === true \|\| \(provider\?\.config\?\.shareQueriesForResearch === true/, `${browser}: opted-in external runs must record diagnostics even when local tracing is off`); + assert.match(agent, /trace\.getRunEvents\(runId\)[\s\S]*enqueueShareDiagnostic/, `${browser}: completed recorder events must be queued with the same share consent`); assert.match(agent, /shareRequest/, `${browser}: capture must prefer the model-facing source-grounded request`); assert.match(agent, /shareRawResponse/, `${browser}: shared response must be the raw provider completion`); assert.match(agent, /rawSummary/, `${browser}: done-tool summaries must exclude appended presentation`); @@ -13909,6 +14017,7 @@ test('Share-for-research delivery stays opt-in and mirrored across both builds', assert.match(settings, /shareQueriesForResearch/, `${browser}: share toggle field missing from settings`); assert.match(settings, /!input\.checked[\s\S]*?confirm\(/, `${browser}: consent confirmation must guard turning the share toggle on`); assert.match(provider, /\/improvement\/generations/, `${browser}: share endpoint missing from the Compass provider transport`); + assert.match(provider, /\/improvement\/diagnostic-traces/, `${browser}: diagnostic endpoint missing from the Compass provider transport`); } });