@sema-agent/connector-browser 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to this package are documented here. Released sections are
4
4
  frozen: once a version is published its section is never edited again; new text
5
5
  goes into the next version's section.
6
6
 
7
+ ## 0.2.0 (2026-10-23)
8
+
9
+ ### Added
10
+
11
+ - The export of the login state (CN-055), off until the person turns it on: `auth` also
12
+ takes `"export": "on"` or `"off"` next to `{}` or `storageStateFile`
13
+ (`{"export": "on"}`, `{"storageStateFile": "<absolute path>", "export": "on"}`); every
14
+ `auth` states the whole configuration, so `"off"` or no `export` key turns it off. The
15
+ switch is the handle `BROWSER_EXPORT` (holding `on`, not a secret), stored or removed in
16
+ one group with `BROWSER_STORAGE_STATE`; a stored switch that holds anything else refuses
17
+ the start.
18
+ - With the export on, playwright-mcp also gets `--caps storage`, and the connector's
19
+ standard input and output pass through a relay. Of the seventeen storage tools only
20
+ `browser_storage_state` is offered (26 tools in all): the sixteen that answer with cookie
21
+ or web storage values, or load a state, are withheld from `tools/list` and answered as a
22
+ tool playwright-mcp does not have, never reaching it. A `browser_storage_state` call is
23
+ given a file the connector names in the launch's output directory (a name no page can
24
+ guess, whatever name the caller gave); when it succeeds, that file is held to the login
25
+ state rule and stored as `BROWSER_STORAGE_STATE`, then removed, and the next launch starts
26
+ from it. A save that cannot be stored stores nothing and its answer says why, without the
27
+ content. No other file is read: state files a page downloads into the output directory
28
+ are never taken for the saved one.
29
+
30
+ ### Changed
31
+
32
+ - Retires KL-15 (logins made in a session were never kept): with the export on, a session
33
+ keeps the login it saves. New KL-26: a saved state holds everything the browser holds,
34
+ the cookies pages set for their own sites included.
35
+ - With the export off, the arguments playwright-mcp gets and the way MCP passes through are
36
+ exactly as before.
37
+ - With `@sema-agent/connectors` 0.8.0 (CN-040), playwright-mcp runs with `HOME` set to its own home in the
38
+ connector's directory, so Playwright's registry entry for the running browser lies under `<CONNECTOR_DIR>/home`
39
+ instead of the user's `Library/Caches` (macOS) or `.cache` (Linux).
40
+ - With `@sema-agent/connectors` 0.8.0 and `@sema-agent/connectors-contract` 0.2.0 (CN-030), a request the launcher
41
+ does not take is answered with `connector.request_unreadable` and its `error.data.reason` instead of
42
+ `connector.forbidden_scope`, which now means only a tool outside the granted scope.
43
+
44
+ ## 0.1.2 (2026-10-21)
45
+
46
+ ### Documentation
47
+
48
+ - The connector's document ships inside the package as `README.md`, a byte copy of
49
+ the repository's `docs/CONNECTORS/<name>.md` made by the build (CN-056): a person
50
+ who installed the package reads its setup, what runs, the recommended policy and
51
+ the known limits without the repository.
52
+ - The recommended policy names four tools to leave out (`browser_run_code_unsafe`,
53
+ `browser_evaluate`, `browser_network_request`, `browser_network_requests`) and
54
+ `browser_file_upload` as a fifth a deployment may add back; the example list holds
55
+ 20 names, 21 with it.
56
+
7
57
  ## 0.1.1 (2026-10-16)
8
58
 
9
59
  ### Documentation
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Browser connector
2
+
3
+ `@sema-agent/connector-browser` (CN-027) gives the engine a web browser. It runs the
4
+ [Playwright MCP server](https://github.com/microsoft/playwright-mcp) (`@playwright/mcp`) unmodified behind the
5
+ `sema-connector` launcher, driving a pinned Chrome for Testing, at the versions and sha256 values its `binaries.lock.json`
6
+ pins. The launcher's stages (scope, normalize, status, trace, the withholding of secret values) apply to it as to every
7
+ connector; see [`ARCHITECTURE.md`](../ARCHITECTURE.md). Its bin is `sema-connector-browser`; its subcommands are the shared
8
+ CLI kit's (CN-039) around the shared install of a pinned upstream program (CN-027) and this package's `auth`, and read the
9
+ configuration directory from `CONFIG_LOCAL_DIR` like every connector's
10
+ ([`INTEGRATION-SERVER.md`](../INTEGRATION-SERVER.md)).
11
+
12
+ The browser is headless and runs in a fresh, in-memory profile at every start; what it knows of a login is the login
13
+ state `auth` stored (see "The login state"), which a session can save back when the person has turned the export of the
14
+ login state on (CN-055, see "Exporting the login state"). Chrome for Testing is pinned for `darwin-arm64` and
15
+ `linux-x64` only.
16
+
17
+ ## Setting it up
18
+
19
+ ```sh
20
+ export CONFIG_LOCAL_DIR=/absolute/path/to/the/engine/configuration
21
+ sema-connector-browser install
22
+ echo '{}' | sema-connector-browser auth # a clean profile
23
+ sema-connector-browser mount
24
+ sema-connector-browser status
25
+ ```
26
+
27
+ On Linux the connector keeps Chromium's own sandbox on (`--sandbox`, never `--no-sandbox`), which needs user
28
+ namespaces: in a container that does not allow them (Docker's default seccomp profile, for one) the first
29
+ `browser_navigate` answers `Chromium sandboxing failed!` and nothing is browsed. Run the connector on a host or in a
30
+ container that allows user namespaces (KL-18 in the repository's `docs/KNOWN-LIMITS.md`); the connector never turns
31
+ the sandbox off.
32
+
33
+ Then restart the engine. To start from a login instead of a clean profile, save a Playwright storage state (for example
34
+ with Playwright's `context.storageState({ path })` after logging in) to a file only you can read, and give `auth` its path:
35
+ `echo '{"storageStateFile": "/absolute/path/state.json"}' | sema-connector-browser auth`. Delete the file afterwards; the
36
+ state is a secret (it holds the session cookies). To let sessions keep the logins they make, turn the export on with the
37
+ same line: `echo '{"export": "on"}' | sema-connector-browser auth` (a clean profile, export on), or
38
+ `{"storageStateFile": "/absolute/path/state.json", "export": "on"}`.
39
+
40
+ ## The pinned build
41
+
42
+ `binaries.lock.json` is the one place that names the build, in the shape "Pinned upstream programs" in
43
+ [`ARCHITECTURE.md`](../ARCHITECTURE.md) describes: the key `playwright-mcp`, version 0.0.83, and five archives.
44
+
45
+ | Archive | Platform | Source | Unpacked as |
46
+ |---|---|---|---|
47
+ | `playwright-mcp-0.0.83.tgz` | `*` | `https://registry.npmjs.org/@playwright/mcp/-/mcp-{version}.tgz` | `node_modules/@playwright/mcp` |
48
+ | `playwright-1.64.0-alpha-1790635538000.tgz` | `*` | `https://registry.npmjs.org/playwright/-/{file}` | `node_modules/playwright` |
49
+ | `playwright-core-1.64.0-alpha-1790635538000.tgz` | `*` | `https://registry.npmjs.org/playwright-core/-/{file}` | `node_modules/playwright-core` |
50
+ | `chrome-mac-arm64.zip` | `darwin-arm64` | `https://cdn.playwright.dev/builds/cft/155.0.8059.12/mac-arm64/{file}` | `chrome` |
51
+ | `chrome-linux64.zip` | `linux-x64` | `https://cdn.playwright.dev/builds/cft/155.0.8059.12/linux64/{file}` | `chrome` |
52
+
53
+ The three npm tarballs are the whole run-time closure of `@playwright/mcp` 0.0.83 (its lock file lists no other
54
+ package), each pinned with the sha256 of the tarball whose `sha512` integrity the registry gives. Chrome for Testing
55
+ 155.0.8059.12 is the build that `playwright-core` 1.64.0-alpha-1790635538000 names (Playwright's `chromium` 1247), fetched
56
+ from the host Playwright itself downloads it from. The program is the Node.js script
57
+ `node_modules/@playwright/mcp/cli.js` (`{"node": ...}`): it runs on the Node.js that runs the connector, and Node.js
58
+ finds `playwright` and `playwright-core` next to it. npm is never run.
59
+
60
+ ## install
61
+
62
+ `install` is the shared install of a pinned upstream program (see "Pinned upstream programs" in
63
+ [`ARCHITECTURE.md`](../ARCHITECTURE.md)): it fetches the four archives of this platform, compares each with its sha256
64
+ and checks what it gives before anything is unpacked, then unpacks each below
65
+ `<configuration directory>/connectors/browser/bin/` (0700) and keeps the archives there. About 580 MB on `darwin-arm64`.
66
+ When that directory already holds the pinned build, `install` changes nothing. On a platform the lock does not pin it
67
+ refuses (`binaries.lock.json pins no playwright-mcp build for <platform>`).
68
+
69
+ ## auth
70
+
71
+ `auth` reads exactly one line from standard input (never from an argument), a JSON object with at most the string
72
+ members `storageStateFile` and `export`:
73
+
74
+ ```json
75
+ {}
76
+ {"storageStateFile": "<absolute path>"}
77
+ {"export": "on"}
78
+ {"storageStateFile": "<absolute path>", "export": "on"}
79
+ ```
80
+
81
+ - `{}` stores a clean profile, `{"cookies":[],"origins":[]}`. Empty input is refused rather than read as `{}`: a pipe
82
+ whose producer failed must not replace a stored login with an empty one.
83
+ - `storageStateFile` names a Playwright storage state file by its absolute path: at most 4 MiB of UTF-8 JSON holding one
84
+ object with exactly the keys `cookies` and `origins`, each a list. Its text is stored as it is.
85
+ - `export` is `"on"` or `"off"`: `"on"` turns the export of the login state on (see "Exporting the login state"),
86
+ `"off"` or no `export` key turns it off. Every `auth` states the connector's whole configuration: the login state as
87
+ above, and the export on or off.
88
+ - Any other key, a key given twice, a value that is not a string, another `export` value, or a file outside the rule is
89
+ refused, and a refusal never repeats what was read.
90
+
91
+ The state is stored through the secrets backend as the handle `BROWSER_STORAGE_STATE`: a 0600 file in
92
+ `<configuration directory>/connectors/browser/` (0700), a secret (no metadata). The export switch is the handle
93
+ `BROWSER_EXPORT`, holding `on`, stored when the export is on (a handle that is not a secret, `{"secret": false}`) and
94
+ removed when it is off. Both writes are one group inside the lock of the connector's directory, as for every connector
95
+ (see "Secrets" in [`ARCHITECTURE.md`](../ARCHITECTURE.md)): a connector started while `auth` runs gets the old state and
96
+ switch or the new ones. The switch is written first, so an `auth` that fails leaves the old login state, with the switch
97
+ as it was or already as asked.
98
+
99
+ ## mount and status
100
+
101
+ `mount` writes the connector's standalone catalog entry through the shared `mount` function, and `status` prints the
102
+ status the launcher serves, as for every connector. Before `auth` there is no handle, so the launcher does not start the
103
+ connector and answers itself (see "Parked session" in [`ARCHITECTURE.md`](../ARCHITECTURE.md)): `status` prints
104
+ `{"state":"unauthorized","tools":[]}`. After `auth` (a clean profile included) the status is `authorized` and lists the
105
+ 25 tools of playwright-mcp (26 with the export on, see "Exporting the login state"); reading them starts playwright-mcp
106
+ but not the browser, which starts at the first tool that needs a page.
107
+
108
+ ## What runs
109
+
110
+ With no arguments, `sema-connector-browser` is the connector process the launcher starts. It refuses to start (exit 1,
111
+ with a line on stderr naming what to do) on the shared start check ("Pinned upstream programs": without `CONNECTOR_DIR`,
112
+ without the `BROWSER_STORAGE_STATE` handle, when its per-launch copy, or that of `BROWSER_EXPORT`, is not a regular file in
113
+ `CONNECTOR_DIR`, when `bin/` does not hold the pinned build), when the stored state breaks the rule of "auth", when a stored
114
+ `BROWSER_EXPORT` does not hold `on`, when the export is on and `CONNECTOR_DIR` is not
115
+ `<configuration directory>/connectors/browser`, and when Chrome for Testing is not in `bin/chrome`. Otherwise it makes the launch's output directory (see "The output directory"), makes it the working
116
+ directory, and starts `node_modules/@playwright/mcp/cli.js` on its own Node.js as its child on the same standard input,
117
+ output and error, so every MCP message passes between the launcher and playwright-mcp untouched (with the export on, its
118
+ standard input and output pass through the connector's relay instead: see "Exporting the login state"); it ends as
119
+ playwright-mcp ends, with its exit code or by the signal that ended it, and passes its stop signals on. playwright-mcp closes the browser when its input ends or it gets `SIGTERM` (measured well under a second,
120
+ inside the launcher's teardown steps); killed with `SIGKILL` before that, it leaves Chromium running (KL-22).
121
+
122
+ playwright-mcp gets the arguments of "Security baseline" and the environment the launcher gave the connector without
123
+ `CONNECTOR_DIR`, `BROWSER_STORAGE_STATE_FILE`, `BROWSER_EXPORT_FILE` and every `PLAYWRIGHT_MCP_*` variable (the variable
124
+ twins of its options), so its options are those arguments whoever starts the connector, and with `HOME` set to its own
125
+ home in the connector's directory, `<CONNECTOR_DIR>/home` (CN-040; "Pinned upstream programs" in
126
+ [`ARCHITECTURE.md`](../ARCHITECTURE.md)): what Playwright keeps under its home stays there (KL-25).
127
+
128
+ ## Security baseline
129
+
130
+ The arguments are one table in `src/upstream.ts`; none comes from the environment, standard input or a file.
131
+
132
+ | Argument | Why |
133
+ |---|---|
134
+ | `--headless` | no window; the browser is driven only through the tools. |
135
+ | `--isolated` | the profile lives in memory and is gone when the browser closes; nothing persists between starts but the stored login state. |
136
+ | `--browser chromium` | Chrome for Testing, the pinned build. |
137
+ | `--sandbox` | the Chromium sandbox on: playwright-mcp leaves it off on Linux for Chrome for Testing unless asked (KL-18). |
138
+ | `--block-service-workers` | a page cannot install a service worker that outlives it or intercepts its requests. |
139
+ | `--no-webmcp` | tools a page registers are not offered to the engine: a page cannot add tools. |
140
+ | `--timeout-navigation 60000` | the navigation timeout, stated rather than defaulted. |
141
+ | `--output-max-size 67108864` | the output directory holds at most 64 MiB; older files are evicted. |
142
+ | `--executable-path <bin>/chrome/...` | the pinned Chrome for Testing in the bin directory; playwright-mcp never looks for or downloads a browser. |
143
+ | `--output-dir <launch directory>/out` | screenshots, snapshots and other files land in the launch's own directory and go with it. |
144
+ | `--storage-state <per-launch copy>` | the login state, read when the browser context is created. |
145
+ | `--caps storage` | only with the export of the login state on, last: playwright-mcp's storage capability, of whose tools the connector offers only `browser_storage_state` (see "Exporting the login state"). |
146
+
147
+ Never given: `--no-sandbox`, `--caps` other than `--caps storage` with the export on (none of the opt-in capabilities
148
+ `vision`, `pdf`, `devtools`, `config`, `network` is ever on), `--user-data-dir`, `--save-session`, `--secrets`, `--allow-unrestricted-file-access`, `--proxy-server`,
149
+ `--port` and `--host` (MCP runs on standard input and output only), `--init-script`, `--init-page`, `--config`,
150
+ `--extension`, `--cdp-endpoint`, `--allowed-origins` and `--blocked-origins` (playwright-mcp itself says they are not a
151
+ security boundary and do not apply to redirects; the connector does not offer them as one).
152
+
153
+ ## The login state
154
+
155
+ The state is a secret handle: the launcher reads it before the connector starts and hands the connector a per-launch copy
156
+ (0600, inside the connector's directory, removed when the launcher ends), whose path the connector gives playwright-mcp
157
+ with `--storage-state`. playwright-mcp loads its cookies and local storage into each new browser context. The launcher
158
+ withholds the state's text as a whole from everything it forwards; a cookie value a page's script can read (one not
159
+ marked `HttpOnly`) reaches the engine like any page content, through `browser_evaluate` and the other tools that read the
160
+ page. A reply that carries the stored state's text as a whole, the clean profile's `{"cookies":[],"origins":[]}`
161
+ included, is withheld like any secret value (answered with `connector.internal`).
162
+
163
+ The profile is in memory (`--isolated`): a login made during a session ends with the browser unless the session saves
164
+ it with the export on (below). Otherwise, to keep a login, store a new state with `auth`.
165
+
166
+ ## Exporting the login state
167
+
168
+ The export (CN-055) lets a session keep the logins it makes. It is off until the person turns it on with `auth`
169
+ (`"export": "on"`, see "auth"); a stored `BROWSER_EXPORT` handle is the only way to turn it on, and nothing else ever
170
+ writes the login state back.
171
+
172
+ With the export on, playwright-mcp runs with its storage capability (`--caps storage`), and the connector's standard
173
+ input and output pass through the connector's relay, which changes three things and passes every other message on as
174
+ it is (each line whole; a line longer than the launcher's 4 MiB is passed on cut to one byte over it, which the launcher
175
+ refuses as it refuses any longer line):
176
+
177
+ - **One storage tool is offered.** The capability adds seventeen tools; sixteen of them read, set or load cookies and web
178
+ storage and answer with their values (`browser_cookie_*`, `browser_localstorage_*`, `browser_sessionstorage_*`,
179
+ `browser_set_storage_state`). playwright-mcp has no option to leave a tool out, so the relay withholds them: they are
180
+ not in `tools/list` (nor in the status), and a call naming one never reaches playwright-mcp: the relay answers it with
181
+ playwright-mcp's own answer for a tool it does not have (`Tool "<name>" not found`, a tool result marked as an
182
+ error), without starting the browser; a call without an id is dropped. The list in `src/upstream.ts` is the
183
+ one place that names them. `browser_storage_state` is the one tool the export adds: 26 tools in all.
184
+ - **The connector names the file.** A `browser_storage_state` call reaches playwright-mcp with its arguments replaced by
185
+ a file of the connector's choosing, in the launch's output directory and under a name no page can guess, whatever file
186
+ name the caller gave (playwright-mcp would resolve a relative name against the client's first root, outside the
187
+ launch). playwright-mcp writes the browser's cookies and local storage there, HttpOnly cookies included, and its answer
188
+ names the file and holds no value. A call without an id is dropped.
189
+ - **A successful save is stored.** When playwright-mcp answers that call with success, the connector reads that one file
190
+ (never through a symbolic link), holds it to the rule of "auth", stores it as `BROWSER_STORAGE_STATE` through the
191
+ secrets backend (the lock of the connector's directory, written whole) and removes the file; the answer is passed on as
192
+ playwright-mcp gave it (a file that cannot be removed is said on the connector's standard error; it goes with the
193
+ launch's directory). When the file is not there or not a regular file, breaks the rule, is over 4 MiB, or the
194
+ backend cannot store it, nothing is stored, the file is removed, the answer says `The login state was not stored:`
195
+ with the reason (never the content) and the connector says the same on its standard error. When playwright-mcp's own
196
+ answer is a failure, it is passed on as it is and nothing is stored.
197
+
198
+ No other file is ever read: a page can put files of names of its own choosing into the output directory (a download
199
+ lands there, named as the page says), so a state file there named like playwright-mcp's own (`storage-state-….json` or
200
+ `storage-state.json`) is never taken for the saved one. Each save replaces the stored state; the running launch keeps
201
+ the copy it started with, and the next launch starts from the last state saved. The saved state is everything the
202
+ browser holds at that moment, the cookies a page set for itself included (KL-26).
203
+
204
+ With the export off, playwright-mcp has no storage capability and the connector passes its standard input and output
205
+ straight through, as before.
206
+
207
+ ## The output directory
208
+
209
+ Every launch has an output directory of its own: `out` (0700) in the launch's directory, the 0700 directory the launcher
210
+ makes inside the connector's directory for the per-launch copy of the login state and removes when the launch ends. It
211
+ holds what playwright-mcp writes (screenshots, page snapshots, console logs, downloads, at most 64 MiB) and goes with the
212
+ launch, so nothing a session wrote outlives it, and two launches at once (two engine sessions, or `status` while a
213
+ session runs) never touch each other's files. It is also playwright-mcp's working directory, and playwright-mcp confines
214
+ the files its tools read and write (`browser_file_upload`, a screenshot's file name, `browser_run_code_unsafe`'s
215
+ `filename`) to its output directory and its workspace, which is the working directory unless the MCP client names a root
216
+ (then its first root). `file:` URLs are not opened.
217
+
218
+ ## Tools
219
+
220
+ The connector offers every tool playwright-mcp lists; which of them a caller may use is the launcher's scope stage, and
221
+ standalone grants `tool:*` unless the configuration directory carries a local per-tool policy (CN-054, `ARCHITECTURE.md`,
222
+ "Stages on the launcher hop"). The 25 tools, and the one the export adds, with what each can reach:
223
+
224
+ | Tool | Reach |
225
+ |---|---|
226
+ | `browser_run_code_unsafe` | **Runs arbitrary JavaScript in the playwright-mcp process** (playwright-mcp calls it RCE-equivalent): it can read and write every file the user can, the tokens of the other connectors in the configuration directory included, start processes and connect anywhere. |
227
+ | `browser_evaluate` | Runs JavaScript in the page, with the page's access: its DOM, storage and script-readable cookies, requests as the page. |
228
+ | `browser_network_requests`, `browser_network_request` | The page's requests and responses, headers and bodies included (authorization headers a page sends, and whatever a logged-in site puts in a body; the request headers Playwright reports carry no cookies). |
229
+ | `browser_file_upload`, `browser_drop` | Give files to a page: files in the output directory or the workspace (see "The output directory"). |
230
+ | `browser_navigate`, `browser_navigate_back`, `browser_tabs` | Open any `http` or `https` URL, as the logged-in user where the state has a login. |
231
+ | `browser_click`, `browser_type`, `browser_press_key`, `browser_fill_form`, `browser_select_option`, `browser_hover`, `browser_drag`, `browser_handle_dialog` | Act on the page as the user (submit forms, confirm dialogs, buy, post, delete). |
232
+ | `browser_snapshot`, `browser_find`, `browser_take_screenshot`, `browser_console_messages` | Read the page (its text, its look, its console); snapshots and screenshots are also written to the output directory. |
233
+ | `browser_resize`, `browser_emulate_media`, `browser_wait_for`, `browser_close` | Change the viewport or media features, wait, close the browser. |
234
+ | `browser_storage_state` | Only with the export on: replaces the stored login state with everything the browser holds (its cookies, HttpOnly ones included, and local storage); the answer holds no value (see "Exporting the login state"). |
235
+
236
+ ### A recommended policy
237
+
238
+ Mounting this connector grants every tool above by default (`tool:*`): nothing in this package writes a policy for
239
+ it. An operator who wants to keep the reach of `browser_run_code_unsafe`, `browser_evaluate`,
240
+ `browser_network_request` and `browser_network_requests` off, while still using the browser for everything else, writes `connectors/browser/tools.json` in the configuration directory by hand
241
+ (`ARCHITECTURE.md`, "Stages on the launcher hop"; no CLI subcommand writes this file):
242
+
243
+ ```json
244
+ {
245
+ "allow": [
246
+ "browser_navigate", "browser_navigate_back", "browser_tabs",
247
+ "browser_click", "browser_type", "browser_press_key", "browser_fill_form", "browser_select_option",
248
+ "browser_hover", "browser_drag", "browser_handle_dialog",
249
+ "browser_snapshot", "browser_find", "browser_take_screenshot", "browser_console_messages",
250
+ "browser_resize", "browser_emulate_media", "browser_wait_for", "browser_close", "browser_drop"
251
+ ]
252
+ }
253
+ ```
254
+
255
+ The example leaves a fifth tool out as well, `browser_file_upload`: a deployment that needs it adds it back (it
256
+ reaches only the output directory and the workspace, not arbitrary files), so the list above holds 20 names and a
257
+ deployment that keeps `browser_file_upload` lists 21. A tool left out of `allow` fails
258
+ `tools/call` with `connector.forbidden_scope` and is absent from `tools/list` and the status resource's `tools`;
259
+ nothing about the connector itself changes. With the export on, the example leaves `browser_storage_state` out too, so a
260
+ session cannot save its login until the deployment adds it to `allow`.
261
+
262
+ ## Outbound connections
263
+
264
+ The browser connects to whatever the engine asks it to open: that is what it is for. The standalone form has no egress
265
+ proxy (M3), and the connector gives playwright-mcp none. The negative-control gate (`run-browser-negctl-test`) checks
266
+ what else connects: playwright-mcp itself connects nowhere (every Node.js process runs under the gate's egress probe and,
267
+ on macOS, a sandbox that refuses every connection but the gate's own loopback page). Chrome for Testing 155 does contact
268
+ Google on its own when it starts, although Playwright passes switches meant to keep it quiet: the gate's proxied run
269
+ names `accounts.google.com`, `update.googleapis.com`, `www.google.com` and `android.clients.google.com`, and Chromium
270
+ also connects a UDP socket to `[2001:4860:4860::8888]:443` (KL-19); the gate fails on a host outside google.com and
271
+ googleapis.com. The browser follows the system's proxy settings.
272
+
273
+ playwright-mcp also publishes the running browser for other Playwright tools of the same user: a socket under
274
+ `/tmp/pw-<hash>/browser/` and an entry in Playwright's registry directory, which lies in playwright-mcp's home
275
+ (`<CONNECTOR_DIR>/home/Library/Caches/ms-playwright/b` on macOS, `<CONNECTOR_DIR>/home/.cache/ms-playwright/b` on Linux;
276
+ CN-040), both removed when the browser closes. A process of the same user can find the socket and attach to the
277
+ browser there (KL-21).
278
+
279
+ ## Known limits
280
+
281
+ - **No egress policy in the standalone form** (KL-17).
282
+ - **The Chromium sandbox needs user namespaces on Linux** (KL-18): in a container without them Chromium does not start;
283
+ the connector never turns the sandbox off.
284
+ - **Chrome for Testing contacts Google on its own** (KL-19).
285
+ - **Two platforms**: `darwin-arm64` and `linux-x64` (KL-20).
286
+ - **Other processes of the user can attach to the running browser** (KL-21).
287
+ - **Chromium outlives a playwright-mcp killed with `SIGKILL`** before it closed the browser (KL-22).
288
+ - **A saved login state holds everything the browser holds**, the cookies a page set included (KL-26).
289
+ - **Check, then start:** as for every pinned upstream program (see "Pinned upstream programs" in
290
+ [`ARCHITECTURE.md`](../ARCHITECTURE.md)): at each start the trees are compared with their archives by their files'
291
+ names, kinds, sizes, modes and link targets, the program by its content.
package/dist/auth.js CHANGED
@@ -1,41 +1,29 @@
1
- import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs';
2
1
  import { isAbsolute } from 'node:path';
3
2
  import { openFileSecrets, readStdinJson } from '@sema-agent/connectors';
4
- import { NAME, STATE_HANDLE, complain, say } from './names.js';
5
- import { EMPTY_STATE, STATE_LIMIT, readState } from './state.js';
3
+ import { EXPORT_HANDLE, EXPORT_ON, NAME, STATE_HANDLE, complain, say } from './names.js';
4
+ import { EMPTY_STATE, readState, stateFileBytes } from './state.js';
6
5
  const INPUT_LIMIT = 64 * 1024;
7
6
  const FILE_KEY = 'storageStateFile';
7
+ const EXPORT_KEY = 'export';
8
+ const EXPORT_OFF = 'off';
8
9
  function stateFromFile(path) {
9
10
  if (!isAbsolute(path))
10
11
  return { ok: false, problem: `${FILE_KEY} must be an absolute path` };
11
- let fd;
12
- try {
13
- fd = openSync(path, 'r');
14
- }
15
- catch {
16
- return { ok: false, problem: `the ${FILE_KEY} cannot be read` };
17
- }
18
- try {
19
- const st = fstatSync(fd);
20
- if (!st.isFile())
21
- return { ok: false, problem: `the ${FILE_KEY} is not a regular file` };
22
- if (st.size > STATE_LIMIT)
23
- return { ok: false, problem: `the login state is larger than ${STATE_LIMIT} bytes` };
24
- return readState(readFileSync(fd));
25
- }
26
- catch {
27
- return { ok: false, problem: `the ${FILE_KEY} cannot be read` };
28
- }
29
- finally {
30
- closeSync(fd);
31
- }
12
+ const file = stateFileBytes(path, `the ${FILE_KEY}`, true);
13
+ return file.ok ? readState(file.bytes) : file;
32
14
  }
33
15
  export async function auth(configDir) {
34
- const input = await readStdinJson(INPUT_LIMIT, [], [FILE_KEY]);
16
+ const input = await readStdinJson(INPUT_LIMIT, [], [FILE_KEY, EXPORT_KEY]);
35
17
  if (!input.ok) {
36
18
  complain(input.problem);
37
19
  return 1;
38
20
  }
21
+ const exportValue = input.values[EXPORT_KEY];
22
+ if (exportValue !== undefined && exportValue !== EXPORT_ON && exportValue !== EXPORT_OFF) {
23
+ complain(`${EXPORT_KEY} must be "${EXPORT_ON}" or "${EXPORT_OFF}"`);
24
+ return 1;
25
+ }
26
+ const exportOn = exportValue === EXPORT_ON;
39
27
  const file = input.values[FILE_KEY];
40
28
  let state = EMPTY_STATE;
41
29
  if (file !== undefined) {
@@ -48,12 +36,18 @@ export async function auth(configDir) {
48
36
  }
49
37
  const secrets = openFileSecrets({ configDir, connector: NAME });
50
38
  try {
51
- secrets.put(STATE_HANDLE, state);
39
+ secrets.withLock(() => {
40
+ if (exportOn)
41
+ secrets.put(EXPORT_HANDLE, EXPORT_ON, { secret: false });
42
+ else
43
+ secrets.remove(EXPORT_HANDLE);
44
+ secrets.put(STATE_HANDLE, state);
45
+ });
52
46
  }
53
47
  catch (e) {
54
48
  complain(`the login state was not stored (run auth again): ${e instanceof Error ? e.message : 'unknown error'}`);
55
49
  return 1;
56
50
  }
57
- say(`stored ${file === undefined ? 'an empty login state (a clean profile)' : 'the login state of the file'} of connector ${NAME} as handle ${STATE_HANDLE} under ${secrets.directory}`);
51
+ say(`stored ${file === undefined ? 'an empty login state (a clean profile)' : 'the login state of the file'} of connector ${NAME} as handle ${STATE_HANDLE} under ${secrets.directory}; export of the login state ${exportOn ? `on (handle ${EXPORT_HANDLE})` : 'off'}`);
58
52
  return 0;
59
53
  }
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { auth } from './auth.js';
3
3
  import { NAME } from './names.js';
4
4
  import { serve } from './serve.js';
5
5
  import { PLAYWRIGHT_MCP } from './upstream.js';
6
- const AUTH_READS = '{} or {"storageStateFile": "<absolute path>"} as one line';
6
+ const AUTH_READS = '{} or {"storageStateFile": "<absolute path>"}, with "export": "on" or "off" optional, as one line';
7
7
  export function main(argv, env) {
8
8
  return connectorCli({ name: NAME, serve, install: PLAYWRIGHT_MCP.install, auth, authReads: AUTH_READS }, argv, env);
9
9
  }
package/dist/names.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export declare const NAME = "browser";
2
2
  export declare const STATE_HANDLE = "BROWSER_STORAGE_STATE";
3
+ export declare const EXPORT_HANDLE = "BROWSER_EXPORT";
4
+ export declare const EXPORT_ON = "on";
3
5
  export declare const PROGRAM: string, say: (line: string) => void, complain: (line: string) => void;
package/dist/names.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { cliOutput } from '@sema-agent/connectors';
2
2
  export const NAME = 'browser';
3
3
  export const STATE_HANDLE = 'BROWSER_STORAGE_STATE';
4
+ export const EXPORT_HANDLE = 'BROWSER_EXPORT';
5
+ export const EXPORT_ON = 'on';
4
6
  export const { program: PROGRAM, say, complain } = cliOutput(NAME);
@@ -0,0 +1,5 @@
1
+ import type { FileSecrets, UpstreamRelay } from '@sema-agent/connectors';
2
+ export declare function exportRelay({ out, secrets }: {
3
+ readonly out: string;
4
+ readonly secrets: FileSecrets;
5
+ }): UpstreamRelay;
package/dist/relay.js ADDED
@@ -0,0 +1,81 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { STATE_HANDLE, complain } from './names.js';
5
+ import { readState, stateFileBytes } from './state.js';
6
+ import { SAVE_TOOL, WITHHELD } from './upstream.js';
7
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
8
+ const keyOf = (id) => (typeof id === 'string' || typeof id === 'number' ? `${typeof id}:${String(id)}` : null);
9
+ const lineOf = (m) => Buffer.from(JSON.stringify(m), 'utf8');
10
+ const failed = (id, text) => lineOf({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
11
+ const notFound = (id, name) => failed(id, `### Error\nTool "${name}" not found`);
12
+ export function exportRelay({ out, secrets }) {
13
+ const lists = new Set();
14
+ const saves = new Map();
15
+ const store = (file) => {
16
+ const saved = stateFileBytes(file, 'the saved storage state', false);
17
+ if (!saved.ok)
18
+ return saved.problem;
19
+ const read = readState(saved.bytes);
20
+ if (!read.ok)
21
+ return read.problem;
22
+ try {
23
+ secrets.put(STATE_HANDLE, read.text);
24
+ }
25
+ catch (e) {
26
+ return `the secrets backend did not store it (${e instanceof Error ? e.message : 'unknown error'})`;
27
+ }
28
+ return null;
29
+ };
30
+ return {
31
+ toProgram(line, m) {
32
+ if (m === null)
33
+ return line;
34
+ const key = keyOf(m.id);
35
+ if (m.method === 'tools/list') {
36
+ if (key !== null)
37
+ lists.add(key);
38
+ return line;
39
+ }
40
+ const params = isObject(m.params) ? m.params : null;
41
+ if (m.method !== 'tools/call' || typeof params?.name !== 'string')
42
+ return line;
43
+ if (WITHHELD.includes(params.name))
44
+ return key === null ? null : { answer: notFound(m.id, params.name) };
45
+ if (params.name !== SAVE_TOOL)
46
+ return line;
47
+ if (key === null)
48
+ return null;
49
+ const file = join(out, `export-${randomBytes(16).toString('hex')}.json`);
50
+ saves.set(key, file);
51
+ return lineOf({ ...m, params: { ...params, arguments: { filename: file } } });
52
+ },
53
+ toConnector(line, m) {
54
+ if (m === null || 'method' in m)
55
+ return line;
56
+ const key = keyOf(m.id);
57
+ if (key === null)
58
+ return line;
59
+ if (lists.delete(key)) {
60
+ if (!isObject(m.result) || !Array.isArray(m.result.tools))
61
+ return line;
62
+ return lineOf({ ...m, result: { ...m.result, tools: m.result.tools.filter((t) => !(isObject(t) && typeof t.name === 'string' && WITHHELD.includes(t.name))) } });
63
+ }
64
+ const file = saves.get(key);
65
+ if (file === undefined)
66
+ return line;
67
+ saves.delete(key);
68
+ const problem = isObject(m.result) && m.result.isError !== true ? store(file) : null;
69
+ try {
70
+ rmSync(file, { force: true });
71
+ }
72
+ catch (e) {
73
+ complain(`the file of a saved login state could not be removed (${e.code ?? 'unknown error'}); it goes with the launch's directory`);
74
+ }
75
+ if (problem === null)
76
+ return line;
77
+ complain(`the login state was not stored: ${problem}`);
78
+ return failed(m.id, `### Error\nThe login state was not stored: ${problem}`);
79
+ },
80
+ };
81
+ }
package/dist/serve.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import type { Env } from '@sema-agent/connectors';
1
+ import { type Env } from '@sema-agent/connectors';
2
2
  export declare function serve(env: Env): Promise<number>;
package/dist/serve.js CHANGED
@@ -1,31 +1,10 @@
1
- import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { NAME, PROGRAM, STATE_HANDLE, complain } from './names.js';
4
- import { STATE_LIMIT, readState } from './state.js';
1
+ import { lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { openFileSecrets } from '@sema-agent/connectors';
4
+ import { EXPORT_HANDLE, EXPORT_ON, NAME, PROGRAM, STATE_HANDLE, complain } from './names.js';
5
+ import { exportRelay } from './relay.js';
6
+ import { readState, stateFileBytes } from './state.js';
5
7
  import { CHROME_EXECUTABLE, OUT_DIR, PLAYWRIGHT_MCP, SETTINGS_PREFIX, argsFor } from './upstream.js';
6
- function copyOf(path) {
7
- let fd;
8
- try {
9
- fd = openSync(path, 'r');
10
- }
11
- catch {
12
- return null;
13
- }
14
- try {
15
- const st = fstatSync(fd);
16
- if (!st.isFile())
17
- return null;
18
- if (st.size > STATE_LIMIT)
19
- return Buffer.alloc(STATE_LIMIT + 1);
20
- return readFileSync(fd);
21
- }
22
- catch {
23
- return null;
24
- }
25
- finally {
26
- closeSync(fd);
27
- }
28
- }
29
8
  function outputDirectory(out) {
30
9
  try {
31
10
  mkdirSync(out, { mode: 0o700 });
@@ -37,6 +16,14 @@ function outputDirectory(out) {
37
16
  }
38
17
  return lstatSync(out).isDirectory() ? null : `${OUT_DIR} next to the ${STATE_HANDLE} copy is not a directory: start the connector with sema-connector run ${NAME}`;
39
18
  }
19
+ function exportStored(path) {
20
+ try {
21
+ return statSync(path).size <= 64 && readFileSync(path, 'utf8').trim() === EXPORT_ON;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
40
27
  export function serve(env) {
41
28
  const refuse = (problem) => {
42
29
  complain(problem);
@@ -47,12 +34,17 @@ export function serve(env) {
47
34
  if (!start.ok)
48
35
  return refuse(start.problem);
49
36
  const state = start.files[STATE_HANDLE];
50
- const bytes = copyOf(state);
51
- if (bytes === null)
52
- return refuse(`the ${STATE_HANDLE} handle cannot be read`);
53
- const read = readState(bytes);
37
+ const copy = stateFileBytes(state, `the ${STATE_HANDLE} handle`, false);
38
+ const read = copy.ok ? readState(copy.bytes) : copy;
54
39
  if (!read.ok)
55
40
  return refuse(`the stored ${STATE_HANDLE} handle is refused: ${read.problem}; store it again with ${PROGRAM} auth`);
41
+ const exportFile = start.files[EXPORT_HANDLE];
42
+ const exportOn = exportFile !== undefined;
43
+ if (exportOn && !exportStored(exportFile))
44
+ return refuse(`the stored ${EXPORT_HANDLE} handle is refused: it holds something other than ${EXPORT_ON}; store it again with ${PROGRAM} auth`);
45
+ const secrets = openFileSecrets({ configDir: dirname(dirname(resolve(start.connectorDir))), connector: NAME });
46
+ if (exportOn && secrets.directory !== resolve(start.connectorDir))
47
+ return refuse(`the export of the login state needs the connector's directory to be <configuration directory>/connectors/${NAME}: start the connector with sema-connector run ${NAME}`);
56
48
  const where = CHROME_EXECUTABLE[process.platform];
57
49
  if (where === undefined)
58
50
  return refuse(`Chrome for Testing is not pinned for ${process.platform}`);
@@ -70,5 +62,6 @@ export function serve(env) {
70
62
  if (problem !== null)
71
63
  return refuse(problem);
72
64
  process.chdir(out);
73
- return start.run(argsFor({ chrome, out, state }), {});
65
+ const relay = exportOn ? exportRelay({ out, secrets }) : undefined;
66
+ return start.run(argsFor({ chrome, out, state }, exportOn), {}, relay);
74
67
  }
package/dist/state.d.ts CHANGED
@@ -7,4 +7,12 @@ export type StateReading = {
7
7
  readonly ok: false;
8
8
  readonly problem: string;
9
9
  };
10
+ export type StateBytes = {
11
+ readonly ok: true;
12
+ readonly bytes: Buffer;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly problem: string;
16
+ };
17
+ export declare function stateFileBytes(path: string, what: string, followLink: boolean): StateBytes;
10
18
  export declare function readState(bytes: Uint8Array): StateReading;
package/dist/state.js CHANGED
@@ -1,5 +1,29 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs';
1
2
  export const STATE_LIMIT = 4 * 1024 * 1024;
2
3
  export const EMPTY_STATE = '{"cookies":[],"origins":[]}';
4
+ export function stateFileBytes(path, what, followLink) {
5
+ let fd;
6
+ try {
7
+ fd = openSync(path, followLink ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW);
8
+ }
9
+ catch {
10
+ return { ok: false, problem: `${what} cannot be read` };
11
+ }
12
+ try {
13
+ const st = fstatSync(fd);
14
+ if (!st.isFile())
15
+ return { ok: false, problem: `${what} is not a regular file` };
16
+ if (st.size > STATE_LIMIT)
17
+ return { ok: false, problem: `the login state is larger than ${STATE_LIMIT} bytes` };
18
+ return { ok: true, bytes: readFileSync(fd) };
19
+ }
20
+ catch {
21
+ return { ok: false, problem: `${what} cannot be read` };
22
+ }
23
+ finally {
24
+ closeSync(fd);
25
+ }
26
+ }
3
27
  export function readState(bytes) {
4
28
  if (bytes.length > STATE_LIMIT)
5
29
  return { ok: false, problem: `the login state is larger than ${STATE_LIMIT} bytes` };
@@ -5,8 +5,11 @@ export declare const OUT_DIR = "out";
5
5
  export declare const SETTINGS_PREFIX = "PLAYWRIGHT_MCP_";
6
6
  export declare const CHROME_EXECUTABLE: Readonly<Record<string, string>>;
7
7
  export declare const FIXED_FLAGS: readonly string[];
8
+ export declare const EXPORT_FLAGS: readonly string[];
9
+ export declare const SAVE_TOOL = "browser_storage_state";
10
+ export declare const WITHHELD: readonly string[];
8
11
  export declare function argsFor(paths: {
9
12
  readonly chrome: string;
10
13
  readonly out: string;
11
14
  readonly state: string;
12
- }): readonly string[];
15
+ }, exportOn: boolean): readonly string[];
package/dist/upstream.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { pinnedUpstream } from '@sema-agent/connectors';
2
- import { NAME, STATE_HANDLE } from './names.js';
2
+ import { EXPORT_HANDLE, NAME, STATE_HANDLE } from './names.js';
3
3
  export const UPSTREAM = 'playwright-mcp';
4
- export const PLAYWRIGHT_MCP = pinnedUpstream({ connector: NAME, upstream: UPSTREAM, lockFile: new URL('../binaries.lock.json', import.meta.url), handles: [STATE_HANDLE] });
4
+ export const PLAYWRIGHT_MCP = pinnedUpstream({ connector: NAME, upstream: UPSTREAM, lockFile: new URL('../binaries.lock.json', import.meta.url), handles: [STATE_HANDLE], optionalHandles: [EXPORT_HANDLE] });
5
5
  export const OUTPUT_MAX_SIZE = 64 * 1024 * 1024;
6
6
  export const OUT_DIR = 'out';
7
7
  export const SETTINGS_PREFIX = 'PLAYWRIGHT_MCP_';
@@ -10,6 +10,26 @@ export const CHROME_EXECUTABLE = Object.freeze({
10
10
  linux: 'chrome/chrome',
11
11
  });
12
12
  export const FIXED_FLAGS = Object.freeze(['--headless', '--isolated', '--browser', 'chromium', '--sandbox', '--block-service-workers', '--no-webmcp', '--timeout-navigation', '60000', '--output-max-size', String(OUTPUT_MAX_SIZE)]);
13
- export function argsFor(paths) {
14
- return [...FIXED_FLAGS, '--executable-path', paths.chrome, '--output-dir', paths.out, '--storage-state', paths.state];
13
+ export const EXPORT_FLAGS = Object.freeze(['--caps', 'storage']);
14
+ export const SAVE_TOOL = 'browser_storage_state';
15
+ export const WITHHELD = Object.freeze([
16
+ 'browser_cookie_list',
17
+ 'browser_cookie_get',
18
+ 'browser_cookie_set',
19
+ 'browser_cookie_delete',
20
+ 'browser_cookie_clear',
21
+ 'browser_set_storage_state',
22
+ 'browser_localstorage_list',
23
+ 'browser_localstorage_get',
24
+ 'browser_localstorage_set',
25
+ 'browser_localstorage_delete',
26
+ 'browser_localstorage_clear',
27
+ 'browser_sessionstorage_list',
28
+ 'browser_sessionstorage_get',
29
+ 'browser_sessionstorage_set',
30
+ 'browser_sessionstorage_delete',
31
+ 'browser_sessionstorage_clear',
32
+ ]);
33
+ export function argsFor(paths, exportOn) {
34
+ return [...FIXED_FLAGS, '--executable-path', paths.chrome, '--output-dir', paths.out, '--storage-state', paths.state, ...(exportOn ? EXPORT_FLAGS : [])];
15
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connector-browser",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Browser connector: runs the pinned Playwright MCP server and a pinned Chrome for Testing behind the sema-connector launcher, in an isolated, headless session whose login state is a secret handle.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,14 +15,15 @@
15
15
  "dist",
16
16
  "CHANGELOG.md",
17
17
  "binaries.lock.json",
18
- "NOTICE"
18
+ "NOTICE",
19
+ "README.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "build": "tsc -p tsconfig.json"
22
23
  },
23
24
  "dependencies": {
24
- "@sema-agent/connectors": "0.7.0",
25
- "@sema-agent/connectors-contract": "0.1.1"
25
+ "@sema-agent/connectors": "0.8.0",
26
+ "@sema-agent/connectors-contract": "0.2.0"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=22"