This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -1,69 +1,66 @@
# Waggle Companion · Chrome MV3 extension
# Waggle Companion - Chrome MV3 extension
The browser-side hook for Waggle OS. Lets the user save any page or selection
to their workspace memory from anywhere on the web, without leaving the tab.
Browser-side capture for Waggle OS. It saves a page selection or the current
page to personal Waggle memory without leaving the tab.
This implements **FR-1** from the 2026-05-28 addictiveness audit — closes the
"external trigger surface" rubric gap (dim 1) for the non-coder personas
whose real workflow lives in browser tabs (researcher, journalist, marketer,
writer, retired teacher).
## What it does
## What it does (v0.1.0)
- The popup shows connection status and the active memory destination.
- Save Selection and Save Page send personal imported-memory captures.
- The context menu can save selected text.
- Pairing is explicit: generate a one-time code in Waggle Settings, then enter
it in the popup. The code is single-use and expires after ten minutes.
- **Popup** — shows connection status + the active workspace memory is saving to + two buttons (save selection / save page).
- **Right-click context menu** — "Save to Waggle memory" appears on any text selection.
- **Reuses existing sidecar endpoints** — `/api/browser-ext/session-token` for local token bootstrap, `/api/browser-ext/health` for status, and `/api/memory/frames` for ingest. No new ingest logic.
The popup never makes network requests. The MV3 background worker redeems the
code and stores only the resulting scoped credential in
`chrome.storage.local.companionToken`. Legacy `sessionToken` values are deleted
and never trusted. A rejected credential is removed; captures are never replayed
or automatically re-paired.
## How to load (developer mode, local install)
## Load locally
1. Start the Waggle sidecar with one of these env vars set so its CORS layer accepts the dev extension origin:
- **Quickest (dev only):** `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1` — accepts any `chrome-extension://*` origin. Never set this in production.
- **Production-shaped:** `WAGGLE_BROWSER_EXT_IDS=<your-extension-id>` (comma-separated for multiple IDs). Pin once you have the loaded extension's ID from `chrome://extensions`.
2. Open `chrome://extensions` in Chrome (or Edge, or any Chromium browser).
3. Toggle **Developer mode** on (top right).
4. Click **Load unpacked** and pick this folder (`apps/browser-ext`).
5. Copy the extension ID shown on the card.
6. Restart the sidecar with `WAGGLE_BROWSER_EXT_IDS=<that-id>` for the production-shaped path, or skip this if you used the dev escape hatch in step 1.
7. Pin the extension to the toolbar.
8. Open the popup — you should see a green dot + "Connected" + the memory destination.
1. Start Waggle with an extension allowlist:
- Development only: `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1`
- Production-shaped: `WAGGLE_BROWSER_EXT_IDS=<extension-id>`
2. Open `chrome://extensions`, enable Developer mode, and choose Load unpacked.
3. Select this `apps/browser-ext` directory.
4. If using the production-shaped allowlist, copy the installed extension ID
into `WAGGLE_BROWSER_EXT_IDS` and restart Waggle.
5. In Waggle Settings -> Advanced, generate a Browser Companion code.
6. Enter that code in the extension popup.
Without either env var set, the sidecar rejects Browser Companion token bootstrap and the popup shows setup recovery copy. The extension stores the sidecar session token in `chrome.storage.local.sessionToken` after a successful bootstrap and sends it as a bearer token on save/status calls.
## What's deliberately NOT in v0.1.0
- **Side panel chat** — the Chrome side panel for asking questions about the current page. Designed for v0.2; would call `/api/chat`.
- **One-time-code pairing UX** — v0.1.0 bootstraps the local session token for an env-allowlisted extension ID. A more explicit desktop Settings pairing flow with a one-time code is future hardening.
- **Cross-browser packaging** — manifest is MV3, works on Chrome/Edge/Brave. Firefox needs a parallel manifest shape.
- **Article extraction** — page text capture is `document.body.innerText` capped at 12k chars. Reader-mode style extraction belongs server-side.
- **Icons** — using browser default. Wire in real icons when we have the brand asset.
- **Build step** — vanilla JS, no bundler. Simpler MVP; if we add typescript/react for the side panel later, add Vite then.
Never enable `WAGGLE_DEV_ALLOW_ANY_EXTENSION` in a production build.
## Files
| File | Role |
|---|---|
| `manifest.json` | MV3 manifest — permissions, action, content script, background |
| `popup.html` | Popup UI shell (dark Hive theme inline) |
| `popup.js` | Popup logic — health refresh, selection read, save dispatch |
| `content.js` | Per-page content script — extracts selection + body text on demand |
| `background.js` | Service worker — fetch wrapper to the Waggle sidecar |
| `manifest.json` | MV3 permissions, popup, content script, background worker |
| `popup.html` | Popup UI |
| `popup.js` | Pairing, health, and capture UI logic |
| `content.js` | On-demand selection and page extraction |
| `background.js` | Pairing and authenticated loopback requests |
## Sidecar contract
- `GET /api/browser-ext/session-token` -> `{ token }` for allowlisted extension origins / MV3 service-worker requests.
- `GET /api/browser-ext/health` -> `{ ok: true, version, activeWorkspaceId, activeWorkspace }` (defined in `packages/server/src/local/routes/browser-ext.ts`; `activeWorkspace` is legacy compatibility)
- `POST /api/memory/frames` — existing endpoint, body `{ content, source: 'import', importance: 'normal' | 'low' }`. Dedup runs server-side.
- `POST /api/browser-ext/pair` redeems an allowlisted extension's valid code.
- `GET /api/browser-ext/health` reports local connection and workspace state.
- `POST /api/memory/frames` accepts only `source: "import"`, personal scope,
and normal/low importance for the paired credential.
## Verification
The global desktop session token is never exposed to the extension.
After loading the unpacked extension:
1. Click the extension icon on any web page → status should read "Connected" with a green dot.
2. Select some text → "Save selection to memory" enables → click it → toast reads "Saved to Waggle memory ✓".
3. Open the Waggle desktop → Memory app → confirm the new frame appears with source `import`.
## Manual smoke test
## Roadmap (post-MVP)
1. Open the popup before pairing: it must show the code form and disable
authenticated capture.
2. Pair with a fresh Settings code: the popup must show Connected.
3. Save a selection and confirm a personal frame with source `import` appears.
4. Revoke in Settings: the next health/save request must require a new code and
must not retry the previous capture.
- v0.2 — side panel with chat about the current page (calls `/api/chat`).
- v0.3 — pre-load Waggle's "Ask about this page" agent on important pages (configurable).
- v0.4 — Firefox MV2 parallel manifest.
- v0.5 — explicit auth pairing UX (one-time code from desktop Settings).
## Deferred
- Side-panel chat about the current page.
- Firefox-specific packaging.
- Reader-mode extraction and branded icons.

View File

@@ -1,11 +1,9 @@
// Waggle Companion background service worker — routes messages from
// popup.js to the local Waggle sidecar at 127.0.0.1:3333.
//
// MV3 service workers are short-lived; we don't keep any state here
// beyond per-message handlers. The sidecar's session token (if any) is
// pulled from chrome.storage.local on every request.
// Waggle Companion background service worker — the only extension process that
// talks to the loopback sidecar. The popup supplies a one-time code; only the
// resulting scoped credential is persisted.
const SIDECAR = 'http://127.0.0.1:3333';
const PAIRING_REQUIRED = 'Browser Companion not paired. Generate a one-time code in Waggle Settings.';
async function readJson(response) {
try {
@@ -18,56 +16,77 @@ async function readJson(response) {
function authErrorMessage(status, body) {
const code = body?.code;
if (code === 'EXTENSION_NOT_ALLOWLISTED') {
return 'Browser Companion is not allowlisted. Add this extension ID to Waggle, restart Waggle, then try again.';
return 'Browser Companion not allowlisted. Add the extension ID in Waggle, restart Waggle, and try again.';
}
if (status === 401 && code === 'INVALID_TOKEN') {
return 'Browser Companion pairing expired. Reopen Waggle desktop, then try again.';
if (code === 'PAIRING_CODE_INVALID') {
return 'Invalid or expired pairing code. Generate a new one-time code in Waggle Settings.';
}
if (status === 401 && (code === 'MISSING_TOKEN' || !code)) {
return 'Browser Companion is not paired. Start Waggle desktop, then try again.';
if (status === 401 && (code === 'INVALID_TOKEN' || code === 'MISSING_TOKEN' || !code)) {
return PAIRING_REQUIRED;
}
return body?.error || `HTTP ${status}`;
}
async function requestSessionToken() {
const r = await fetch(`${SIDECAR}/api/browser-ext/session-token`, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-Waggle-Extension-Id': chrome.runtime.id,
},
});
const data = await readJson(r);
if (!r.ok || !data?.token) {
return { ok: false, error: authErrorMessage(r.status, data) };
}
await chrome.storage.local.set({ sessionToken: data.token });
return { ok: true, token: data.token };
async function removeLegacyToken() {
await chrome.storage.local.remove('sessionToken');
}
async function getAuthHeaders(options = {}) {
async function getAuthHeaders() {
try {
const { sessionToken } = await chrome.storage.local.get(['sessionToken']);
if (sessionToken) return { headers: { Authorization: `Bearer ${sessionToken}` } };
if (!options.pair) return { headers: {} };
const paired = await requestSessionToken();
if (!paired.ok) return { headers: {}, error: paired.error };
return { headers: { Authorization: `Bearer ${paired.token}` } };
const { companionToken, sessionToken } = await chrome.storage.local.get([
'companionToken',
'sessionToken',
]);
if (sessionToken) await removeLegacyToken();
if (!companionToken) return { headers: {}, error: PAIRING_REQUIRED };
return { headers: { Authorization: `Bearer ${companionToken}` } };
} catch (err) {
return { headers: {}, error: String(err) };
}
}
async function pairWithCode(rawCode) {
const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : '';
if (!/^[A-HJ-NP-Z2-9]{8}$/.test(code)) {
return { ok: false, error: 'Enter the 8-character code shown in Waggle Settings.' };
}
try {
const response = await fetch(`${SIDECAR}/api/browser-ext/pair`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Waggle-Extension-Id': chrome.runtime.id,
},
body: JSON.stringify({ code }),
});
const data = await readJson(response);
if (!response.ok || typeof data?.token !== 'string') {
return { ok: false, error: authErrorMessage(response.status, data) };
}
await chrome.storage.local.set({ companionToken: data.token });
await removeLegacyToken();
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
async function health() {
try {
const auth = await getAuthHeaders({ pair: true });
const auth = await getAuthHeaders();
if (auth.error) return { ok: false, error: auth.error };
const r = await fetch(`${SIDECAR}/api/browser-ext/health`, {
const response = await fetch(`${SIDECAR}/api/browser-ext/health`, {
method: 'GET',
headers: { Accept: 'application/json', ...auth.headers },
});
if (!r.ok) return { ok: false, error: authErrorMessage(r.status, await readJson(r)) };
return await r.json();
const data = await readJson(response);
if (response.status === 401) {
await chrome.storage.local.remove('companionToken');
return { ok: false, error: PAIRING_REQUIRED };
}
if (!response.ok) return { ok: false, error: authErrorMessage(response.status, data) };
return data;
} catch (err) {
return { ok: false, error: String(err) };
}
@@ -75,30 +94,23 @@ async function health() {
async function saveMemory(payload) {
try {
const body = JSON.stringify({
content: payload.content,
source: payload.source || 'import',
importance: payload.importance || 'normal',
});
const auth = await getAuthHeaders({ pair: true });
const auth = await getAuthHeaders();
if (auth.error) return { saved: false, error: auth.error };
let r = await fetch(`${SIDECAR}/api/memory/frames`, {
const response = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth.headers },
body,
body: JSON.stringify({
content: payload.content,
source: payload.source || 'import',
importance: payload.importance || 'normal',
}),
});
if (r.status === 401) {
const paired = await requestSessionToken();
if (paired.ok) {
r = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${paired.token}` },
body,
});
}
const data = await readJson(response);
if (response.status === 401) {
await chrome.storage.local.remove('companionToken');
return { saved: false, error: PAIRING_REQUIRED };
}
if (!r.ok) return { saved: false, error: authErrorMessage(r.status, await readJson(r)) };
const data = await r.json();
if (!response.ok) return { saved: false, error: authErrorMessage(response.status, data) };
return {
saved: data?.saved ?? true,
duplicate: data?.duplicate ?? false,
@@ -109,16 +121,16 @@ async function saveMemory(payload) {
}
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
if (msg?.type === 'health') sendResponse(await health());
else if (msg?.type === 'save-memory') sendResponse(await saveMemory(msg));
if (message?.type === 'health') sendResponse(await health());
else if (message?.type === 'pair') sendResponse(await pairWithCode(message.code));
else if (message?.type === 'save-memory') sendResponse(await saveMemory(message));
else sendResponse({ error: 'unknown message type' });
})();
return true; // keep channel open for async sendResponse
return true;
});
// Context menu: right-click selection → "Save to Waggle memory"
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'waggle-save-selection',
@@ -134,7 +146,6 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
source: 'import',
importance: 'normal',
});
// Best-effort badge feedback (MV3 has no toast API in background).
await chrome.action.setBadgeText({ text: result.saved ? '✓' : '!' });
await chrome.action.setBadgeBackgroundColor({ color: result.saved ? '#10b981' : '#ef4444' });
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2500);

View File

@@ -74,6 +74,19 @@
}
#toast.ok { color: var(--success); border-color: var(--success); }
#toast.err { color: var(--danger); border-color: var(--danger); }
#pair-form {
margin-bottom: 10px; padding: 8px; border: 1px solid var(--border);
border-radius: 8px; background: #101014;
}
#pair-form label { display: block; margin-bottom: 6px; color: var(--fg); }
.pair-row { display: flex; gap: 6px; }
#pair-code {
width: 100%; min-width: 0; padding: 7px 8px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg); color: var(--fg);
font: 600 14px/1 monospace; letter-spacing: .12em; text-transform: uppercase;
}
#pair-code:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
#pair-submit { width: auto; margin: 0; }
footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted); text-align: center; }
a { color: var(--primary); text-decoration: none; }
</style>
@@ -87,6 +100,15 @@
<div class="workspace">Memory destination: <strong id="workspace-name"></strong></div>
<form id="pair-form" hidden>
<label for="pair-code">One-time code from Waggle Settings</label>
<div class="pair-row">
<input id="pair-code" name="pair-code" maxlength="8" minlength="8"
pattern="[A-HJ-NP-Za-hj-np-z2-9]{8}" autocomplete="off" spellcheck="false" required>
<button class="primary" id="pair-submit" type="submit">Pair</button>
</div>
</form>
<button class="primary" id="save-selection" disabled>
<span class="icon">💾</span><span>Save selection to memory</span>
</button>

View File

@@ -12,6 +12,9 @@ const btnSelection = $('save-selection');
const btnPage = $('save-page');
const btnOpen = $('open-waggle');
const toast = $('toast');
const pairForm = $('pair-form');
const pairCode = $('pair-code');
const pairSubmit = $('pair-submit');
let cachedSelection = '';
let cachedPageMeta = null;
@@ -60,6 +63,7 @@ async function refreshHealth() {
// textContent (not innerHTML) — workspace names are user-controlled
// and could otherwise be XSS sinks in the extension context.
workspaceNameEl.textContent = formatMemoryDestination(reply);
pairForm.hidden = true;
} else {
throw new Error(reply?.error || 'No response');
}
@@ -67,11 +71,32 @@ async function refreshHealth() {
dot.className = 'dot disconnected';
statusText.textContent = 'Not connected';
workspaceNameEl.textContent = 'Unavailable';
pairForm.hidden = false;
const msg = err?.message || 'Start Waggle desktop on this machine, then re-open this popup.';
showToast(msg, 'err', { sticky: true });
}
}
async function pair(event) {
event.preventDefault();
const code = pairCode.value.trim().toUpperCase();
pairSubmit.disabled = true;
try {
const reply = await chrome.runtime.sendMessage({ type: 'pair', code });
if (!reply?.ok) {
showToast(reply?.error || 'Pairing failed.', 'err', { sticky: true });
return;
}
pairCode.value = '';
showToast('Browser Companion paired.', 'ok');
await refreshHealth();
} catch (err) {
showToast(err?.message || 'Pairing failed.', 'err', { sticky: true });
} finally {
pairSubmit.disabled = false;
}
}
async function readActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return null;
@@ -108,6 +133,7 @@ async function save(kind) {
showToast(reply.duplicate ? 'Already in memory.' : 'Saved to Waggle memory ✓', 'ok');
} else {
const msg = reply?.error || 'Save failed.';
if (isSetupError(msg)) pairForm.hidden = false;
showToast(msg, 'err', { sticky: isSetupError(msg) });
}
}
@@ -115,6 +141,7 @@ async function save(kind) {
btnSelection.addEventListener('click', () => save('selection'));
btnPage.addEventListener('click', () => save('page'));
btnOpen.addEventListener('click', () => chrome.tabs.create({ url: 'http://127.0.0.1:3333' }));
pairForm.addEventListener('submit', pair);
refreshHealth();
readActiveTab();