
Personae
io.github.leek-emperor由 GitHub 账号 leek-emperor 发布v0.2.1更新于 Oct 24, 2026
Control specific, already-open account-isolated Personae browser identities with MCP.
概览
让助手驱动已打开的 Personae 浏览器身份窗口,并保持各账号的 Cookie 与登录状态相互隔离。
- 可以做什么
- Personae 是一款 Electron 桌面浏览器,每个身份都是独立的 Chromium 分区,其 MCP 服务器把这些窗口开放给智能体。工具包括 list_identities、open_identity、snapshot、navigate、click、fill、press、act、get_text、get_url、screenshot、eval_js 和 load_skill。每个工具都接受 identity 参数,可选参数 target 用于操作弹出式子窗口。该服务器只是一个 stdio 启动器,通过本地桥接连接到正在运行的应用。
- 适用场景
- 适用于浏览器窗口已经存在、且分属不同账号的场景,助手需要附着到某个指定身份而不越界到其他身份。也适合同一网站在多个登录下同时打开、助手必须操作正确窗口的多账号工作流。
- 运行要求
- 需要单独安装并正在运行的 Personae 桌面应用;npm 包只是启动器。该应用仅支持桌面端,已在 macOS arm64 上验证;Windows 有打包产物但运行时未经验证,Linux 不是构建目标。未声明 API 密钥,也未声明环境变量。推荐方式使用应用自带的运行时,因此无需安装 Node。
安装
在 SourceWeft 中
- 打开 控制台中的 Personae,将其添加到工作区。
- 为需要使用其工具的对话启用该服务。
Desktop only,通过 STDIO。 STDIO 服务会启动本地进程,因此需要 SourceWeft 桌面宿主。
其他 MCP 客户端
参照 仓库 中的启动说明。
README
Personae
A multi-identity browser · every identity is an isolated partition, every window is agent-controllable
Bundles agent-browser and an MCP server — no Node or CLI install needed
English · 中文
An Electron desktop browser that combines two things: multi-account isolation and letting AI agents drive the browser.
- Each browser identity is its own Chromium
persist:partition — cookies, localStorage and login state are fully isolated, so you can be signed into the same site under several accounts at once. - Each identity is a standalone window with back/forward buttons and an address bar. It behaves like an ordinary browser.
- The agent-browser binary is bundled, and those windows are exposed over MCP, so Codex / Claude Code can drive the page of a specific identity.
Users don't need to pre-install Node, Chrome for Testing, or any CLI.
MCP Registry
Personae is published to the official MCP Registry
as io.github.leek-emperor/personae after its first Registry-enabled release.
The Registry package is a small stdio
launcher for a running, separately installed Personae desktop app — it does
not replace the browser application or create a cloud browser.
After installing and launching Personae, a client that supports npm MCP packages can run:
The launcher discovers Personae through its local bridge and keeps each tool call scoped to the identity named in the request. It uses no API key and opens no public network listener. The app's Configure Codex control remains the recommended setup path because it uses the bundled Electron runtime and needs no Node installation.
The problem it solves
Existing browser-automation tools assume the agent launches the browser. If your product is a browser client, it's the other way around: the windows already exist, and they belong to different account identities. What the agent needs is to attach without crossing identities.
The approach here:
- The app opens a CDP port on startup (system-assigned, loopback only).
- When an identity window opens, the main process resolves the authoritative targetId of its content page via
Target.getTargetInfo. - A local bridge exposes the
identity → targetIdmapping. - The MCP server uses that targetId directly as a tab ref — a single-step hit.
So snapshot(identity: "Account A") and snapshot(identity: "Account B") always land on the right window, even when both have the same site open with identical titles and URLs.
Quick start
Requires Node ≥ 20 and pnpm (for development only; the packaged app depends on neither).
agent-browser must be ≥ 0.36. Below that, tab list --json does not emit targetId,
which is the only thing tying an identity to its window — every tool would fail to find its
target. The version in package.json is already pinned; don't downgrade it.
Add one or two browser identities in the UI, then click an identity to open its window.
The interface is in English by default. Use the EN / 中 toggle in the top-right corner to switch to Chinese; the choice is remembered and also applies to the toolbar inside each identity window and to the agent prompt you copy out.
Tests
The tricky, mostly-undocumented decisions live in small pure modules so they can be
locked down by tests. They run on node:test with no extra dependencies:
Covered: the window.open / popup decision (window-open.ts), the proxy input
parsing (proxy.ts), address-bar URL normalization (url-input.ts), the
Codex-config TOML splice (toml.ts), and the identity palette (colors.ts).
Packaging:
The default is adhoc signing (identity: '-'), which needs no Apple Developer certificate and runs fine on the build machine. But an adhoc-signed app cannot be distributed — Gatekeeper will block it on someone else's Mac. For real distribution, supply a certificate via environment variables and set notarize to true:
Connecting Codex / Claude Code
The fastest path: let the agent connect itself
The UI has a "Let the agent connect itself" block that renders a ready-to-send prompt. Copy it, paste it into Codex or Claude Code, and the agent will write its own MCP config, restart, and verify the link by calling list_identities.
The prompt isn't just a config snippet — it also tells the agent what the 13 tools do and which mistakes to avoid (stale refs, guessing at agent-browser syntax, trying to tell identities apart by page title). It's generated from live runtime values, so the paths in it are always correct for the machine it's running on.
Or click one button
The same panel has a one-click install that writes the MCP server into ~/.codex/config.toml (idempotent, with an automatic backup first). Paths are resolved at runtime from process.execPath, so they can't be wrong.
Or configure it by hand
On macOS:
The executable under Contents/MacOS/ follows productName, so it's Personae. Note that electron-builder's executableName only applies to Windows.
command points at the app's own binary; ELECTRON_RUN_AS_NODE=1 makes it degrade into a plain Node runtime. That's why no Node install is required (verified: with PATH set to /usr/bin:/bin, the full flow still works).
Claude Code:
MCP tools
Every tool takes an identity argument (name or id).
When click / fill receive an @eN ref they automatically take a snapshot first, because refs are only valid within a single agent-browser process — reusing one across processes always fails with Unknown ref. For multi-step interactions, put them in one act batch.
Driving a popup child window. When a page opens a popup (an OAuth sign-in, a share dialog…), that popup becomes a child window belonging to the same identity (see Popups and OAuth). It's a separate CDP target, listed under the identity in list_identities as children. Every acting/reading tool takes an optional target argument — pass a child's targetId (or its index) to drive the popup instead of the main window; omit it to drive the main window as before.
Architecture
Why each identity window is "a shell plus two WebContentsViews"
The goal is to wrap third-party pages in our own navigation UI without using the <webview> tag. So:
- the shell
BrowserWindowis only a container and loadsabout:blank; - the top bar is a local
chrome.htmlwith a dedicated preload exposing just six navigation methods; - the content area is a
WebContentsViewwhere the partition applies, and it deliberately loads no preload at all, keeping third-party pages untouched.
Nested BrowserWindows and BrowserView both work too, and the agent can drive all three (all verified). WebContentsView was chosen because BrowserView is marked @deprecated in Electron's type definitions, and because a nested window is a separate native window on macOS — dragging, resizing and minimizing would all need manual bounds syncing, which never quite feels like "one browser window".
Popups and OAuth
A page can open a popup with window.open(url, name, features) — OAuth sign-ins, "share to…" dialogs, and so on. These need to be real windows: an OAuth popup relies on window.opener and postMessage/window.close() to hand the result back, so it can't be flattened into an in-page navigation without breaking the flow.
The approach here treats a popup as a first-class child of its identity, without ever trying to guess "is this an OAuth window?":
- Same-window vs child. A plain
<a target="_blank">or a feature-lesswindow.openstays an in-window navigation (no opener, no new target) — that's safe and keeps the "one identity, one main target" invariant. Only anew-window-disposition open (i.e.window.openwith window features) becomes a real child window. - Belongs to the identity. The child is created with
parentset to that identity's shell window and the same partition, so it floats over the right window and its cookies land in the right account.window.openeris preserved, so every OAuth style works.outlivesOpener: falsemeans closing the identity closes its popups. - Popup blocker (on by default). The only thing distinguishing "should open" from "shouldn't" is user activation — was there a real click/keypress in the last second? This is exactly how mainstream browsers' popup blockers work: it never inspects the domain or content. Script-driven pop-unders (no preceding input) are blocked and surfaced as a quiet toast; you can turn the blocker off in the top bar.
- Agents can drive it. Each child is a separate CDP target, resolved to its authoritative
targetIdand listed under the identity aschildren. Every MCP tool takes an optionaltargetto address a specific popup. The "one main target per identity" contract for the outside world isn't broken — it's honestly extended to "one main plus tracked children".
The decision itself (same-window / allow-child / ignore) lives in a pure function, src/main/window-open.ts, and is unit-tested.
Proxy per identity
Each identity can route through its own proxy — useful when you want the same site's accounts to come from different regions, or to test geo-behaviour. You bring your own proxy (buy it from IPRoyal / Webshare / Bright Data / whoever); Personae only provides the setting and wires it up.
- Where it applies. A proxy is set on the identity's
persist:partition viasession.setProxy, so it's genuinely per-identity — identity A can exit via a US IP while identity B exits via Japan. No proxy set means direct connection. - Supported. HTTP, HTTPS and SOCKS5. You can fill the fields separately, or paste a full string like
socks5://user:pass@host:portinto the host box. - Authentication. Proxies with a username/password work. Credentials never go into the proxy rules string (Chromium rejects
user:pass@there); auth is answered through Electron'sloginevent instead. - Passwords are encrypted at rest. The password is stored via
safeStorage(Keychain on macOS, DPAPI on Windows) inuserData/proxy-secrets.json, never inidentities.jsonand never sent back to the renderer. WheresafeStorageis unavailable it falls back to plaintext and the UI says so. - Test connection. A button fetches an IP-echo service through that identity's session and shows the actual egress IP, so you can confirm the proxy is really in effect.
- WebRTC leak guard. For any identity with a proxy,
setWebRTCIPHandlingPolicy('disable_non_proxied_udp')is applied so WebRTC can't bypass the proxy and leak your real IP.
This is not an anti-detect browser. It changes your network exit, nothing more. Beyond the WebRTC guard above, it does not do fingerprint spoofing (Canvas / WebGL / UA / timezone / fonts…), so several identities on the same machine still share the same browser fingerprint. If a platform correlates accounts by fingerprint, a proxy alone won't hide that.
The proxy parsing (parseProxyInput / buildProxyRules) is a pure function in src/main/proxy.ts and is unit-tested, including that credentials never leak into the rules string.
Things that bit us
Kept here because most of them aren't documented anywhere.
The CDP port is per browser process. --remote-debugging-port is a process-level switch and has no webPreferences counterpart, so one Electron app has exactly one CDP server; you cannot give each window its own port. Use port 0 to let the system assign one, then read it back from userData/DevToolsActivePort — that file is written before the bind succeeds, so also probe /json/version for liveness.
--pin-tab simply fails on Electron. It triggers Target.createTarget, which Electron reports as Not supported. Worse, pinning is sticky: once a session has used it, the state is persisted and even close --all won't clear it, breaking every later command. So pass --no-pin-tab explicitly on every call.
A page target with an empty title hangs agent-browser. If the shell window never calls loadURL, it appears in CDP as a page target with empty url and title; attaching to it hangs forever (tab list returns nothing, not even a timeout). Hence the shell must load about:blank and set a readable title.
Use about:blank rather than data:text/html,<title>... — non-ASCII titles in the latter get decoded as latin-1 and turn into mojibake.
Setting the title also has a race: about:blank loads so fast that did-finish-load may fire before the listener is attached, leaving the title stuck at about:blank when several identities open concurrently. Fix: call setTitle synchronously once, then again after load.
Never identify a window by title, url, or tab index. Titles and URLs are identical when several identities open the same site, and the CDP target order does not match agent-browser's tab index order, so indices can't be derived either. Only targetId works.
agent-browser's skill content changes between versions — don't copy syntax off the web. Its own SKILL.md states explicitly that it contains no command syntax and requires skills get to fetch it from the CLI. This project proved the point: tab --url "*settings*" and --pin-tab found online don't exist in earlier versions, while tab list --json emitting targetId is a newer capability — and this project's entire targeting scheme is built on it.
agent-browser searches upward from the binary's location for a skills/ directory, which can collide with an unrelated project's directory of the same name. Pass AGENT_BROWSER_SKILLS_DIR explicitly.
A bundled binary can't rely on asarUnpack alone. asarUnpack places files under app.asar.unpacked/resources/bin/, while process.resourcesPath points at Contents/Resources — not the same location, so the binary isn't found after packaging. Use extraResources to align the paths, and keep asarUnpack limited to files actually imported with ?asset, otherwise the same binary ships twice.
On macOS you can't just "skip signing". Setting identity: null makes electron-builder skip signing entirely; the bundle keeps Electron's original adhoc signature, but the resources have been modified, so signature and content disagree and the app refuses to launch silently (no error, no log; spctl reports code has no resources but signature indicates they must be present). The correct approach is identity: '-' for adhoc signing plus com.apple.security.cs.disable-library-validation in the entitlements — set both entitlements and entitlementsInherit, since the former covers the main process.
The userData directory follows package.json's name, while the executable follows productName. These are two different fields, and executableName only applies to Windows — so if they disagree, you will look in the wrong place. This project deliberately sets both to Personae to avoid that. (Watch out on Linux: its filesystem is case-sensitive, so a directory created under an older lowercase name won't be found.)
publish: generic with a placeholder URL crashes packaging at the very last step. electron-builder's template ships provider: generic with url: https://example.com/auto-updates. Switching to provider: github makes it try to infer a release channel at the end of packaging, and without owner/repo context it throws TypeError: Cannot read properties of null (reading 'channel') — the build is already complete, yet it exits as a failure. This project publishes via gh release upload in CI, so publish: null it is.
A job-level if in GitHub Actions cannot access the matrix context. Writing if: inputs.platforms == matrix.name to filter platforms fails silently (actionlint flags context "matrix" is not allowed here, but GitHub itself doesn't complain), so every platform gets built regardless of the input. Generate the matrix JSON in an upstream job and feed it to strategy.matrix with fromJSON.
When rendering SVG with Electron, suppress window-all-closed. The icon script loops "create window → capture → destroy → create next", and each destroy() drops the window count to zero, which by default quits the whole app — the symptom is a crash after only the first size, with the child reporting No rendezvous client, terminating process (parent died?). It looks like a timing issue but retrying doesn't help. Register an empty window-all-closed handler.
Two more: on Retina displays capturePage outputs at devicePixelRatio (asking for 512 yields 1024), so pin zoomFactor / deviceScaleFactor; and inlining a moderately long SVG into data:text/html;base64,... exceeds the URL length limit and makes loadURL fail with ERR_FAILED (-2), so use a temp file with a file:// reference.
Known limitations and security notes
- Isolation is a convention, not an architectural boundary. The CDP port has no access control. It only listens on loopback with a random port, but any local process that connects can drive every identity, crossing partition boundaries. That's the direct cost of letting external agents attach. Don't handle sensitive accounts on a shared machine.
- In-window navigation for ordinary links.
setWindowOpenHandlerturnswindow.openandtarget="_blank"links into same-window navigation so that one identity always maps to one main target. Genuine popups (window.openwith features, e.g. OAuth) are the exception — they open as tracked child windows; see Popups and OAuth. - Each identity occupies at least 3 CDP page targets (shell + top bar + content), plus one more per open popup child, so it scales at roughly 3× the identity count.
- The bundled agent-browser version is pinned at build time; upstream fixes are not picked up automatically.
- Runtime behaviour is only verified on macOS (arm64), including the packaged build. Windows packaging succeeds in CI (macOS + Windows are both built and produce installers), but the app has never actually been run on Windows, so runtime behaviour there is unverified. Linux is not a build target.
bundle:abbundles for the current platform only. - Adhoc-signed builds are not distributable: they run only on the build machine and are blocked by Gatekeeper elsewhere. Real distribution needs your own certificate and notarization.
- The Codex side has not been verified with a real client: the MCP flow was tested with a script acting as the client (including the packaged build in a Node-free environment), but never against an actual codex run.
- Proxy support only changes the network exit, not the fingerprint. See Proxy per identity — it is not an anti-detect browser.
Project layout
resources/bin/ and resources/skills/ are generated by pnpm bundle:ab and not committed.
Icon
The source is design/logo/icon.svg — three non-overlapping coloured cards for three isolated identities, with a pointer for agent control. After editing the SVG, regenerate every platform format:
This produces build/icon.png (1024), build/icon.icns, build/icon.ico and resources/icon.png (512, the runtime window icon).
Rendering goes through Electron's bundled Chromium, so rsvg-convert / Inkscape / ImageMagick aren't needed — those are usually absent from a clean environment, whereas Electron is a dependency this project has anyway. .icns is assembled by the system iconutil; .ico is written byte by byte.
Releasing
Both workflows are manual only (workflow_dispatch) and never run on push or tag.
Go to Actions on GitHub, pick a workflow, then Run workflow:
The Release is created as a draft; review the assets, then hit Publish yourself. If the tag already exists, assets are appended to it (--clobber overwrites same-named files), so re-runs don't just fail.
Publishing does not use electron-builder's own publish step (publish: null in electron-builder.yml); a single aggregation job uploads everything with gh release upload instead, because three platforms running in parallel would otherwise each try to create the same Release and clobber one another.
When the Build & Release workflow creates a release, it also publishes the
version-matched @leek-emperor/personae-mcp stdio launcher to npm and then
publishes server.json with the official mcp-publisher CLI. Set the
repository NPM_TOKEN secret (an npm automation token with permission to
publish that public package) before the first release; MCP Registry
authentication uses GitHub OIDC and needs no separate secret.
macOS artifacts from CI are adhoc-signed too. For real signing, add CSC_LINK / CSC_KEY_PASSWORD to the repository secrets and set notarize to true in electron-builder.yml.
License
MIT — see LICENSE.
The bundled agent-browser is Apache-2.0; copyright belongs to its authors.
来源:README.md,提交 286f36b
工具
0版本历史
1- v0.2.1最新Sep 16, 2026
评分
已满足适用检查项的 50%
该评分衡量此收录条目的完整程度,依据注册中心条目、端点与公开来源计算得出。它不是对服务器本身的评价,也不表示服务器安全或当前可用:可连通性是带日期的检查结果,评分也不包含安装量或用户评价。
- 近 30 天内可连通25 分
请保持端点能够响应 MCP 握手;SourceWeft 每 30 天探测一次。
- 已声明认证方式10 分
请在注册中心条目中声明凭据请求头,或保持端点能够响应探测,以便判定其认证方式。
- 已列出工具15 分
请让服务器在无凭据时响应 tools/list,或发布可在沙箱中无凭据启动的软件包。
- 描述必需15 分
- 图标10 分
- 文档15 分
- 许可证10 分