Skip to content

Commit 768eadb

Browse files
feat(browser-bridge): let a development host learn the extension it is given
An unpacked build's identity is derived from the folder it was loaded from, so it is different on every machine. Asking the person to name it asked for something they cannot know, and the answer changed with every checkout. The development host learns the identity from the extension instead, and only from a proof: the peer still has to answer the surface's challenge with the installation key, and the identity is recorded only once it has. - let the upgrade gate admit an identity the host does not know, in a development host. The proof is still the decision, and a peer that cannot prove is refused and receives no credential - record the proved identity per surface and remember it, atomically and owner-only, under the data directory - keep a packaged host strict: it admits the published identity it was built for, and refuses everything else at the gate - cap how many challenges one pairing will have outstanding, so a peer that connects and then goes quiet cannot hold them - say so on the extension's options page: there is nothing to set on the host side any more
1 parent cddcfc9 commit 768eadb

20 files changed

Lines changed: 762 additions & 48 deletions

‎packages/browser-extension/README.md‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,24 @@ reports the surface as "not enabled" even though its switch is on:
6060
sudo apt-get install -y libsecret-tools
6161
```
6262

63-
Then start the WebUI so it admits this extension. An unpacked build's identity
64-
is derived from the directory it was loaded from, so the host has to be told
65-
which identity it accepts — development mode does not bypass that check, and an
66-
identity the host does not know is refused:
63+
Then start the WebUI. An unpacked build's identity is derived from the
64+
directory it was loaded from, so it is different on every machine — and the
65+
host does not need to be told what it is. The host learns the identity from
66+
the extension itself, once the extension has proved the installation key, and
67+
remembers it:
6768

6869
```bash
69-
MAVIS_BROWSER_EDGE_EXTENSION_IDS=<extension-id> pnpm --filter @mavis/webui dev:server
70+
pnpm --filter @mavis/webui dev:server
7071
```
7172

72-
The extension's options page shows its own id and the exact setting line to
73-
copy, so the id never has to be read out of the browser's extension manager.
74-
The packaged WebUI entry reads the **store** allowlist instead
75-
(`MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`), and a locally loaded build is not
76-
on it — a released package admits the published extension on its own.
73+
Nothing is configured on the host side, and nothing has to be found out first:
74+
the id is on the extension's own options page, together with the setting a
75+
host that pins its extensions would need.
76+
77+
The **packaged** WebUI entry is stricter, on purpose. It admits the published
78+
extension — the one the store issued — and refuses a locally loaded build
79+
unless that build's identity is named in the store allowlist
80+
(`MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`).
7781

7882
Then point the extension at the WebUI host. The extension cannot learn the
7983
host's address by itself, so an absent origin is reported rather than replaced
@@ -97,10 +101,10 @@ options*:
97101
the client dials with (a loopback `http:` address, with no path);
98102
- the **installation key** from the WebUI's Settings page, which is
99103
write-only: the page shows a fingerprint afterwards and never the key;
100-
- the **extension id** and the **development host setting** it belongs in,
101-
which the page derives from the build it is: a published build says that the
102-
released WebUI package already admits it, and a local build says which
103-
variable has to carry its id.
104+
- the **extension id** and the **host setting** it belongs in, which the page
105+
derives from the build it is. The page also says that there is nothing to
106+
set on the host side: a released package admits the published extension, and
107+
a development host learns this extension from the proof.
104108

105109
Saving either one wakes the worker, which re-reads the configuration and
106110
pairs with the host.

‎packages/browser-extension/options.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ <h2>This extension</h2>
7676
<button id="copy-extension-id" type="button">Copy</button>
7777
</div>
7878
<div class="row stacked">
79-
<span class="label">Development host setting</span>
79+
<span class="label">Host setting</span>
8080
<input id="host-allowlist" class="value" type="text" readonly spellcheck="false" />
8181
<button id="copy-host-allowlist" type="button">Copy</button>
8282
</div>

‎packages/browser-extension/src/options.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ async function renderIdentity(): Promise<void> {
126126
: "This is a local build. Its identity comes from the folder it was loaded from, so it differs on every machine.";
127127
identityNote.textContent =
128128
kind === "published"
129-
? "The released WebUI package already admits this extension, so there is nothing to set on the host side. The setting below is only for a development WebUI."
130-
: "A development WebUI admits only the extensions you name, so start it with the setting below.";
129+
? "Nothing to set on the host side. A released WebUI package admits the published extension, and a development WebUI learns this extension the first time it proves the installation key."
130+
: "Nothing to set on the host side. A development WebUI learns this extension the first time it proves the installation key.";
131131
}
132132

133133
/** Put a value on the clipboard, or say that the clipboard was not there. */

‎packages/webui/docs/adr/0019-webui-browser-use-surfaces-and-external-browser-bridge.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,37 @@ id it admits. That configuration is now visible where the id is: the
480480
extension's options page shows its own id and the exact setting line that
481481
carries it, so a person no longer has to read the id out of the browser's
482482
extension manager to satisfy the host.
483+
484+
### A11 — A development host learns the identity instead of being told it
485+
486+
A10 settled the published identity: it is a constant of the release, so it is
487+
baked into the package and the person who installs it configures nothing. The
488+
development host had the same defect for a different reason, and A6 recorded
489+
it as a configuration value: an unpacked build's id is derived from the
490+
directory it was loaded from, so it differs on every machine and every path,
491+
and asking the person to name it was asking for something they cannot know.
492+
493+
So the development host learns the identity instead, and it learns it from a
494+
proof rather than from a claim:
495+
496+
- The upgrade gate admits an identity the host does not know. This widens who
497+
may **try** the handshake, not who may **act**: the peer still has to
498+
answer the surface's challenge with the installation key, and A2's
499+
proof-first order still holds — a peer that proves nothing is refused and
500+
receives no credential.
501+
- When the peer proves the installation key, or a credential this host already
502+
issued, the host records the identity under
503+
`<dataDir>/browser-bridge/trusted-extensions.json` and admits it from then
504+
on. The file holds identities, not secrets; it is written atomically and
505+
owner-only, and a file that cannot be read reads as "nothing is trusted
506+
yet", which costs the extension one more proof and no secret.
507+
- The identity is still per surface, and the peer's own `hello` still says
508+
which browser it is on. A5 stands: the name selects which secret to check
509+
against, never the identity to believe.
510+
511+
A **packaged** host learns nothing. Its published identity is exact, and a
512+
packaged host that admitted unknown origins would stop being the published
513+
extension — which is the whole of A6 and A10.
514+
515+
The development allowlist variables keep working, and add to what the host has
516+
learned rather than replacing it. They are no longer required for anything.

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ and it is the current state of both composition roots:
6565

6666
| Entry point | Line | `dev` | Bridge allowlist |
6767
| --- | --- | --- | --- |
68-
| `scripts/run-webui-server.mjs` | 67 | `dev: true` | development — `MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS` |
68+
| `scripts/run-webui-server.mjs` | 74 | `dev: true` | development — the identities it has learned, plus `MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS` |
6969
| `release/webui-npm/bin/mcode-webui.mjs` | 93 | `dev: true` | **store** — the published identity the package was built for, plus `MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS` |
7070

7171
So on a packaged WebUI today, the generic path already accepts any upgrade that passes the loopback
@@ -96,6 +96,35 @@ requires no extension id, no environment variable and no restart with a hand-wri
9696
store allowlist variables remain for a self-hosted store build, and they add to the published
9797
identity rather than replacing it.
9898

99+
## The development host learns the identity it is given
100+
101+
The same reasoning applies to a development host, and for a stronger reason. An unpacked build's id
102+
is derived from the directory it was loaded from, so it is **different on every machine and every
103+
path**: there is no constant to bake in, and a name the person has to go and look up is a name they
104+
will get wrong. So the development host does not ask for one either.
105+
106+
It learns the identity from the extension, and only from a proof:
107+
108+
- The upgrade gate admits an identity the host does not know. This widens **who may try**, not
109+
**who may act**: the peer still has to answer the surface's challenge with the installation key,
110+
and a peer that cannot is refused and receives no credential.
111+
- When the peer proves the installation key — or a credential this host already issued — the host
112+
records the identity under `<dataDir>/browser-bridge/trusted-extensions.json` and admits it from
113+
then on. The file is written atomically and owner-only, and a file that cannot be read reads as
114+
"nothing is trusted yet", which costs the extension one more proof and no secret.
115+
- The identities are per surface, and the peer's own `hello` still says which browser it is on. The
116+
name selects **which secret to check against**, never the identity to believe (ADR 0019, A5).
117+
118+
A **packaged** host does not learn anything: its published identity is exact, and a packaged host
119+
that admitted unknown origins would stop being the published extension. The development
120+
allowlist variables still work on a development host, and they add to what it has learned rather
121+
than replacing it.
122+
123+
The cost of the wider upgrade gate is a peer that connects and then goes quiet: it would hold a
124+
challenge unanswered. Each pairing therefore caps how many challenges it will have outstanding at
125+
once (`MAX_OUTSTANDING_CHALLENGES`), and the hello deadline closes the sockets that never introduce
126+
themselves.
127+
99128
**`implemented`: the bridge admission path never reads `dev`.** The pairing proof is verified
100129
unconditionally, and the browser switch is checked unconditionally. `admission.ts:135` holds an
101130
explicit no-op `if (context.dev)` branch whose only purpose is to make the absence of a bypass

‎packages/webui/docs/browser-use/09-acceptance-record.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,18 +108,23 @@ store's browser** — the Chrome Web Store build in Chrome, the Edge Add-ons bui
108108
the id named only in `MAVIS_BROWSER_CHROME_EXTENSION_IDS`, refuses it. A packaged host that fell
109109
back to the development allowlist would admit whatever unpacked extension happened to be present,
110110
which is the identity nobody can rely on.
111+
5. **A development host needs to be told nothing.** A development host started with no allowlist at
112+
all pairs with the extension anyway: the host learns the identity from the extension that proves
113+
the installation key, and remembers it. The case runs against a real Edge and a real Chromium.
111114

112115
The **installed package** is checked too, and not by a bridge the test built. The package
113-
verification starts the real `mcode-webui` from the installed archive, configures it with **both**
114-
allowlists, and shows that the store identity is admitted on its `/browser-bridge` path while the
116+
verification builds the package with the store's public key, starts the real `mcode-webui` from the
117+
installed archive, and shows that the **published** identity is admitted on its `/browser-bridge`
118+
path with **nothing configured for it** — the package carries that identity, because it is a
119+
constant of the release. The identity the environment names is admitted alongside it, and the
115120
development identity is refused. That is the shipped process, running the shipped configuration.
116121

117122
The two entries are configured apart, in the source:
118123

119-
| Entry | File | Allowlist it reads |
124+
| Entry | File | Identities it admits |
120125
| --- | --- | --- |
121-
| Development | `scripts/run-webui-server.mjs` | `MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS` |
122-
| **Packaged** | `release/webui-npm/bin/mcode-webui.mjs` | `MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS` |
126+
| Development | `scripts/run-webui-server.mjs` | the ones it has learned, plus `MAVIS_BROWSER_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_CHROME_EXTENSION_IDS` |
127+
| **Packaged** | `release/webui-npm/bin/mcode-webui.mjs` | the published identity it was built for, plus `MAVIS_BROWSER_STORE_EDGE_EXTENSION_IDS`, `MAVIS_BROWSER_STORE_CHROME_EXTENSION_IDS` |
123128

124129
The packaged entry passes `packaged: true` to the bridge; the development entry does not.
125130

‎packages/webui/src/server/browser-bridge/admission.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ export interface BrowserBridgeAdmissionContext {
7070
* across all the pairings; tests can substitute a custom matcher.
7171
*/
7272
readonly isExtensionOrigin?: (origin: string) => string | undefined;
73+
/**
74+
* Whether an extension identity the host does not know may still attempt
75+
* a handshake.
76+
*
77+
* A development host says yes. An unpacked build's id is derived from the
78+
* folder it was loaded from, so it differs on every machine, and asking the
79+
* person to name it is asking for something they cannot know. The identity
80+
* is learned only after the extension proves the surface's installation
81+
* key, so this widens who may *try*, not who may *act*: a peer that cannot
82+
* prove the key is refused and receives no credential.
83+
*
84+
* A packaged host says no. The published identity is exact, and admitting
85+
* unknown origins would stop being the published extension.
86+
*/
87+
readonly learnUnknownIdentities?: boolean;
7388
}
7489

7590
/**
@@ -127,7 +142,12 @@ export function admitBrowserBridgeUpgrade(
127142
const admitted = [...context.pairings.values()].some((pairing) =>
128143
pairing.admitsExtensionId(extensionId),
129144
);
130-
if (!admitted) {
145+
// A host that learns identities lets an unknown one through this gate, and
146+
// no further: the peer still has to prove the installation key before it
147+
// is given anything. The gate is a pre-filter (ADR 0019, A2), and this is
148+
// the development host saying that the pre-filter cannot be a name the
149+
// person has to look up.
150+
if (!admitted && !context.learnUnknownIdentities) {
131151
return reject(socket, 403, "Forbidden Origin");
132152
}
133153
// Dev does NOT bypass. The header is on the upgrade so a future wiring

‎packages/webui/src/server/browser-bridge/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export {
5555

5656
export {
5757
BrowserBridgePairing,
58+
PairingChallengeLimitError,
5859
PairingSwitchedOffError,
5960
type BrowserBridgePairingMetadata,
6061
type BrowserBridgePairingOptions,
@@ -121,6 +122,14 @@ export {
121122
type BrowserBridgeDispatch,
122123
} from "./registry.js";
123124

125+
export {
126+
createFileTrustedExtensionStore,
127+
createInMemoryTrustedExtensionStore,
128+
isExtensionId,
129+
trustedExtensionsFile,
130+
type TrustedExtensionStore,
131+
} from "./trusted-extensions.js";
132+
124133
export {
125134
BrowserBridgeServer,
126135
WebuiBridgeOperationError,

‎packages/webui/src/server/browser-bridge/pairing.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ import {
3838

3939
const NONCE_BYTES = 32;
4040

41+
/**
42+
* How many challenges one pairing will have outstanding at once.
43+
*
44+
* A challenge is a nonce the host has issued and the peer has not answered.
45+
* A peer that connects and then goes quiet holds one, and a host that lets
46+
* identities be learned has to assume that some of those peers are not the
47+
* extension anyone expected. The cap bounds what such a peer can hold, and
48+
* the hello deadline closes the sockets that never even introduced
49+
* themselves.
50+
*/
51+
const MAX_OUTSTANDING_CHALLENGES = 16;
52+
4153
/** The non-secret metadata for a credential, suitable for surfacing in the UI. */
4254
export interface BrowserBridgePairingMetadata {
4355
readonly keyId: string;
@@ -228,6 +240,13 @@ export class BrowserBridgePairing {
228240
`cannot issue challenge for "${this.surface}"`,
229241
);
230242
}
243+
// The cap comes before the nonce is made, so a refused peer costs no
244+
// entropy and leaves no trace to clean up.
245+
if (this.outstandingNonces.size >= MAX_OUTSTANDING_CHALLENGES) {
246+
throw new PairingChallengeLimitError(
247+
`browser bridge for "${this.surface}" has too many unanswered challenges`,
248+
);
249+
}
231250
const nonceBytes = this.randomBytesSource(NONCE_BYTES);
232251
const nonce = bufferToBase64Url(nonceBytes);
233252
this.outstandingNonces.add(nonce);
@@ -617,6 +636,22 @@ export class PairingSwitchedOffError extends Error {
617636
}
618637
}
619638

639+
/**
640+
* A typed signal that the pairing refuses because too many challenges are
641+
* already unanswered.
642+
*
643+
* A host that learns identities lets unknown extensions attempt a handshake,
644+
* so it has to be able to say no to a peer that keeps taking challenges and
645+
* answering none. The peer is closed rather than served, and the reason is
646+
* specific so that it is not mistaken for a credential problem.
647+
*/
648+
export class PairingChallengeLimitError extends Error {
649+
constructor(message: string) {
650+
super(message);
651+
this.name = "PairingChallengeLimitError";
652+
}
653+
}
654+
620655
/** Encode bytes as base64url with no padding — the challenge's nonce format. */
621656
function bufferToBase64Url(bytes: Uint8Array): string {
622657
return Buffer.from(bytes)

0 commit comments

Comments
 (0)