Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ data
tmp-*.mjs
*.png
!apps/web/public/icons/**/*.png
!apps/browser-extension/public/icons/*.png
*.jpg
*.jpeg
*.gif
*.webp
.media
artifacts/
.playwright-mcp/
test-results/
playwright-report/
Expand Down
67 changes: 67 additions & 0 deletions apps/browser-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Dispatch Browser Feedback

Select an element on a live web page, add a comment, and send the bounded DOM
context to a running Dispatch agent.

## Build and load

From the Dispatch repository root:

```sh
pnpm --filter @dispatch/browser-extension build
```

Then open `chrome://extensions`, enable Developer mode, choose **Load
unpacked**, and select `apps/browser-extension/dist`.

To create a zip that can be distributed or uploaded for review, run:

```sh
pnpm --filter @dispatch/browser-extension package
```

The archive is written to `apps/browser-extension/dist/dispatch-browser-feedback.zip`.

Click the extension toolbar icon to open its side panel. Enter the URL of your
Dispatch instance, approve the pairing request in Dispatch, return to the page
you want to inspect, and select a running agent.

## Permissions and privacy

The configured Dispatch origin is only the feedback destination. The extension
can inspect an unrelated HTTP or HTTPS site after the user explicitly invokes
the picker there. On first use, Chrome prompts the user to grant the extension
access to HTTP and HTTPS pages. This optional grant makes the picker reliable as
the side panel follows the user between projects and can be revoked at any time
from Chrome's extension settings. The inspected page never needs to share an
origin with Dispatch. Access to the configured Dispatch origin is requested
separately at pairing time.

Before showing or sending the context preview, the extension removes form
values, editable content, scripts, inline event handlers, `srcdoc`, and likely
secret URL parameters.

Each Chrome profile receives a separate 90-day, revocable token. The token is
stored only in extension-local storage and is restricted to trusted extension
contexts. Disconnecting removes the local token and the optional Dispatch host
permission. If the server cannot be reached to revoke the remote token, the
extension reports that explicitly; active connections can always be revoked
independently in Dispatch settings.

HTTP Dispatch URLs are supported for self-hosted environments. Before pairing,
the extension requires an explicit acknowledgement that HTTP sends credentials
and feedback without transport encryption. HTTPS should be preferred on
untrusted networks.

Chrome blocks script injection on browser-owned pages such as
`chrome://extensions` and the Chrome Web Store. After the user grants access to
HTTP and HTTPS sites, selection can inspect reachable open DOM in the top-level
page and in HTTP/HTTPS frames, including cross-origin frames where Chrome
permits extension injection. Frames Chrome refuses to inject into and closed
shadow roots are not inspected.

## Release checks

The manifest and package versions must match; an extension test enforces this.
Before packaging a release, run the repository checks and extension tests, then
run the package command above. The ZIP must contain `manifest.json` at its root.
22 changes: 22 additions & 0 deletions apps/browser-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@dispatch/browser-extension",
"version": "0.28.4",
"private": true,
"type": "module",
"scripts": {
"build": "vite build && vite build --config vite.picker.config.ts",
"package": "pnpm run build && rm -f dist/dispatch-browser-feedback.zip && cd dist/unpacked && zip -qr ../dispatch-browser-feedback.zip .",
"check": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/chrome": "^0.0.326",
"happy-dom": "^18.0.1",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^4.1.2"
},
"dependencies": {
"css-selector-generator": "3.9.2"
}
}
Binary file added apps/browser-extension/public/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/browser-extension/public/icons/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/browser-extension/public/icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/browser-extension/public/icons/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions apps/browser-extension/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "Dispatch Browser Feedback",
"description": "Select live page elements and send focused feedback to a Dispatch agent.",
"version": "0.28.4",
"minimum_chrome_version": "114",
"permissions": ["scripting", "storage", "sidePanel"],
"optional_host_permissions": ["http://*/*", "https://*/*"],
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_title": "Open Dispatch feedback",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"side_panel": {
"default_path": "side-panel.html"
}
}
12 changes: 12 additions & 0 deletions apps/browser-extension/side-panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dispatch feedback</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/side-panel.ts"></script>
</body>
</html>
16 changes: 16 additions & 0 deletions apps/browser-extension/src/lib/device-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";

import { buildDeviceName } from "./device-name";

describe("buildDeviceName", () => {
it("creates a recognizable profile-specific browser label", () => {
expect(buildDeviceName("mac", "a1b2")).toBe("Chrome on macOS · A1B2");
expect(buildDeviceName("win", "4c9d")).toBe("Chrome on Windows · 4C9D");
});

it("keeps unknown platforms understandable", () => {
expect(buildDeviceName("unknown", "beef")).toBe(
"Chrome on this device · BEEF"
);
});
});
13 changes: 13 additions & 0 deletions apps/browser-extension/src/lib/device-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const PLATFORM_LABELS: Record<string, string> = {
mac: "macOS",
win: "Windows",
android: "Android",
cros: "ChromeOS",
linux: "Linux",
openbsd: "OpenBSD",
};

export function buildDeviceName(platform: string, suffix: string): string {
const label = PLATFORM_LABELS[platform] ?? "this device";
return `Chrome on ${label} · ${suffix.toUpperCase()}`;
}
35 changes: 35 additions & 0 deletions apps/browser-extension/src/lib/dispatch-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { normalizeDispatchBaseUrl, usesInsecureHttp } from "./dispatch-url";

describe("normalizeDispatchBaseUrl", () => {
it("allows HTTP for a Tailscale address", () => {
expect(normalizeDispatchBaseUrl("http://100.97.168.63:6767/settings")).toBe(
"http://100.97.168.63:6767"
);
});

it("allows HTTPS for public instances", () => {
expect(normalizeDispatchBaseUrl("https://dispatch.example.com/path")).toBe(
"https://dispatch.example.com"
);
});

it("allows HTTP for public instances", () => {
expect(normalizeDispatchBaseUrl("http://dispatch.example.com/path")).toBe(
"http://dispatch.example.com"
);
});

it("rejects embedded credentials", () => {
expect(() =>
normalizeDispatchBaseUrl("https://user:[email protected]")
).toThrow("cannot include credentials");
});
});

describe("usesInsecureHttp", () => {
it("identifies HTTP connections", () => {
expect(usesInsecureHttp("http://dispatch.local:6767")).toBe(true);
expect(usesInsecureHttp("https://dispatch.example.com")).toBe(false);
});
});
14 changes: 14 additions & 0 deletions apps/browser-extension/src/lib/dispatch-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function normalizeDispatchBaseUrl(input: string): string {
const url = new URL(input.trim());
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Dispatch URL must use http or https.");
}
if (url.username || url.password) {
throw new Error("Dispatch URL cannot include credentials.");
}
return url.origin;
}

export function usesInsecureHttp(input: string): boolean {
return new URL(input).protocol === "http:";
}
Loading
Loading