@sema-agent/connector-browser 0.1.2 → 0.2.1

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,51 @@ 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.1 (2026-10-24)
8
+
9
+ ### Changed
10
+
11
+ - Ships its own copy of `docs/KNOWN-LIMITS.md` as `KNOWN-LIMITS.md` inside the package (CN-063: its README's
12
+ mention of `docs/KNOWN-LIMITS.md` pointed at a repository path that did not resolve once installed), and is
13
+ re-pinned to `@sema-agent/connectors` 0.8.1 at release (CN-062, CN-064).
14
+
15
+ ## 0.2.0 (2026-10-23)
16
+
17
+ ### Added
18
+
19
+ - The export of the login state (CN-055), off until the person turns it on: `auth` also
20
+ takes `"export": "on"` or `"off"` next to `{}` or `storageStateFile`
21
+ (`{"export": "on"}`, `{"storageStateFile": "<absolute path>", "export": "on"}`); every
22
+ `auth` states the whole configuration, so `"off"` or no `export` key turns it off. The
23
+ switch is the handle `BROWSER_EXPORT` (holding `on`, not a secret), stored or removed in
24
+ one group with `BROWSER_STORAGE_STATE`; a stored switch that holds anything else refuses
25
+ the start.
26
+ - With the export on, playwright-mcp also gets `--caps storage`, and the connector's
27
+ standard input and output pass through a relay. Of the seventeen storage tools only
28
+ `browser_storage_state` is offered (26 tools in all): the sixteen that answer with cookie
29
+ or web storage values, or load a state, are withheld from `tools/list` and answered as a
30
+ tool playwright-mcp does not have, never reaching it. A `browser_storage_state` call is
31
+ given a file the connector names in the launch's output directory (a name no page can
32
+ guess, whatever name the caller gave); when it succeeds, that file is held to the login
33
+ state rule and stored as `BROWSER_STORAGE_STATE`, then removed, and the next launch starts
34
+ from it. A save that cannot be stored stores nothing and its answer says why, without the
35
+ content. No other file is read: state files a page downloads into the output directory
36
+ are never taken for the saved one.
37
+
38
+ ### Changed
39
+
40
+ - Retires KL-15 (logins made in a session were never kept): with the export on, a session
41
+ keeps the login it saves. New KL-26: a saved state holds everything the browser holds,
42
+ the cookies pages set for their own sites included.
43
+ - With the export off, the arguments playwright-mcp gets and the way MCP passes through are
44
+ exactly as before.
45
+ - With `@sema-agent/connectors` 0.8.0 (CN-040), playwright-mcp runs with `HOME` set to its own home in the
46
+ connector's directory, so Playwright's registry entry for the running browser lies under `<CONNECTOR_DIR>/home`
47
+ instead of the user's `Library/Caches` (macOS) or `.cache` (Linux).
48
+ - With `@sema-agent/connectors` 0.8.0 and `@sema-agent/connectors-contract` 0.2.0 (CN-030), a request the launcher
49
+ does not take is answered with `connector.request_unreadable` and its `error.data.reason` instead of
50
+ `connector.forbidden_scope`, which now means only a tool outside the granted scope.
51
+
7
52
  ## 0.1.2 (2026-10-21)
8
53
 
9
54
  ### Documentation
@@ -0,0 +1,35 @@
1
+ # Known limits
2
+
3
+ Accepted, low-severity limits that are not scheduled for a fix. Each entry states when it would stop
4
+ being acceptable. An entry must not describe a second source of truth, a documented exception to a
5
+ contract, or a long-lived transitional path: those are defects and get a ticket instead.
6
+
7
+ | ID | Symptom | Why it is not fixed | Trigger | Upgrade criterion |
8
+ |---|---|---|---|---|
9
+ | KL-01 | A standalone catalog entry names its configuration directory with an absolute `--config-dir`. After the directory is moved, or when the `mcp` domain is exported in a bundle to another machine, the entry still names the old path: the launcher finds no secrets there and reports `unauthorized`, and `mount` (and with it the seed) refuses the stale entry as a conflict until it is removed by hand. | The engine starts stdio connectors with a minimal environment, so the entry must carry the directory (CN-028); an absolute path is machine-local by nature, like the installed connector package the entry also depends on. The seed (CN-009) never overwrites an entry, so it does not repair one. | Moving the configuration directory; importing a bundle that carries connector entries. | The CLI wrapper (CN-012) rewriting entries on import, or an engine that hands its configuration directory to stdio connectors. |
10
+ | KL-02 | The end-to-end gate pins `@sema-agent/server` 7.122.0 word for word in `test/negctl/CN-006.json`: the four helper invocations the server starts outside the egress probe (`ps`, two `git` commands and `/bin/bash`, each with its exact arguments) and the variables `MODEL_ID`, `MODEL_API_KEY` and `APPROVAL_GRANT` it needs for a local run. A server release that changes any of them turns the gate red until a person has reviewed the new invocations or variables and updated the fixture. | The server documents neither its helper processes nor the minimal environment of a local run as a stable contract; the fixture records what 7.122.0 does, and a reviewed change is what the gate is for. | A new version of the `@sema-agent/server` development dependency. | The server documents these helpers and variables as a stable contract, or publishes a machine-readable minimal environment that the gate can read instead of the fixture. |
11
+ | KL-03 | On the hosted http face, `GET /status` answers `503` until a client has opened a session and the connector has answered `initialize`; a health probe on a fresh launcher sees an error although the launcher is fine (the compose healthcheck probes for the `401` instead). | The status lists the tools the caller sees, which only a running connector can tell; a `200` before that would carry an invented empty list, and the contract has no way to say "unknown". | Reading `/status` on a hosted launcher before any session, or after the session ended. | A `connector-status-v1` change that can state the tools as unknown, or a hosted launcher that starts its connector at boot (M2, with the token vault). |
12
+ | KL-04 | A connector that takes none of its waiting input for 10 seconds is ended as "stalled" (`connector.internal` on every pending call; exit 5 on stdio), also when it is merely busy: a client that keeps sending more than the pipe holds while one tool runs synchronously for over 10 seconds loses its pending calls. A reader taking at least about 1 KiB per 10 seconds is not affected. | The launcher cannot tell "stopped reading" from "busy", and without the bound a connector that stops reading leaves the launcher and itself hanging when upstream only closes its pipe (CN-025). | A connector that blocks for more than 10 seconds while more input than the pipe buffer waits for it. | A connector protocol signal for "busy", or a measured need for a longer bound from a real connector (M1). |
13
+ | KL-05 | A connector directory whose `.auth.lock` was left behind (a process killed while holding it, a power loss during `auth`) stops every launch of that connector: the launcher waits 5 seconds, exits 2 and names the lock; `auth` and `status` wait and refuse the same way, until someone removes the lock file by hand. | The lock is never guessed stale (a live holder could be slow), the same rule as the mount lock; a liveness check by pid would be wrong across machines and containers. | A crash or kill while a write or a launch snapshot holds the lock. | A lock that carries a lease the holder renews, or an operator command that removes a lock after checking its holder. |
14
+ | KL-06 | When a connector has secret values, the launcher holds back the last bytes of the connector's stderr until it knows they do not begin a secret, so a receipt line the launcher writes meanwhile can land in the middle of a connector's log line (the line resumes after the receipt). Programs splitting at the documented line breaks still read every receipt whole; only a person reading the log sees the split. | Withholding needs the lookahead, and receipts are written when a call settles. | A connector writing a log line slowly while a call settles. | A reader that needs receipts apart from the connector's log: a sink of their own (CN-035 kept them on the launcher's stderr, with a bound). |
15
+ | KL-07 | `mount` and `auth` report success once the new file is written whole, synced and renamed into place, but the directory is not synced afterwards and the file sync is the ordinary `fsync` (not the full flush some file systems need): a power loss right after a reported success can leave the old `config.d/mcp.json` or token file, or on some file systems an empty one. | The whole-write rule (CN-042) is about never putting a file cut short in place; crash durability is a separate property with a platform-specific cost. | A power loss or kernel crash within moments of a successful `mount` or `auth`. | A measured need from a deployment, or a platform rule for directory sync and full flush. |
16
+ | KL-08 | Storing a secret handle writes its metadata file before its value file, so a value write that fails leaves the new metadata (for example a new `expiresAt`) next to the old value; the connector CLI reports the failure with exit 1 and `auth` has to be run again. | The order keeps a secret from being marked as not a secret halfway through; the pair is written whole again by the next `auth`. | A write failure between the two files of one handle. | A per-handle write that lands both files or neither. |
17
+ | KL-09 | A seed whose marker write fails (a full disk, a file size limit) exits 1 with `write_failed`, but the entries it mounted stay, unrecorded: a person who removes one of them before the next successful seed sees the next seed mount it again. | Undoing a mount would need a second writer that removes entries from `config.d/mcp.json`, outside `mount`; the next seed that can write the marker finds the entries and records them as present. | A marker write that fails, then an entry removed before a seed that succeeds. | A remove operation through `mount` (the CLI wrapper, CN-012) that the seed can undo with, or a measured case of a failing marker write. |
18
+ | KL-10 | A write that fails before anything is in place can leave an empty directory behind: `mount` creates `config.d` before it writes the catalog file, and the secrets backend creates `connectors/<name>` before it writes a handle, so a refused `mount` on a fresh directory leaves an empty `config.d`, a failed `put` an empty `connectors/<name>`, and an `install` refused for an archive that does not match the lock an empty `connectors/<name>/bin` (0700). | An empty directory changes nothing a reader sees (no catalog entry, no handle), and removing it would need a guess that nothing else is about to use it. | A first `mount` or `put` whose write fails (a full disk, a file size limit). | A reader that treats the empty directory as a state of its own. |
19
+ | KL-11 | The GitHub connector hands its personal access token to github-mcp-server in the variable `GITHUB_PERSONAL_ACCESS_TOKEN`: while github-mcp-server runs, a process of the same user (or root) that reads other processes' environments reads the token, and so does a crash report or core dump that records the environment. The Gitea connector hands gitea-mcp only the path of its 0600 copy, so its token is only ever in a file (which the same user can read too). | github-mcp-server 2.0.2 takes a personal access token from that variable only: it has no file or descriptor input for it. An argument would be readable by every process, so the variable is the narrowest way in. | Running the GitHub connector where other processes of the same user are not trusted. | github-mcp-server gains a file or descriptor input for the token (the connector then hands it the per-launch copy's path, as for Gitea). |
20
+ | KL-12 | With a token that looks like a classic personal access token (it starts with `ghp_`), github-mcp-server 2.0.2 sends `HEAD https://api.github.com/` (with a stored host, `HEAD <host>/api/v3/`) with the token every time it starts, before any tool is called, to read the token's scopes: every start of the GitHub connector, `sema-connector-github status` included, contacts GitHub. With a token that starts like a fine-grained one (`github_pat_`) or with neither prefix it connects nowhere until a tool is called; other kinds of token (such as `gho_`) were not measured. | It is github-mcp-server's own start-up (it hides the tools a classic token cannot use) and no option of it turns the request off; the destination is the GitHub API the token belongs to, and the gate fails on any other. | Starting the GitHub connector with a classic personal access token. | github-mcp-server gains an option to skip the scope request, or a deployment needs a start without network (a fine-grained token avoids it today). |
21
+ | KL-13 | A standalone connector that needs no credentials never runs: with no secret handle the launcher parks the session (see "Parked session" in `ARCHITECTURE.md`), so such a connector shows `unauthorized`, lists no tools and has no `auth` to run; storing any secret handle for it starts it. | The state has one judgement (a secret handle is present or not) and no second fact such as a declaration by the connector that it needs none; the private stub and the gates' planted connectors store a handle first. | A connector package that takes no credential, mounted in a standalone directory. | A connector of this repository that genuinely needs no credential (the browser connector of M1 keeps its login state as a handle instead). |
22
+ | KL-14 | `statusCommand` and `seed` find the launcher and the connector packages through this package's own `package.json` (`import.meta.url`), so a copy of `@sema-agent/connectors` inlined into another program's bundle cannot run them (`mount` and `mountCommand` are unaffected); the package is consumed installed. | A bundled copy has no package directory to resolve from, and a second way of finding the launcher would be a second source. | A command-line tool that bundles its dependencies and calls `statusCommand` or `seed`. | A consumer that must bundle; then the launcher path and the resolution root become explicit inputs of those two functions. |
23
+ | KL-23 | A standalone browser connector that is mounted without a `tools.json` grants every tool, `browser_run_code_unsafe` and `browser_evaluate` among them (CN-054 gives the operator the allow list; nothing writes one by default). | A default policy would be a second source of what the operator decides, and the shared layer does not know one connector's tools from another's. | The browser connector mounted with no policy file. | A connector package that ships a default `tools.json` the operator can override, as one rule for every connector. |
24
+ | KL-24 | The GitHub connector authenticates with a personal access token only; github-mcp-server's own OAuth login never runs, because the parked session keeps the upstream from starting without a token (CN-052). | An OAuth flow needs a person at a browser and a place to keep the result; the connector's `auth` and the handle store are that place, and the flow is designed when a deployment needs it rather than guessed now. | A deployment that cannot issue personal access tokens. | A deployment that needs OAuth: then `auth` gains an OAuth form that stores the result as the token handle (the GitHub connector's next minor). |
25
+ | KL-17 | The standalone browser connector has no egress policy: the browser opens whatever URL a tool call names, follows redirects and loads what pages load. playwright-mcp's `--allowed-origins` is not a security boundary (playwright-mcp says so) and is not used. | Browsing what the engine asks for is the connector's purpose; an egress policy belongs to the hosted container (M2, M3), enforced outside the browser. | Any browsing. | The hosted deployment's egress proxy (M3), or a standalone policy with an enforcement point outside the browser. |
26
+ | KL-18 | On Linux, Chromium's sandbox needs unprivileged user namespaces (or a setuid sandbox helper): in a container without them Chromium does not start and every page tool fails. The browser connector always passes `--sandbox` (playwright-mcp leaves the sandbox off on Linux for Chrome for Testing otherwise) and never `--no-sandbox`. | Without the sandbox a compromised renderer acts as the user; the container's profile (user namespaces, seccomp) is the deployment's to provide, not the connector's to work around. | The browser connector on Linux where user namespaces are disabled, as in many container runtimes by default. | The hosted deployment declares a container profile with user namespaces (M2), or a measured deployment that cannot provide them. |
27
+ | KL-19 | Chrome for Testing 155 contacts Google on its own when it starts, although Playwright passes switches meant to keep it quiet: the browser gate's proxied run names `accounts.google.com`, `update.googleapis.com`, `www.google.com` and `android.clients.google.com` (port 443), and past a proxy Chromium also connects a UDP socket to `[2001:4860:4860::8888]:443`; without a proxy it goes through the system's proxy settings or directly. | The requests come from Chromium components no option of playwright-mcp turns off, and the connector passes Chromium no switch of its own; the gate contains them and fails on a host outside google.com and googleapis.com. | Every start of the browser. | A playwright-mcp option or a Chromium switch that stops them, an egress policy (KL-17), or a host outside those domains in the gate's reading. |
28
+ | KL-20 | Chrome for Testing is pinned for `darwin-arm64` and `linux-x64` only: on another platform `install` of the browser connector refuses (`pins no playwright-mcp build for <platform>`). | Chrome for Testing publishes no Linux arm64 build, and the macOS x64 build has not been fetched and checked. | Installing the browser connector on another platform. | A request for another platform, with its archive downloaded and its sha256 checked. |
29
+ | KL-21 | While the browser runs, playwright-mcp publishes it for other Playwright tools of the same user: a socket under `/tmp/pw-<hash>/browser/` (the system's temporary directory: the launcher passes no `TMPDIR` to the connector) and an entry in Playwright's registry, which since CN-040 lies in the program's home in the connector's directory (`<CONNECTOR_DIR>/home/Library/Caches/ms-playwright/b` on macOS, `<CONNECTOR_DIR>/home/.cache/ms-playwright/b` on Linux) rather than in the user's home. A process of the same user can find the socket and attach to the browser and its session there, and the entry stays behind when the browser does not close normally. | It is playwright-mcp's own behaviour, with no option to turn it off; a process of the same user can read the stored login state in the configuration directory anyway. | The browser connector where other processes of the same user are not trusted. | playwright-mcp gains an option not to publish the browser, or the hosted form (one container per session). |
30
+ | KL-22 | When playwright-mcp is killed with `SIGKILL` before it has closed the browser, Chromium (a process group of its own) keeps running, unreachable, until it is killed by hand (measured still running 86 seconds later). The launcher's teardown sends `SIGKILL` two steps (2 s) after the end of input; playwright-mcp closes the browser on the end of its input (measured 0.16 to 0.33 s) or on `SIGTERM` (0.16 s on a quiet machine, 2.2 and 5.4 s in two gate runs on a loaded one), so a close that outlasts the two steps is possible under load. | Playwright starts Chromium in a process group of its own and nothing outside playwright-mcp knows its pid; the teardown steps are one rule for every connector (CN-025). | playwright-mcp killed with `SIGKILL` (out of memory, or a close that outlasts the teardown steps). | A browser left running in normal use, or a way for the connector to learn Chromium's process group. |
31
+ | KL-25 | What a pinned upstream program keeps under its home, `<CONNECTOR_DIR>/home` (CN-040), is neither scanned nor redacted, and nothing removes it: gitea-mcp's log (its tool errors, which can name URLs and user names of the Gitea server), Playwright's registry of running browsers, and whatever caches a program keeps. The gates measured no dummy token in gitea-mcp's log after a tool call. | The files are the program's own and stay inside the 0700 connector directory, outside the user's home; the launcher keeps secret values out of what it forwards (the message path and stderr), and scanning a program's private files would be a second redaction path with no rule for what it may change. | An upstream program that writes a value it was given (a token, a host's credentials) into a file under its home. | A token measured in a file under a program's home, or an upstream option to turn its file log off. |
32
+ | KL-26 | With the browser connector's export of the login state on (CN-055), a save stores everything the browser holds at that moment: the session's login, and also the cookies and local storage any page visited in the session set for its own site, including a login a page forced on another site (a login cross-site request forgery). The next launch starts from all of it, as a browser with a profile on disk would. | The state is playwright-mcp's own `browser_storage_state` of the whole browser context; choosing which origins to keep would be a second rule over a state the connector does not otherwise interpret, and the person turned the export on to keep what the session holds. | A save after browsing pages that are not trusted, with the export on. | A request to keep only named sites, or a measured case of a stored state carrying a page's forced login. |
33
+ | KL-27 | A request line the launcher cannot read on stdio (over 4 MiB, a batch, a repeated key, an id that is neither a string nor a number) is answered with `id` null and `connector.request_unreadable` (CN-030), so an MCP client cannot match the answer to the request it sent: it sees an error it cannot place and waits for that request until its own timeout. Over the hosted http face the same refusal is the answer to its own http request (`400` or `413`), so nothing waits there. | Nothing of a line the launcher cannot read is read, its id included: a batch has no single id, a line over 4 MiB is never parsed, and an object with a repeated key may carry an id that parsers read differently, so reading an id out of it would judge a message the launcher has just refused to judge. JSON-RPC answers a request whose id it cannot detect with id null too. | A tool call whose arguments make its line longer than 4 MiB; a batch, a repeated key or an id of another type, which the MCP SDK clients do not send. | A client measured waiting on such a request in normal use, or one reading of the id that every parser shares agreed for these lines. |
34
+ | KL-28 | A host can stop delivering the sandbox reports of one executable while the sandbox goes on refusing (seen on 2026-10-22: for hours no report of a refused node connection, while bash was reported as before). The connector gates then cannot see what node processes try past the probe, the planted upstreams included: the attempts are refused (the gates' control processes read `EPERM`) but not observed, and the last line of each gate says so ("the host delivered no sandbox reports for node, so attempts by unprobed processes (...) were blocked but not observed"). Delivery is shown only for the executables of the gates' control processes (bash and node); for gitea-mcp, github-mcp-server and Chrome for Testing the gates read the reports that come and cannot tell a host that drops them. | The reports are the host's to deliver, and nothing in sandbox-exec or the system log asks for them; what keeps a connection from leaving is the refusal, which the control still proves on every run. Failing the gate would turn it red for something this repository cannot change. | A host that stops delivering the reports of an executable. | A check that needs the reports themselves, not the refusal, comes to depend on them (github-mcp-server's name lookups are read from its reports), or the reports of a pinned program are seen missing: then that program gets a control process of its own. |
35
+ | KL-29 | On the hosted http face a session that has had no request of its own for 10 minutes is released (CN-033): a client that holds a session, sends nothing for longer and then sends a request gets `404` for its session id and has to `initialize` again (MCP Streamable HTTP answers that `404` with a new session). A request in flight for longer than 10 minutes is not affected, and neither is its session while it waits. The 10 minutes are fixed: no deployment setting changes them. | The face opens no stream, so the launcher has no sign that a client is still there other than its requests; a session held until `DELETE` kept every other client at `409` until the launcher restarted, and one fixed time is one rule for every deployment. | A client idle for more than 10 minutes within one session. | A deployment measuring clients that are idle for longer in normal use, or the hosted session model of M2 (a session per client, or a stream that shows the client is there). |
package/README.md CHANGED
@@ -10,7 +10,9 @@ configuration directory from `CONFIG_LOCAL_DIR` like every connector's
10
10
  ([`INTEGRATION-SERVER.md`](../INTEGRATION-SERVER.md)).
11
11
 
12
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"). Chrome for Testing is pinned for `darwin-arm64` and `linux-x64` only.
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.
14
16
 
15
17
  ## Setting it up
16
18
 
@@ -25,13 +27,15 @@ sema-connector-browser status
25
27
  On Linux the connector keeps Chromium's own sandbox on (`--sandbox`, never `--no-sandbox`), which needs user
26
28
  namespaces: in a container that does not allow them (Docker's default seccomp profile, for one) the first
27
29
  `browser_navigate` answers `Chromium sandboxing failed!` and nothing is browsed. Run the connector on a host or in a
28
- container that allows user namespaces (KL-18 in the repository's `docs/KNOWN-LIMITS.md`); the connector never turns
29
- the sandbox off.
30
+ container that allows user namespaces (KL-18; see `KNOWN-LIMITS.md`, shipped with this package and, in the
31
+ repository, `docs/KNOWN-LIMITS.md`); the connector never turns the sandbox off.
30
32
 
31
33
  Then restart the engine. To start from a login instead of a clean profile, save a Playwright storage state (for example
32
34
  with Playwright's `context.storageState({ path })` after logging in) to a file only you can read, and give `auth` its path:
33
35
  `echo '{"storageStateFile": "/absolute/path/state.json"}' | sema-connector-browser auth`. Delete the file afterwards; the
34
- state is a secret (it holds the session cookies).
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"}`.
35
39
 
36
40
  ## The pinned build
37
41
 
@@ -64,24 +68,33 @@ refuses (`binaries.lock.json pins no playwright-mcp build for <platform>`).
64
68
 
65
69
  ## auth
66
70
 
67
- `auth` reads exactly one line from standard input (never from an argument), a JSON object with at most one string member:
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`:
68
73
 
69
74
  ```json
70
75
  {}
71
76
  {"storageStateFile": "<absolute path>"}
77
+ {"export": "on"}
78
+ {"storageStateFile": "<absolute path>", "export": "on"}
72
79
  ```
73
80
 
74
81
  - `{}` stores a clean profile, `{"cookies":[],"origins":[]}`. Empty input is refused rather than read as `{}`: a pipe
75
82
  whose producer failed must not replace a stored login with an empty one.
76
83
  - `storageStateFile` names a Playwright storage state file by its absolute path: at most 4 MiB of UTF-8 JSON holding one
77
84
  object with exactly the keys `cookies` and `origins`, each a list. Its text is stored as it is.
78
- - Any other key, a key given twice, a value that is not a string, or a file outside the rule is refused, and a refusal
79
- never repeats what was read.
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.
80
90
 
81
91
  The state is stored through the secrets backend as the handle `BROWSER_STORAGE_STATE`: a 0600 file in
82
- `<configuration directory>/connectors/browser/` (0700), a secret (no metadata). The write holds the lock of the
83
- connector's directory as for every connector (see "Secrets" in [`ARCHITECTURE.md`](../ARCHITECTURE.md)): a connector
84
- started while `auth` runs gets the old state or the new one, and an `auth` that fails leaves the old one.
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.
85
98
 
86
99
  ## mount and status
87
100
 
@@ -89,24 +102,28 @@ started while `auth` runs gets the old state or the new one, and an `auth` that
89
102
  status the launcher serves, as for every connector. Before `auth` there is no handle, so the launcher does not start the
90
103
  connector and answers itself (see "Parked session" in [`ARCHITECTURE.md`](../ARCHITECTURE.md)): `status` prints
91
104
  `{"state":"unauthorized","tools":[]}`. After `auth` (a clean profile included) the status is `authorized` and lists the
92
- 25 tools of playwright-mcp; reading them starts playwright-mcp but not the browser, which starts at the first tool that
93
- needs a page.
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.
94
107
 
95
108
  ## What runs
96
109
 
97
110
  With no arguments, `sema-connector-browser` is the connector process the launcher starts. It refuses to start (exit 1,
98
111
  with a line on stderr naming what to do) on the shared start check ("Pinned upstream programs": without `CONNECTOR_DIR`,
99
- without the `BROWSER_STORAGE_STATE` handle, when its per-launch copy is not a regular file in `CONNECTOR_DIR`, when `bin/`
100
- does not hold the pinned build), when the stored state breaks the rule of "auth", and when Chrome for Testing is not in
101
- `bin/chrome`. Otherwise it makes the launch's output directory (see "The output directory"), makes it the working
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
102
116
  directory, and starts `node_modules/@playwright/mcp/cli.js` on its own Node.js as its child on the same standard input,
103
- output and error, so every MCP message passes between the launcher and playwright-mcp untouched; it ends as playwright-mcp ends, with its exit code or by the signal that ended it, and passes its stop
104
- signals on. playwright-mcp closes the browser when its input ends or it gets `SIGTERM` (measured well under a second,
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,
105
120
  inside the launcher's teardown steps); killed with `SIGKILL` before that, it leaves Chromium running (KL-22).
106
121
 
107
122
  playwright-mcp gets the arguments of "Security baseline" and the environment the launcher gave the connector without
108
- `CONNECTOR_DIR`, `BROWSER_STORAGE_STATE_FILE` and every `PLAYWRIGHT_MCP_*` variable (the variable twins of its options),
109
- so its options are those arguments whoever starts the connector.
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).
110
127
 
111
128
  ## Security baseline
112
129
 
@@ -125,9 +142,10 @@ The arguments are one table in `src/upstream.ts`; none comes from the environmen
125
142
  | `--executable-path <bin>/chrome/...` | the pinned Chrome for Testing in the bin directory; playwright-mcp never looks for or downloads a browser. |
126
143
  | `--output-dir <launch directory>/out` | screenshots, snapshots and other files land in the launch's own directory and go with it. |
127
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"). |
128
146
 
129
- Never given: `--no-sandbox`, `--caps` (none of the opt-in capabilities `vision`, `pdf`, `devtools`, `config`, `network`,
130
- `storage` is on), `--user-data-dir`, `--save-session`, `--secrets`, `--allow-unrestricted-file-access`, `--proxy-server`,
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`,
131
149
  `--port` and `--host` (MCP runs on standard input and output only), `--init-script`, `--init-page`, `--config`,
132
150
  `--extension`, `--cdp-endpoint`, `--allowed-origins` and `--blocked-origins` (playwright-mcp itself says they are not a
133
151
  security boundary and do not apply to redirects; the connector does not offer them as one).
@@ -142,8 +160,49 @@ marked `HttpOnly`) reaches the engine like any page content, through `browser_ev
142
160
  page. A reply that carries the stored state's text as a whole, the clean profile's `{"cookies":[],"origins":[]}`
143
161
  included, is withheld like any secret value (answered with `connector.internal`).
144
162
 
145
- Logins made during a session are not written back: the profile is in memory (`--isolated`), so they end with the browser
146
- (KL-15). To keep a login, store a new state with `auth`.
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.
147
206
 
148
207
  ## The output directory
149
208
 
@@ -160,18 +219,19 @@ the files its tools read and write (`browser_file_upload`, a screenshot's file n
160
219
 
161
220
  The connector offers every tool playwright-mcp lists; which of them a caller may use is the launcher's scope stage, and
162
221
  standalone grants `tool:*` unless the configuration directory carries a local per-tool policy (CN-054, `ARCHITECTURE.md`,
163
- "Stages on the launcher hop"). The 25 tools, with what each can reach:
222
+ "Stages on the launcher hop"). The 25 tools, and the one the export adds, with what each can reach:
164
223
 
165
224
  | Tool | Reach |
166
225
  |---|---|
167
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. |
168
227
  | `browser_evaluate` | Runs JavaScript in the page, with the page's access: its DOM, storage and script-readable cookies, requests as the page. |
169
- | `browser_network_requests`, `browser_network_request` | The page's requests and responses, headers and bodies included (session cookies and authorization headers of a logged-in site). |
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). |
170
229
  | `browser_file_upload`, `browser_drop` | Give files to a page: files in the output directory or the workspace (see "The output directory"). |
171
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. |
172
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). |
173
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. |
174
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"). |
175
235
 
176
236
  ### A recommended policy
177
237
 
@@ -196,7 +256,8 @@ The example leaves a fifth tool out as well, `browser_file_upload`: a deployment
196
256
  reaches only the output directory and the workspace, not arbitrary files), so the list above holds 20 names and a
197
257
  deployment that keeps `browser_file_upload` lists 21. A tool left out of `allow` fails
198
258
  `tools/call` with `connector.forbidden_scope` and is absent from `tools/list` and the status resource's `tools`;
199
- nothing about the connector itself changes.
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`.
200
261
 
201
262
  ## Outbound connections
202
263
 
@@ -210,13 +271,13 @@ also connects a UDP socket to `[2001:4860:4860::8888]:443` (KL-19); the gate fai
210
271
  googleapis.com. The browser follows the system's proxy settings.
211
272
 
212
273
  playwright-mcp also publishes the running browser for other Playwright tools of the same user: a socket under
213
- `/tmp/pw-<hash>/browser/` and an entry in Playwright's registry directory (`~/Library/Caches/ms-playwright/b` on macOS,
214
- `~/.cache/ms-playwright/b` on Linux), removed when the browser closes. A process of the same user can attach to the
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
215
277
  browser there (KL-21).
216
278
 
217
279
  ## Known limits
218
280
 
219
- - **Logins made in a session are not kept** (KL-15).
220
281
  - **No egress policy in the standalone form** (KL-17).
221
282
  - **The Chromium sandbox needs user namespaces on Linux** (KL-18): in a container without them Chromium does not start;
222
283
  the connector never turns the sandbox off.
@@ -224,6 +285,7 @@ browser there (KL-21).
224
285
  - **Two platforms**: `darwin-arm64` and `linux-x64` (KL-20).
225
286
  - **Other processes of the user can attach to the running browser** (KL-21).
226
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).
227
289
  - **Check, then start:** as for every pinned upstream program (see "Pinned upstream programs" in
228
290
  [`ARCHITECTURE.md`](../ARCHITECTURE.md)): at each start the trees are compared with their archives by their files'
229
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.2",
3
+ "version": "0.2.1",
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",
@@ -16,14 +16,15 @@
16
16
  "CHANGELOG.md",
17
17
  "binaries.lock.json",
18
18
  "NOTICE",
19
- "README.md"
19
+ "README.md",
20
+ "KNOWN-LIMITS.md"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsc -p tsconfig.json"
23
24
  },
24
25
  "dependencies": {
25
- "@sema-agent/connectors": "0.7.0",
26
- "@sema-agent/connectors-contract": "0.1.1"
26
+ "@sema-agent/connectors": "0.8.1",
27
+ "@sema-agent/connectors-contract": "0.2.0"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=22"