Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **A source hint for production builds, from the page's source maps.** When a minified build has
stripped React's `_debugSource` and Vue's `__file`, Pinpoint takes the clicked component's own
minified code, finds it in the scripts the page loaded, follows that script's `sourceMappingURL`
(or `SourceMap` header; `data:` maps and indexed maps too) and maps it back to the original
`file:line` and component name. The note's source hint says `method: "sourcemap"`. The service
worker fetches only the page's own origin (or another loopback port from a loopback page) — never
a third-party host — within 1.5 seconds and 8 MB per file; on any failure the note simply has no
file, as before. The VLQ decoder is ~40 lines in `extension/sourcemap.js`, no new dependency.

## [0.7.1] — 2026-10-20

### Added
Expand Down
18 changes: 14 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy

*Last updated: 18 October 2026. This describes Pinpoint 0.7 — the extension in `extension/` and
*Last updated: 20 October 2026. This describes Pinpoint 0.7 — the extension in `extension/` and
the bridge in `bridge/`, as published in this repository.*

Pinpoint is a developer tool that runs on your own machine. There is no Pinpoint account, no Pinpoint
Expand All @@ -16,7 +16,8 @@ this is assembled and posted to your local bridge:
- **the element** — tag, id, classes, a CSS selector, DOM path, rendered text, trimmed `outerHTML`,
`role`/`aria-*`/`data-*` attributes, bounding box, about 25 computed style properties, and a
fingerprint used to re-find the element after a re-render
- **the framework hint** where a dev build exposes it — React/Vue component chain and `file:line`
- **the framework hint** where a dev build exposes it — React/Vue component chain and `file:line` —
or, when a production build has stripped that, the `file:line` the page's own source map gives (below)
- **your comment** — the sentence you typed
- **a screenshot** — a PNG cropped to the element, captured by the extension's service worker
- **page errors and failed requests**, only when something went wrong (below)
Expand All @@ -42,6 +43,14 @@ recorded. Request and response bodies and headers are never read. The record is
extension's memory only, per tab, and is dropped when the tab closes or loads a new page. Nothing is
attached when nothing went wrong.

**Source maps.** When the framework hint finds no file (a production build), the extension's
service worker reads the page's own scripts and their source maps to find where the clicked component
came from: it downloads only scripts the page itself loaded, and only from the page's own origin (or,
on a loopback page, another loopback port), without cookies, at most 8 MB each, for at most 1.5
seconds. Scripts and maps on any other host are never requested. What it keeps is a file path, a
line and a component name in the note's source hint; the scripts and maps stay in the worker's
memory for a minute and are not stored or sent anywhere.

Before page errors and failed requests leave the browser, they are cut to 2000 characters and then
scrubbed. Query strings and fragments are dropped from URLs, but a stack frame's line:col is kept.
Passwords in URLs (`scheme://user:pass@host`) are removed. Bearer and Basic credentials, JWTs, long
Expand Down Expand Up @@ -75,7 +84,8 @@ for microphone permission the first time. Dictation is not offered on Firefox.

To `http://127.0.0.1:<port>` (7331 by default) and nowhere else. The extension contains no other
network destination — no telemetry endpoint, no remote code, no update service beyond the browser's
own. The bridge refuses any request carrying a web page's `Origin`, accepts an extension only with the
own. (The one other request it makes is the source-map lookup above: to the local page's own dev
server, for files that page already loaded.) The bridge refuses any request carrying a web page's `Origin`, accepts an extension only with the
token in `~/.pinpoint/token`, and only listens on loopback.

**The one place data leaves Pinpoint is the one you asked for:** the coding agent you connect. Over
Expand Down Expand Up @@ -116,7 +126,7 @@ leaves nothing behind.

| Permission | Why |
|---|---|
| `host_permissions: <all_urls>` | Chrome requires a broad host permission for `tabs.captureVisibleTab`, which takes the screenshot. A narrower permission does not grant that API. It is not used to read or inject into pages beyond the local-development matches below. |
| `host_permissions: <all_urls>` | Chrome requires a broad host permission for `tabs.captureVisibleTab`, which takes the screenshot. A narrower permission does not grant that API. It is not used to read or inject into pages beyond the local-development matches below; the only fetches it allows are the source-map lookup's, to a local page's own origin. |
| `activeTab`, `scripting` | Show the bar and the picker on the page you are annotating. |
| `tabs` | Know which tab is being captured, and keep pins in sync across tabs showing the same page. |
| `storage` | The settings listed above. |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,8 @@ element tag, id, classes, a CSS selector built from stable attributes w
attributes, bounding box, ~25 computed style properties, and a fingerprint
(tag + text + key attributes) used to verify a pin is still on the right element
source framework (react/vue/svelte/angular/astro), component chain,
file:line where the dev build exposes it
file:line where the dev build exposes it — or, in a production build, from the
page's own source maps (same origin only; method: "sourcemap")
screenshot PNG of just the element (+8px), long edge ≤1200px
context only when something went wrong: uncaught errors and failed requests from the
minute before, redacted in the browser
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Pinpoint moves text and screenshots from your browser to your coding agent, so t
- **Page context is untrusted and redacted.** Error messages, stack lines and failed-request URLs come from the page, like the element's text and HTML. The service worker checks their shape, keeps only the last 60 seconds and redacts them (`extension/redact.js`) before they reach the bridge; if the redaction script is missing, nothing is attached. Before page errors and failed requests leave the browser, they are cut to 2000 characters and then scrubbed. Query strings and fragments are dropped from URLs, but a stack frame's line:col is kept. Passwords in URLs (`scheme://user:pass@host`) are removed. Bearer and Basic credentials, JWTs, long hex and base64 strings are replaced. So are the values of key=value and "key":"value" pairs whose name ends in a secret word (api key, token, secret, password/passwd/pwd, session id, sid, signature/sig, auth), including prefixed names such as DB_PASSWORD or csrf_token. Each string is capped at 300 characters. In the agent's markdown every entry is flattened to one line with emphasis and backticks removed, under a label saying it is page data, not instructions.
- **Recorded steps are scraped page data.** They appear in the markdown below the "untrusted data" line, flattened (no newlines, emphasis or backticks), and the store caps them at 10. Only trusted `pointerdown` events in the top frame become steps, and only the top frame may start, add to or stop a recording.
- **New routes keep the same guards.** `POST /annotations/:id/accept` and `POST /annotations/:id/after` sit behind the same Host, Origin and `x-pinpoint-token` checks as every other annotation route. `/after` only re-checks a resolved note's element in a tab already showing that page, and only while the extension has been heard from in the last two minutes. `/android` and `/android/annotations` accept only the bridge's own origin or a token-carrying extension, like the `/ios` routes.
- **The source-map lookup never leaves the page's origin.** When a production build has no framework debug info, the service worker (`extension/sourcemap.js`) fetches the scripts the page loaded and their source maps to resolve the source hint. It runs only for a local development page, takes the origin from the browser's own record of the asking frame (never from the message), and fetches only URLs on that origin — or another loopback port when the page is on loopback — with redirects refused, no cookies, an 8 MB cap per file and a 1.5 s budget. A map on a CDN or any third-party host is not requested. Running in the worker rather than the page means the page's CSP and any `fetch` it has wrapped play no part, and the page cannot see or answer the requests. Only the resulting file, line and component name go into the note.
- **The extension only injects itself into local development pages** — localhost, the loopback range, private LAN addresses, `.local`/`.test`/`.localhost` hosts and `file://`. Anywhere else it is not present unless you turn it on for that tab from the toolbar.
- **The `<all_urls>` host permission** is required by Chrome for `tabs.captureVisibleTab` (the element screenshot) and nothing else; a narrower permission does not grant that API.
- **Screenshots never become loose image files.** They are stored base64-encoded inside `~/.pinpoint/annotations.json`, and a queued capture is abandoned rather than taken if the page changed first — you never get a screenshot of the wrong page.
Expand Down
2 changes: 1 addition & 1 deletion bridge/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function toMarkdown(a, { heading = true, channel = "mcp" } = {}) {
lines.push(`- Text as rendered: "${flat(e.text)}"` + (tt && tt !== "none" ? ` (CSS text-transform: ${tt} — the source string will differ, grep the HTML below instead)` : ""));
}
if (s.components?.length) lines.push(`- Component chain (${flat(s.framework)}): ${s.components.map(flat).join(" ← ")}`);
if (s.file) lines.push(`- Source file: ${flat(s.file)}${s.line ? ":" + flat(s.line) : ""}`);
if (s.file) lines.push(`- Source file: ${flat(s.file)}${s.line ? ":" + flat(s.line) : ""}` + (s.method === "sourcemap" ? " (found through the page's source map)" : ""));
else if (!s.components?.length) lines.push(`- Source file: not detected (no framework metadata on this page) — find the element by grepping the HTML below`);
const attrs = Object.entries(s.attributes || {});
if (attrs.length) lines.push(`- Markers: ${attrs.map(([k, v]) => `${flat(k)}="${flat(v)}"`).join(", ")}`);
Expand Down
10 changes: 9 additions & 1 deletion extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
const api = globalThis.browser ?? globalThis.chrome;

// pinpointRedact, for page context. A Firefox build lists redact.js in background.scripts instead.
if (typeof importScripts === "function") { try { importScripts("redact.js"); } catch {} }
// pinpointSourceMap, for the source-hint fallback; Firefox lists it in background.scripts too.
if (typeof importScripts === "function") { try { importScripts("redact.js"); } catch {} try { importScripts("sourcemap.js"); } catch {} }

const DEFAULT_PORT = 7331;
const WATCH_ALARM = "pinpoint-watch";
Expand Down Expand Up @@ -710,6 +711,13 @@ api.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!tab?.id) return { ok: false, error: "No active tab." };
return togglePicker(tab, true);
}
// The source-hint fallback (sourcemap.js). Here, not in the page: the page's CSP and any fetch
// it has wrapped do not apply, and the page never sees the requests. The origin checked is the
// asking frame's own, from the browser — never one the message names.
case "sourcemap": {
if (!sender.url || !isLocalDev(sender.url) || !globalThis.pinpointSourceMap) return null;
return globalThis.pinpointSourceMap.resolve({ snippet: msg.snippet, scripts: msg.scripts }, sender.url);
}
case "canRun": {
const [tab] = await api.tabs.query({ active: true, currentWindow: true });
const reason = await whyNot(tab?.url);
Expand Down
44 changes: 41 additions & 3 deletions extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,39 @@
return { framework: null, components: [], file: null, line: null, column: null, attributes: {} };
}

// A production build has stripped the framework's debug info, so the hint has no file: ask the
// worker to trace the component through the page's own source maps (sourcemap.js). Bounded, and
// any failure just leaves the hint as it was.
// Started when the popover opens (paintMeta) and reused at Send, so the lookup has usually
// finished — or at least filled the worker's cache — by the time the comment is written.
const mapLookups = new WeakMap();
function mapLookup(el) {
if (!mapLookups.has(el)) mapLookups.set(el, mapSource(el).catch(() => null));
return mapLookups.get(el);
}
async function mapSource(el) {
let raw = null;
const on = (e) => { raw = e.detail; };
el.addEventListener("pinpoint:probe-result", on, { once: true });
el.dispatchEvent(new CustomEvent("pinpoint:probe", { bubbles: true }));
el.removeEventListener("pinpoint:probe-result", on);
let probe = null;
try { probe = JSON.parse(raw); } catch {}
if (typeof probe?.snippet !== "string") return null;
const r = await chrome.runtime.sendMessage({ type: "sourcemap", snippet: probe.snippet, scripts: probe.scripts }).catch(() => null);
if (typeof r?.file !== "string") return null;
return { ...r, fnName: probe.name };
}
async function applyMapSource(el, hint) {
const r = await Promise.race([mapLookup(el), new Promise((res) => setTimeout(res, 1800))]); // the worker stops at 1.5 s
if (!r) return;
Object.assign(hint, { file: r.file, line: r.line ?? null, column: r.column ?? null, method: "sourcemap" });
if (r.name) {
const i = hint.components.indexOf(r.fnName);
if (i >= 0) hint.components[i] = r.name; else hint.components.unshift(r.name);
}
}

// The page's recent uncaught errors, from inspector.js in the MAIN world. Raw and untrusted: the
// worker validates and redacts them before they reach the bridge.
function pageErrors() {
Expand Down Expand Up @@ -1625,6 +1658,7 @@
// repaint it without wiping whatever you have already typed.
function paintMeta(el, region) {
const hint = sourceHint(el);
if (!hint.file && /^(react|vue)$/.test(hint.framework || "")) mapLookup(el);
const comp = hint.components[0] ? [elt("b", null, `<${hint.components[0]}>`), " "] : [];
const file = hint.file ? ` · ${hint.file}${hint.line ? ":" + hint.line : ""}` : "";
if (flowSteps) {
Expand Down Expand Up @@ -1876,6 +1910,8 @@
// A region annotation is an element annotation on its anchor, plus the box. Everything
// downstream — selector, fingerprint, pin re-finding, styles — keeps working unchanged.
if (opts.region) a.region = opts.region;
// Only React and Vue leave a component function to look up; anything else adds no await here.
if (!a.source.file && /^(react|vue)$/.test(a.source.framework || "")) { try { await applyMapSource(el, a.source); } catch {} }
const errors = pageErrors();
if (errors.length) a.context = { errors };
return a;
Expand All @@ -1894,7 +1930,7 @@
];
if (a.element.text) lines.push(`**Text:** "${a.element.text}"`);
if (s.components?.length) lines.push(`**Component chain (${s.framework}):** ${s.components.join(" ← ")}`);
if (s.file) lines.push(`**Source:** ${s.file}${s.line ? ":" + s.line : ""}`);
if (s.file) lines.push(`**Source:** ${s.file}${s.line ? ":" + s.line : ""}${s.method === "sourcemap" ? " (via source map)" : ""}`);
lines.push(`**Size:** ${a.element.rect.width}×${a.element.rect.height}px at viewport ${a.page.viewport.width}×${a.page.viewport.height}`);
const st = Object.entries(a.element.styles).map(([k, v]) => `${k}: ${v}`).join("; ");
if (st) lines.push(``, `**Computed styles:** ${st}`);
Expand All @@ -1919,12 +1955,14 @@
try {
const region = selectedRegion;
const page = selectedPage, steps = flowSteps;
// A page note is cropped to the window: the whole visible page, not the <body> box. Measured
// before the note is built: a source-map lookup in there can take a moment, and the crop is of
// what was on screen when Send was pressed (the worker skips it if the page scrolled since).
const capture = await captureRect(el, page ? { x: window.scrollX, y: window.scrollY, width: window.innerWidth, height: window.innerHeight } : region);
const a = await buildAnnotation(el, comment, region ? { region } : {});
if (draft) a.status = "draft";
if (page) a.kind = "page";
if (steps) { a.kind = "flow"; a.steps = steps; }
// A page note is cropped to the window: the whole visible page, not the <body> box.
const capture = await captureRect(el, page ? { x: window.scrollX, y: window.scrollY, width: window.innerWidth, height: window.innerHeight } : region);
if (capture) hideOverlayForCapture();
// The worker stores the comment first and answers immediately, then takes and attaches the
// crop on its own. Nothing here is on the page's lifetime.
Expand Down
27 changes: 27 additions & 0 deletions extension/inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@
return hint;
}

// For the source-map fallback (sourcemap.js, in the worker): the nearest component's own source
// text and the page's scripts, so the worker can find which bundle holds it. Only asked when the
// hint above found no file — a production build. The function's text is the page's own code.
function mapProbe(el) {
let fn = null;
for (let node = el; node && !fn; node = node.parentElement) {
const key = Object.keys(node).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"));
for (let f = key && node[key], i = 0; f && i < 80 && !fn; f = f.return, i++) {
const t = f.type;
fn = typeof t === "function" ? t : [t?.render, t?.type, t?.type?.render].find((x) => typeof x === "function") || null;
}
const v = node.__vueParentComponent?.type || node.__vue__?.$options;
if (!fn && v) fn = [v.setup, v.render].find((x) => typeof x === "function") || null;
}
if (!fn) return null;
const src = Function.prototype.toString.call(fn);
if (src.length < 16 || /\{\s*\[native code\]\s*\}/.test(src)) return null;
const scripts = new Set([...document.scripts].map((s) => s.src).filter(Boolean));
try { for (const r of performance.getEntriesByType("resource")) if (r.initiatorType === "script") scripts.add(r.name); } catch {}
return { snippet: src.slice(0, 120), name: fn.name || null, scripts: [...scripts].slice(0, 30) };
}
document.addEventListener("pinpoint:probe", (e) => {
let r = null;
try { r = mapProbe(e.target); } catch {}
e.target.dispatchEvent(new CustomEvent("pinpoint:probe-result", { detail: JSON.stringify(r) }));
}, true);

// ---------- page context ----------
// What went wrong on the page lately: uncaught errors and unhandled rejections, newest last. Only
// listening — console is never wrapped, so DevTools keeps showing the page's own call sites. This
Expand Down
Loading
Loading