Skip to content

Commit 8d839e8

Browse files
feat(browser-bridge): offer the extension this build speaks to, and report a stale one honestly
The browser will not install an extension for the person: a store install needs their own click, and an unpacked install needs developer mode and a folder they pick. What the host can do is make sure the package they get is the one this build produces — a stale extension is the failure that reads like a network timeout and costs an afternoon. The host rebuilds the extension when its sources are newer than its build, packs it as a zip, and serves it at GET /browser-extension behind the per-start credential. Settings → 浏览器 → 下载最新扩展包 links it; the link carries the token because the route is admitted like every other document the host serves. A host that carries no extension answers 404 rather than an empty archive, because an empty package reads like a download that worked. The other half is the report. A session that goes away while a command is in flight is a disconnection, not a timeout: nothing was late, the peer hung up, and the two call for opposite responses. The peer announces its build in the handshake and the host knows the build it ships, so the message names the versions instead of leaving the reader to look for a network problem that is not there. The extension's version follows its protocol (0.2.0): the open-tab frame is a protocol change, and a host that speaks it has to be able to tell an older extension from a current one.
1 parent 87e0f86 commit 8d839e8

27 files changed

Lines changed: 979 additions & 9 deletions

‎packages/agent-tools/src/desktop/local-browser.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,9 @@ async function executeBrowserAction(
596596
errorCode === 'background_render_operation_timeout' ||
597597
errorCode === 'background_render_queue_timeout'
598598
? 'BROWSER_OPERATION_TIMEOUT'
599-
: errorCode;
599+
: errorCode === 'browser_extension_disconnected'
600+
? 'BROWSER_EXTENSION_DISCONNECTED'
601+
: errorCode;
600602
const structuredCode = normalizedErrorCode ?? /^([A-Z][A-Z0-9_]+):/u.exec(message)?.[1];
601603
const label =
602604
isRecord(error) && typeof error.label === 'string'

‎packages/browser-extension/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "MiniMax Code Browser Bridge",
4-
"version": "0.1.0",
4+
"version": "0.2.0",
55
"description": "Pairs this browser with a local MiniMax Code WebUI host so the person and the Agent can operate the same browser session.",
66
"minimum_chrome_version": "116",
77
"background": {

‎packages/browser-extension/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mavis/browser-extension",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Edge/Chrome extension that bridges a person's own browser to the local WebUI browser bridge.",
55
"license": "MIT",
66
"type": "module",

‎packages/webui/docs/browser-use/02-bridge-protocol.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,19 @@ effect the user already sees.
324324
On deadline expiry the operation reports `browser_operation_timeout` from the pinned error-code set
325325
(`packages/webui/src/shared/contracts/browser.ts`).
326326

327+
**A disconnect is not a timeout.** When the session goes away while a command is in flight, the
328+
command did not run out of time — the peer hung up. The host reports that as its own code,
329+
`browser_extension_disconnected`, because the two call for opposite responses: a timeout is worth
330+
retrying and a disconnect is not. The usual cause of a disconnect is an extension older than the
331+
host it is talking to — it cannot read the frames this build sends, so it closes the socket rather
332+
than refusing them, and the host used to read that as a timeout.
333+
334+
The peer announces its build in the `hello` frame (`extensionVersion`), and the host knows the
335+
build it ships (`expectedExtensionVersion`, read from the extension's own `manifest.json` by the
336+
composition root). When the two disagree the message names both versions, so "your extension is
337+
behind" is a fact rather than a guess; when the host does not know its own build, the message says
338+
the extension *may* be out of date and points at the download in Settings.
339+
327340
**`measured`, in part.** A real Chrome and a real Edge had the extension's service worker
328341
terminated while an image stream was running: the host saw the session drop, the surface went
329342
down, and the bridge was usable again as soon as the worker woke. The 250 ms → 4 s ladder and the

‎packages/webui/docs/browser-use/03-extension-admission.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,25 @@ path.
239239
| **The peer is authenticated against a local process** | `implemented` — the `Origin` header still selects the allowlisted extension id, and it is no longer the thing that decides. A peer must additionally prove possession of the **installation key** before the host hands over a pairing credential, and a peer that proves only a credential this host already issued receives no credential at all. A local process can spoof the origin and supply its own ECDH key, but it holds neither secret, so no `pair` frame is sent to it. See [`04-pairing-credentials.md`](04-pairing-credentials.md) |
240240
| **The specific refusal says what is missing** | `implemented` — `BrowserBridgeProofRefusal` distinguishes `switched_off`, `unknown_nonce`, `unknown_key_id`, `installation_key_mismatch`, `pairing_credential_mismatch` and `no_peer_public_key`, and each maps to its own stable `closed`-frame reason text in `describeRefusal` (`session.ts`). A generic "pairing proof failed" would leave the person unable to tell "paste your installation key" from "your host rotated". |
241241

242+
## Updating the extension, and what loading a new build costs
243+
244+
**`implemented`.** The browser does not install an extension for the person: a store install needs
245+
their own click in the store, and an unpacked install needs developer mode and a folder they pick.
246+
What the host can do is make sure the package they get is the one this build speaks to, because a
247+
stale extension is the failure that reads like a network timeout and costs an afternoon.
248+
249+
| Element | State |
250+
| --- | --- |
251+
| The host builds the extension when its sources are newer than its build | `implemented` — `ensureCurrentBuild`, `packages/webui/src/server/browser-bridge/extension-package.ts` |
252+
| The package is served as a download, behind the per-start credential | `implemented` — `GET /browser-extension`, `packages/webui/src/server/http/browser-extension.ts`; the name carries the version, so two packages are told apart |
253+
| The person is offered it in one place | `implemented` — 浏览器 → 下载最新扩展包, `packages/webui/src/client/features/browser/ui/BrowserSettingsPage.public.tsx` |
254+
| A host with no extension says so | `implemented` — the route answers `404`, never an empty archive, because an empty package reads like a download that worked |
255+
256+
Loading the package costs one thing, and the Settings page says so: an unpacked extension's id is
257+
derived from the folder it is loaded from, so a new folder is a **new identity**. The person pairs
258+
it again with the installation key from the same Settings page, and the development host learns the
259+
new identity from that proof rather than from a configuration the person cannot know.
260+
242261
## Related
243262

244263
| Concern | Document |

‎packages/webui/src/client/application/port-slices.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ const BROWSER_KEYS = [
161161
"closeBrowserWebTab",
162162
"listBrowserCapabilities",
163163
"getBrowserInstallationKey",
164+
"browserExtensionPackageUrl",
164165
] as const satisfies readonly (keyof BrowserPort)[];
165166

166167
const COMPOSER_KEYS = [

‎packages/webui/src/client/features/browser/contracts/browser-port.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,15 @@ export interface BrowserPort {
283283
readonly getBrowserInstallationKey?: (request: {
284284
readonly surface: WebuiBridgeBrowserSurface;
285285
}) => Promise<WebuiBrowserInstallationKeyResult>;
286+
/**
287+
* The URL of the extension package this host produces.
288+
*
289+
* The browser will not install an extension for the person — a store install
290+
* needs their own click, and an unpacked install needs developer mode and a
291+
* folder they pick — so the host's part is to hand them the package this
292+
* build speaks to. The URL is built by the transport rather than by a
293+
* component, because only the transport knows the per-start token the route
294+
* checks.
295+
*/
296+
readonly browserExtensionPackageUrl?: () => string;
286297
}

‎packages/webui/src/client/features/browser/ui/BrowserSettingsPage.public.tsx‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
import { useCallback, useEffect, type ReactElement } from "react";
2020

21-
import { SettingsPage } from "../../../ui/settings-layout.js";
21+
import { SettingsPage, SettingsRow, SettingsSection } from "../../../ui/settings-layout.js";
22+
import { WebuiButtonLink } from "../../../ui/button.js";
23+
import { useWebuiPortSlice } from "../../../bindings/port-slices-context.js";
2224
import {
2325
useWebuiBrowserAuthorization,
2426
useWebuiBrowserAuthorizationState,
@@ -32,6 +34,9 @@ import {
3234
export function BrowserSettingsPage(): ReactElement {
3335
const owner = useWebuiBrowserAuthorization();
3436
const state = useWebuiBrowserAuthorizationState();
37+
// The package is the host's, and the host knows where it is: the port builds
38+
// the URL because only it knows the token the route checks.
39+
const packageUrl = useWebuiPortSlice("browser")?.browserExtensionPackageUrl?.();
3540

3641
// The read is the owner's, and it is asked for once per mount: a second
3742
// caller re-reads the same answer rather than keeping a second copy of it.
@@ -62,6 +67,61 @@ export function BrowserSettingsPage(): ReactElement {
6267
<BrowserInstallationKey
6368
readKey={state?.canReadInstallationKey && owner ? owner.readInstallationKey : undefined}
6469
/>
70+
<BrowserExtensionPackage packageUrl={packageUrl} />
6571
</SettingsPage>
6672
);
6773
}
74+
75+
/**
76+
* The extension package, as the settings page offers it.
77+
*
78+
* The browser will not install an extension for the person: a store install
79+
* needs their own click, and an unpacked install needs developer mode and a
80+
* folder they pick. What the host can do is hand them the package this build
81+
* produces, so the extension they load is one this build can talk to — a
82+
* mismatch between the two reads like a network timeout and costs an
83+
* afternoon.
84+
*
85+
* The link opens in a new tab on purpose. A download that succeeds leaves the
86+
* page alone, and one that fails opens the host's own refusal beside it
87+
* rather than replacing the settings the person was reading.
88+
*/
89+
function BrowserExtensionPackage({
90+
packageUrl,
91+
}: {
92+
readonly packageUrl?: string;
93+
}): ReactElement {
94+
return (
95+
<SettingsSection
96+
title="浏览器扩展包"
97+
testId="browser-extension-package"
98+
hint="扩展由你手动加载,浏览器不会替人安装。"
99+
>
100+
<SettingsRow
101+
title="下载最新扩展包"
102+
description={
103+
packageUrl
104+
? "下载这个构建对应的扩展包,解压后在浏览器的扩展管理页用「加载已解压的扩展程序」加载它。换个目录加载会改变扩展的身份,需要用新的安装密钥重新配对。"
105+
: "当前运行时没有随附浏览器扩展,没有可下载的扩展包。"
106+
}
107+
testId="browser-extension-package-row"
108+
>
109+
{packageUrl ? (
110+
<WebuiButtonLink
111+
href={packageUrl}
112+
download
113+
target="_blank"
114+
rel="noreferrer"
115+
variant="quiet-secondary"
116+
size="inline"
117+
data-testid="browser-extension-package-download"
118+
>
119+
下载最新扩展包
120+
</WebuiButtonLink>
121+
) : (
122+
<span data-testid="browser-extension-package-unavailable">不可用</span>
123+
)}
124+
</SettingsRow>
125+
</SettingsSection>
126+
);
127+
}

‎packages/webui/src/client/platform/transport.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export function createWebuiTransport(options: WebuiTransportOptions): Required<W
218218
watchMiniAppAgentTurn,
219219
watchMiniAppAgentObservations,
220220
workspaceFileUrl,
221+
browserExtensionUrl,
221222
miniappStandaloneUrl,
222223
watchEvents,
223224
} = createWebuiWire(options);
@@ -289,6 +290,10 @@ export function createWebuiTransport(options: WebuiTransportOptions): Required<W
289290
operation: {...body.operation},
290291
}),
291292
workspaceFileUrl,
293+
// The extension package is a download rather than an operation: the route
294+
// answers with the archive itself, so the client needs the URL and not a
295+
// reply to unwrap.
296+
browserExtensionPackageUrl: browserExtensionUrl,
292297
readWorkspaceArchive: (body) => request(READ_WORKSPACE_ARCHIVE_OPERATION_NAME, body),
293298
extractWorkspaceArchive: (body) => request(EXTRACT_WORKSPACE_ARCHIVE_OPERATION_NAME, body),
294299
createTerminal: (body) => request(CREATE_TERMINAL_OPERATION_NAME, body),

‎packages/webui/src/client/platform/wire.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,15 @@ export interface WebuiWire {
237237
): () => void;
238238
/** The one HTTP route, built from the same base and token as the socket. */
239239
workspaceFileUrl(input: { readonly workspaceDir: string; readonly path: string }): string;
240+
/**
241+
* The browser extension package this host produces, as a download.
242+
*
243+
* The route is admitted like every other document — the service checks the
244+
* token on it — so the link carries it. The package is the build this host
245+
* speaks to, so the person loads the extension this build can talk to
246+
* rather than one they happened to download earlier.
247+
*/
248+
browserExtensionUrl(): string;
240249
/**
241250
* The standalone Mini App's page, built from the same base and token.
242251
*
@@ -928,6 +937,15 @@ export function createWebuiWire({
928937
return `${base}/workspace-file?${query.toString()}`;
929938
}
930939

940+
function browserExtensionUrl(): string {
941+
// Same base rule as `workspaceFileUrl`: the socket URL carries the token,
942+
// the route checks it, and `ws:`/`wss:` share a prefix so one
943+
// substitution reaches `http:`/`https:`.
944+
const base = baseWebsocketUrl.replace(/\/$/u, "").replace(/^ws/iu, "http");
945+
const query = new URLSearchParams({ token });
946+
return `${base}/browser-extension?${query.toString()}`;
947+
}
948+
931949
return {
932950
request,
933951
stream,
@@ -941,6 +959,7 @@ export function createWebuiWire({
941959
watchMiniAppAgentTurn,
942960
watchMiniAppAgentObservations,
943961
workspaceFileUrl,
962+
browserExtensionUrl,
944963
miniappStandaloneUrl,
945964
watchEvents: createWebuiEventChannel({ websocketUrl, webSocket }),
946965
};

0 commit comments

Comments
 (0)