Merge pull request #1454 from feder-cr/libs-latest

browser: never pinned; the image takes the newest release and the engine reads the server it finds
This commit is contained in:
Federico Elia authored and GitHub committed 2026-10-14 18:38:56 -07:00
commit ef69f5e19c
51 files changed
+937 -1694

No files matched your search

+9 -3
View File
@@ -2,14 +2,20 @@ name: golden image
# Builds the golden image once, here, and publishes it as the release golden-<inputs digest>, which
# `invisible-dots image build` downloads instead of building (guest/image-builder/src/prebuilt.ts). The digest is the
# one every host computes from the pinned inputs, so a host only ever takes the image of its own inputs. A digest that
# already has its release is not built again.
# one every host computes from the pinned inputs and the newest release of the Dot's browser on the package index
# (browser-release.ts), so a host only ever takes the image of its own inputs. A digest that already has its release
# is not built again.
#
# The schedule is what makes a new release of the browser reach the hosts as a prebuilt image: no file of this
# repository changes when one is published, and the next run here finds a new digest and builds it.
on:
push:
branches: [main]
paths:
- guest/image-builder/**
- virtualization/images/**
schedule:
- cron: "17 */6 * * *"
workflow_dispatch:
permissions:
@@ -67,4 +73,4 @@ jobs:
fi
gh release create "$tag" "$RUNNER_TEMP"/release/* \
--title "Golden image $tag" \
--notes "The golden image of the inputs ${tag#golden-}, built by the golden image workflow from ${GITHUB_SHA}. invisible-dots image build downloads it when a checkout's inputs have this digest. It holds Ubuntu 24.04 and the software guest/image-builder/pins.json and the locks name, each under its own license (THIRD_PARTY_NOTICES.md)."
--notes "The golden image of the inputs ${tag#golden-}, built by the golden image workflow from ${GITHUB_SHA}. invisible-dots image build downloads it when a checkout's inputs have this digest. It holds Ubuntu 24.04, the software guest/image-builder/pins.json and the engine's lock name, and the release of invisible-playwright-mcp its manifest records, each under its own license (THIRD_PARTY_NOTICES.md)."
+2 -2
View File
@@ -237,8 +237,8 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
# The golden image's browser built as provision.sh builds it (apt packages of pins.json, the hashed
# mcp-requirements.lock, the engine of the browser), a desktop on :0, and the Dot
# The golden image's browser built as provision.sh builds it (apt packages of pins.json, the newest
# invisible-playwright-mcp, the engine of the browser), a desktop on :0, and the Dot
# (dot-agentd and the engine under their two users) browsing pages of the container. It needs the
# network for those downloads and for the egress address of a launch. Same exit rule and summary
# line as the smoke job.
+24 -18
View File
@@ -276,17 +276,19 @@ QEMU on the same disk would corrupt it.
code is ours to change often, so a change to it is a new ISO and not an hour
of golden build (the engine's source is not an input of the golden digest).
A runtime disk that needs another dependency needs a new golden image.
- Every input of the golden image is pinned by content: the cloud image, `uv`
and the tunnel by SHA-256 (`virtualization/images/base.json`,
`guest/image-builder/pins.json`), and the whole Python environment of
`invisible-playwright-mcp`, transitive packages included, by
`guest/image-builder/builder/mcp-requirements.lock`, every package at an
exact version with the SHA-256 of its files. The builder installs it with
`uv pip install --require-hashes --only-binary :all:`, so nothing is
resolved from the index at build time and no package's build script runs;
the lock is the one place the two top-level versions are
written, and it is part of the inputs digest, so a changed transitive
package is a new image. The apt packages come from the Ubuntu archive as it
- Every input of the golden image but the browser is pinned by content: the
cloud image, `uv` and the tunnel by SHA-256 (`virtualization/images/base.json`,
`guest/image-builder/pins.json`). The browser is never pinned: each build
takes the newest release of `invisible-playwright-mcp` on PyPI, with the
newest `invisible-playwright` it accepts and the `invisible-core` that one
names (`guest/image-builder/src/browser-release.ts`). The three versions are
part of the inputs digest, so a new release is a new image, and the image's
manifest records the ones installed; the scheduled run of
`.github/workflows/golden-image.yml` publishes that image without any change
to this repository. The builder installs them with
`uv pip install --only-binary :all:`, so no package's build script runs, and
refuses an environment that holds another browser (the names in
`guest/image-builder/builder/other-browsers.txt`). The apt packages come from the Ubuntu archive as it
was at the moment `apt_snapshot` of `pins.json` names (snapshot.ubuntu.com,
`apt-get --snapshot`), so they and their dependencies are the same on any day
of the build; apt checks their signatures.
@@ -1326,11 +1328,15 @@ does not know.
The page tools are `invisible-playwright-mcp`'s, the only browser of a Dot,
offered as an MCP host offers a server's tools: the server's name, description
and input schema, plus the `identity_id` of the open identity whose server the
call goes to. The engine reads them, with the server's instructions, from
`nanobot/dots/invisible_playwright_mcp.json`, which
`guest/image-builder/builder/capture-mcp-interface.py` writes from the real
server at the version the lock pins, started as the engine starts it; a test
holds its version to the lock's. Which of the server's tools the model is
call goes to. The engine reads them, with the server's instructions, from the
server its Dot has, when it starts: `nanobot/dots/server_capture.py`, run as dot
by the python of the installed `invisible-playwright-mcp`, starts the server as
the engine starts it and prints what it answers (`nanobot/dots/server_interface.py`).
A Dot keeps the golden image it was made from and takes every new runtime disk,
so the engine never assumes a version of the server: it offers the tools the
server it finds serves. A page tool that server does not serve is not offered
(`GET /tools` says so), and a Dot whose server cannot be read offers no page
tool, and a launch says why. Which of the server's tools the model is
offered is the permission table's: `browser_open`, `browser_close`,
`browser_list` and `browser_status` are the manager's (the identity tools), and
`browser_watch` is the UI's frame. The server's instructions go into the
@@ -1801,8 +1807,8 @@ Before it serves it checks that no other engine owns the state.
(the model cannot call `browser_open`; the launch is its way, and the one
decision of whether a browser starts). A frame that finds the browser gone
closes the identity the same way. "Gone" is the library's own sentence (its
`NOT_OPEN` and `GONE`, which `capture-mcp-interface.py` records from the pinned
package's constants in `nanobot/dots/invisible_playwright_mcp.json`) as the
`NOT_OPEN` and `GONE`, which `server_capture.py` reads from the installed
package's constants) as the
whole error of the call, not a phrase inside one: a failed click or select
echoes text the page controls, and a page that writes the sentence closes nothing. An open is known the same way:
`browser_open`'s answer starts with the library's `OPENED`, recorded beside the two. The manager hears of the end
+4 -4
View File
@@ -51,10 +51,10 @@ before the MCP client stops the process two seconds after closing its input.
tools, and `browser_watch` is the frame the UI shows.
- The server's instructions go into the system prompt whenever a page tool is
offered.
- The engine reads both from a capture of the pinned version
(`nanobot/dots/invisible_playwright_mcp.json`, written by
`guest/image-builder/builder/capture-mcp-interface.py`), held to the lock by a
test.
- The engine reads both from the server its Dot has, when it starts
(`nanobot/dots/server_interface.py`): a Dot keeps the golden image it was made
from, so the engine offers what that server serves, whatever its version. The
image takes the newest release at each build; nothing pins it.
- What the engine still adds is what only a Dot knows: a URL that is not
`http://` or `https://` is refused before it reaches the server, and the skill
is about identities.
+17 -19
View File
@@ -27,8 +27,8 @@ image's SHA-256; `invisible-dots doctor` checks an image against it with
each Dot's overlay is larger and cloud-init grows the filesystem).
3. Writes the builder seed with `@invisible-dots/iso`: one ISO labelled
`cidata` holding `user-data`, `meta-data`, `provision.sh`, `pins.env`,
`mcp-requirements.lock`, the pinned downloads, `engine-requirements.lock`,
`build-engine-env.sh` and `build-browser-env.sh`. The engine's own source is
the pinned downloads, `engine-requirements.lock`, `build-engine-env.sh`,
`build-browser-env.sh` and `other-browsers.txt`. The engine's own source is
not on it: it is ours, so it travels on the runtime ISO.
4. Boots it once with the QEMU the host runs Dots with, on the accelerator
the vm-manager chose (`-accel kvm` or `-accel whpx`, never emulation), the
@@ -40,16 +40,17 @@ image's SHA-256; `invisible-dots doctor` checks an image against it with
cloud image's services a Dot never uses (snapd, unattended-upgrades, apport, the LXD stubs, Ubuntu Pro,
the release upgrader, the SSH server, pollinate, which would tell Canonical of
every new Dot), then builds the Dot's browser with
`build-browser-env.sh <lock> ~dot/.local/share/invisible-dots/mcp`, as user
`build-browser-env.sh ~dot/.local/share/invisible-dots/mcp <the three releases>`, as user
`dot`: a virtual environment filled with
`uv pip install --require-hashes --only-binary :all: -r mcp-requirements.lock`, its
`uv pip install --only-binary :all:` of the versions `pins.env` names, its
`invisible-playwright-mcp` linked into `~/.local/bin`, and
`invisible-playwright fetch` run from that environment, so the cached browser
engine is the one the MCP server expects (the library keeps its own GeoIP
database, fetched from its release at a launch). The
browser smoke runs the same script. The only
browser a Dot has is that server: a test refuses any other browser or browser
library among the apt packages, the lock and the build scripts. `provision.sh`
browser a Dot has is that server: the build refuses an environment holding a
package `builder/other-browsers.txt` names, and a test refuses one among the apt
packages and the build scripts. `provision.sh`
then builds the engine's Python
environment with `build-engine-env.sh <lock> /opt/invisible-dots-engine
/opt/invisible-dots/engine`: a venv from `/usr/bin/python3` (CPython 3.12)
@@ -69,17 +70,14 @@ image's SHA-256; `invisible-dots doctor` checks an image against it with
6. Converts the disk into `golden-<version>.qcow2`, writes the manifest first
and then the image, read-only.
`builder/mcp-requirements.lock` is the MCP server's whole Python environment:
every package at an exact version with the SHA-256 of its files, so nothing is
resolved from the index at build time and a dependency missing from it fails
the build. It is the one place the versions of `invisible-playwright-mcp` and
`invisible-playwright` are written (`pins.env` and the manifest read them from
it), and its header has the command that regenerates it. The engine offers its
model what the pinned server serves (`invisible_engine_dots/nanobot/dots/invisible_playwright_mcp.json`:
its instructions and its `tools/list`, from the real server started as the engine
starts it), written by `builder/capture-mcp-interface.py`, and a test holds the
capture's version to the one pinned here: a new `invisible-playwright-mcp` version
needs a new capture in the same change.
The browser is never pinned. `src/browser-release.ts` asks PyPI for the newest
`invisible-playwright-mcp`, the newest `invisible-playwright` it accepts and the
`invisible-core` that one names; the three go into the inputs digest, `pins.env` and the
manifest, so a new release is a new image, and the scheduled run of
`.github/workflows/golden-image.yml` publishes it. The engine does not assume a
version: at start it reads the tools and instructions of the server its Dot has
(`invisible_engine_dots/nanobot/dots/server_interface.py`), so a Dot on an older
golden image keeps working with every new runtime disk.
`builder/engine-requirements.lock` is the same for the engine
(`invisible_engine_dots/pyproject.toml`): the one place its dependency
@@ -162,8 +160,8 @@ from Linux or WSL.
## The browser smoke
`test/smoke/run.sh --suite browser` runs the same two daemons with the Dot's real browser:
the apt packages of `pins.json`, the browser built by `builder/build-browser-env.sh` on the
hashed `mcp-requirements.lock`, the runtime disk's `dot-desktop` script (Xvfb and XFCE on
the apt packages of `pins.json`, the newest browser built by `builder/build-browser-env.sh`,
the runtime disk's `dot-desktop` script (Xvfb and XFCE on
`:0`), and the stand-in model navigating, reading and taking screenshots of pages served
from the container. The `browser-smoke` job of `.github/workflows/tests.yml` runs it, and
the `gate` job needs it. It needs the network for the downloads of the build and for the
@@ -1,41 +1,46 @@
#!/usr/bin/env bash
# Builds the Dot's browser (architecture sections 3.3 and 6): the Python environment of
# invisible-playwright-mcp from the hashed lock (as user dot), the command on dot's PATH, the browser
# engine the wrapper's seal expects. provision.sh runs it as root inside the golden image's builder VM, and the browser
# smoke runs it on a plain ubuntu:24.04 container, so both build the same thing. The Dot's browser is
# this server and nothing else (owner's rule).
# Builds the Dot's browser (architecture sections 3.3 and 6): the Python environment of invisible-playwright-mcp
# (as user dot), the command on dot's PATH, the browser engine the wrapper's seal expects. provision.sh runs it as
# root inside the golden image's builder VM, and the browser smoke runs it on a plain ubuntu:24.04 container, so both
# build the same thing. The Dot's browser is this server and nothing else (owner's rule).
#
# build-browser-env.sh <lock> <environment directory>
# build-browser-env.sh <environment directory> [<requirement>...]
#
# The command is linked into ~dot/.local/bin, where the model's commands find it; the engine's unit names
# it by that path (INVISIBLE_DOTS_MCP_COMMAND) and starts it as dot.
# The requirements are what to install; with none, the newest invisible-playwright-mcp and what it needs. The golden
# image passes the release it resolved from the index (browser-release.ts), so the image is what its digest names;
# the browser smoke installs the newest. No version of the browser is written in the repository (owner's rule).
#
# The lock is a hashed list of every package at an exact version (uv pip install
# --require-hashes), so a file that changed on the index, or a dependency missing from the
# lock, fails the build instead of entering the image. The engine is fetched with the
# invisible-playwright of that environment, so the cached engine is the one the server's seal
# The command is linked into ~dot/.local/bin, where the model's commands find it; the engine's unit names it by that
# path (INVISIBLE_DOTS_MCP_COMMAND), starts it as dot, and reads what it serves from it when it starts. The engine is
# fetched with the invisible-playwright of that environment, so the cached engine is the one the server's seal
# expects, in ~dot/.cache/invisible-playwright.
set -Eeuo pipefail
usage="usage: build-browser-env.sh <lock> <environment directory>"
lock="${1:?$usage}"
env_dir="${2:?$usage}"
usage="usage: build-browser-env.sh <environment directory> [<requirement>...]"
env_dir="${1:?$usage}"
shift
requirements=("$@")
[ "${#requirements[@]}" -gt 0 ] || requirements=(invisible-playwright-mcp)
bin_dir=/home/dot/.local/bin
[ -f "$lock" ] || { echo "build-browser-env: no lock at $lock" >&2; exit 1; }
other_browsers="$(dirname "${BASH_SOURCE[0]}")/other-browsers.txt"
[ -f "$other_browsers" ] || { echo "build-browser-env: no $other_browsers" >&2; exit 1; }
id dot >/dev/null 2>&1 || { echo "build-browser-env: user dot does not exist" >&2; exit 1; }
as_dot() { sudo -u dot -H env PATH="/home/dot/.local/bin:/usr/local/bin:/usr/bin:/bin" "$@"; }
# dot reads the lock from a file of its own reach.
dot_lock="$(mktemp)"
trap 'rm -f "$dot_lock"' EXIT
install -m 0644 "$lock" "$dot_lock"
as_dot mkdir -p "$bin_dir" "$(dirname "$env_dir")"
as_dot uv venv --quiet --python /usr/bin/python3 "$env_dir"
# Wheels only, as for the engine: no build script of any package runs. --no-cache: the image does not need
# uv's download cache in dot's home.
as_dot uv pip install --no-cache --python "$env_dir/bin/python" --require-hashes --only-binary :all: -r "$dot_lock"
# Wheels only, as for the engine: no build script of any package runs. --no-cache: the image does not need uv's
# download cache in dot's home.
as_dot uv pip install --no-cache --python "$env_dir/bin/python" --only-binary :all: "${requirements[@]}"
# What was installed, not what was asked for: no package of it may be another browser.
installed="$(as_dot uv pip freeze --python "$env_dir/bin/python" | sed -n 's/^\([A-Za-z0-9._-]*\)==.*/\1/p' | tr 'A-Z_.' 'a-z--')"
refused=""
while read -r name; do
case "$name" in ''|'#'*) continue ;; esac
refused="$refused$(printf '%s\n' "$installed" | grep -E "^${name}(-.*)?$" || true)"
done < "$other_browsers"
[ -z "$refused" ] || { echo "build-browser-env: another browser would enter the image (owner's rule): $refused" >&2; exit 1; }
[ -x "$env_dir/bin/invisible-playwright" ] || { echo "build-browser-env: no invisible-playwright in $env_dir/bin" >&2; exit 1; }
[ -x "$env_dir/bin/invisible-playwright-mcp" ] || { echo "build-browser-env: no invisible-playwright-mcp in $env_dir/bin" >&2; exit 1; }
as_dot ln -sfn "$env_dir/bin/invisible-playwright-mcp" "$bin_dir/invisible-playwright-mcp"
@@ -1,75 +0,0 @@
"""Capture what invisible-playwright-mcp tells a model, as the engine serves it to a Dot's model.
The engine offers the model the page tools of the server with the server's own names, descriptions and input
schemas, and puts the server's instructions in its prompt, as an MCP host does (architecture section 8.3). It
reads them from `invisible_engine_dots/nanobot/dots/invisible_playwright_mcp.json`, which this writes from the
real server at the version `mcp-requirements.lock` pins, started the way the engine starts it
(`INVISIBLE_MCP_HOST_MANAGED=1`): `initialize` gives the version and the instructions, `tools/list` the tools.
Nothing is opened: listing starts no browser. It also records the sentences the engine reads in the server's
answers (`browser_open`'s answer starts with "the browser is open", and any tool can answer that it is not open or
gone), taken from the installed package's own constants, so the engine recognizes them by the library's words at the
pinned version and a version that changes them shows.
Run it with a Python that has the pinned server installed (and so the `mcp` package it depends on), whenever
the lock moves to another version:
python guest/image-builder/builder/capture-mcp-interface.py \
invisible_engine_dots/nanobot/dots/invisible_playwright_mcp.json
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from invisible_playwright_mcp.mcp import DEFAULT_BROWSER_ID, GONE, NOT_OPEN, OPENED
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
PACKAGE = "invisible-playwright-mcp"
NOTE = (
"What invisible-playwright-mcp tells a model, at the version builder/mcp-requirements.lock pins, started as the "
"engine starts it (INVISIBLE_MCP_HOST_MANAGED=1): its instructions and its tools/list as served, and the "
"sentences the engine reads in its answers (the package's OPENED, NOT_OPEN and GONE, %s being the browser). Written by "
"guest/image-builder/builder/capture-mcp-interface.py; capture it again when the lock changes that version."
)
async def capture() -> dict:
environment = {**os.environ, "INVISIBLE_MCP_HOST_MANAGED": "1"}
server = StdioServerParameters(command=sys.executable, args=["-m", "invisible_playwright_mcp"], env=environment)
async with stdio_client(server) as (read, write), ClientSession(read, write) as session:
started = await session.initialize()
listed = await session.list_tools()
return {
"//": NOTE,
"package": PACKAGE,
"version": started.serverInfo.version,
"instructions": started.instructions,
"browser": DEFAULT_BROWSER_ID,
"answers": {"opened": OPENED, "not_open": NOT_OPEN, "gone": GONE},
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
"annotations": tool.annotations.model_dump(exclude_none=True) if tool.annotations else None,
}
for tool in listed.tools
],
}
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"usage: {Path(sys.argv[0]).name} <output.json>")
interface = asyncio.run(capture())
Path(sys.argv[1]).write_bytes((json.dumps(interface, indent=1, ensure_ascii=False) + "\n").encode("utf-8"))
print(f"{interface['package']} {interface['version']}: {len(interface['tools'])} tools")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
# Owner's rule: the only browser of a Dot is invisible-playwright-mcp. No other browser, headless browser or browser
# library enters the guest image. One name per line; a package or apt package is refused when its name is one of
# these or starts with one of them followed by "-". Read by build-browser-env.sh, on what it actually installed, and
# by the tests, on the apt packages of pins.json.
chromium
chrome
google-chrome
firefox
epiphany
midori
falkon
konqueror
qutebrowser
lynx
links
links2
w3m
elinks
selenium
seleniumbase
puppeteer
pyppeteer
playwright
patchright
undetected-chromedriver
chromedriver
geckodriver
webkit
webkit2gtk
+13 -9
View File
@@ -76,15 +76,18 @@ id dot >/dev/null 2>&1 || useradd --create-home --shell /bin/bash dot
# image without it would fail at every boot, so it fails the build.
id dotagentd >/dev/null 2>&1 || { console "the builder seed made no user dotagentd"; false; }
step "installing invisible-playwright-mcp $MCP_VERSION, fetching the browser engine"
# The whole environment comes from the hashed lock on the seed and is built as dot by the script the
# seed carries, the one the browser smoke runs too (section 3.3): every package at the version the
# lock names and with a file whose SHA-256 it lists, nothing resolved from the index. The engine is
# fetched with the invisible-playwright of that environment, so the cached engine is the one the
# server's seal expects, in ~dot/.cache/invisible-playwright.
step "installing invisible-playwright-mcp $MCP_VERSION (invisible-playwright $PLAYWRIGHT_VERSION, invisible-core $CORE_VERSION), fetching the browser engine"
# The release is the newest on the index when the host started the build (browser-release.ts): the three versions
# that make it up are installed exactly, and uv resolves what they need. The environment is built as dot by the
# script the seed carries, the one the browser smoke runs too (section 3.3), which refuses any other browser among
# what it installed. The engine is fetched with the invisible-playwright of that environment, so the cached engine is
# the one the server's seal expects, in ~dot/.cache/invisible-playwright.
as_dot() { sudo -u dot -H env PATH="/home/dot/.local/bin:/usr/local/bin:/usr/bin:/bin" "$@"; }
mcp_env=/home/dot/.local/share/invisible-dots/mcp
bash "$payload/$BROWSER_BUILD" "$payload/$PYTHON_LOCK" "$mcp_env"
bash "$payload/$BROWSER_BUILD" "$mcp_env" \
"invisible-playwright-mcp==$MCP_VERSION" "invisible-playwright==$PLAYWRIGHT_VERSION" "invisible-core==$CORE_VERSION"
# What the environment holds, read from it: the manifest records what was installed, not what was asked for.
installed_version() { as_dot "$mcp_env/bin/python" -c 'import sys, importlib.metadata as m; print(m.version(sys.argv[1]))' "$1"; }
# The engine the image carries is read from the library that decides it (the seal of invisible_core: tag,
# Firefox version, BuildID), the same facts `invisible-playwright version` prints in its engine line.
engine_version="$(as_dot "$mcp_env/bin/python" -c 'from invisible_core import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION; from invisible_core.constants import BUILD_ID; print(f"{BINARY_VERSION} Firefox {FIREFOX_UPSTREAM_VERSION} build {BUILD_ID}")')"
@@ -118,8 +121,9 @@ component ubuntu "$VERSION_ID"
component kernel "$(uname -r)"
component uv "$(/usr/local/bin/uv --version)"
component hev-socks5-tunnel "$TUNNEL_VERSION"
component invisible-playwright-mcp "$MCP_VERSION"
component invisible-playwright "$PLAYWRIGHT_VERSION"
component invisible-playwright-mcp "$(installed_version invisible-playwright-mcp)"
component invisible-playwright "$(installed_version invisible-playwright)"
component invisible-core "$(installed_version invisible-core)"
component browser-engine "$engine_version"
component engine-python "$engine_python"
+3 -3
View File
@@ -19,14 +19,14 @@ export function defaultAssetRoot(): string {
export const BUILDER_USER_DATA = "builder/user-data.yaml";
export const BUILDER_PROVISION = "builder/provision.sh";
/** The hashed lock of the MCP server's Python environment (python-lock.ts). */
export const BUILDER_PYTHON_LOCK = "builder/mcp-requirements.lock";
/** The hashed lock of the engine's Python environment (python-lock.ts); the runtime disk carries a copy. */
export const BUILDER_ENGINE_LOCK = "builder/engine-requirements.lock";
/** Builds the engine's Python environment inside the builder VM; the same script builds it in a Linux container. */
export const BUILDER_ENGINE_BUILD = "builder/build-engine-env.sh";
/** Builds the Dot's browser (the MCP server's environment and the engine) inside the builder VM; the browser smoke runs the same script. */
export const BUILDER_BROWSER_BUILD = "builder/build-browser-env.sh";
/** The browsers that never enter the image (owner's rule): build-browser-env.sh refuses them among what it installs. */
export const BUILDER_OTHER_BROWSERS = "builder/other-browsers.txt";
export const RUNTIME_INSTALL = "runtime/install.sh";
export const RUNTIME_DESKTOP = "runtime/dot-desktop.sh";
export const RUNTIME_DOT_INSTALL = "runtime/dot-install.sh";
@@ -40,7 +40,7 @@ export function unitAsset(name: (typeof GUEST_UNITS)[number]): string {
}
/** Every guest file, for checks that apply to all of them. */
export const GUEST_ASSETS: readonly string[] = [BUILDER_USER_DATA, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_ENGINE_LOCK, BUILDER_ENGINE_BUILD, BUILDER_BROWSER_BUILD, RUNTIME_INSTALL, RUNTIME_DESKTOP, RUNTIME_DOT_INSTALL, RUNTIME_VMPROXY, ...GUEST_UNITS.map(unitAsset)];
export const GUEST_ASSETS: readonly string[] = [BUILDER_USER_DATA, BUILDER_PROVISION, BUILDER_ENGINE_LOCK, BUILDER_ENGINE_BUILD, BUILDER_BROWSER_BUILD, BUILDER_OTHER_BROWSERS, RUNTIME_INSTALL, RUNTIME_DESKTOP, RUNTIME_DOT_INSTALL, RUNTIME_VMPROXY, ...GUEST_UNITS.map(unitAsset)];
/**
* Reads a guest file and refuses one a Windows checkout turned into CRLF:
@@ -0,0 +1,85 @@
/**
* The Dot's browser is the newest published invisible-playwright-mcp (owner's rule): no version of it is written in
* this repository. When a golden image is built, the newest release is asked of the package index, and the three
* versions that make it up are what the image installs, what its inputs digest covers (a new release is a new image,
* never an old image reused or downloaded under the same name) and what its manifest records.
*
* The three: invisible-playwright-mcp, the newest; invisible-playwright, the newest, which has to satisfy the
* server's floor; invisible-core, the one that invisible-playwright pins exactly, with the browser engine it seals.
* The other packages of the server's environment are what these three require, resolved by uv inside the builder.
*/
import { fetchTextWithRetries, type FetchVerifiedOptions } from "./download.js";
export const MCP_PACKAGE = "invisible-playwright-mcp";
export const PLAYWRIGHT_PACKAGE = "invisible-playwright";
export const CORE_PACKAGE = "invisible-core";
/** Where the versions are asked: PyPI's JSON API, one document per project. */
export const PACKAGE_INDEX = "https://pypi.org/pypi";
export interface BrowserRelease {
/** invisible-playwright-mcp, the server the engine runs. */
mcp: string;
/** invisible-playwright, the wrapper the server drives the browser with. */
playwright: string;
/** invisible-core, which the wrapper pins and which seals the browser engine. */
core: string;
}
/** What pins.env carries without quoting, inside the builder VM's shell. */
const VERSION = /^[0-9][0-9A-Za-z.+_-]*$/;
interface Project {
version: string;
requires: readonly string[];
}
async function project(index: string, name: string, download: FetchVerifiedOptions): Promise<Project> {
const url = `${index}/${name}/json`;
let info: { version?: unknown; requires_dist?: unknown };
try {
info = (JSON.parse(await fetchTextWithRetries(url, download)) as { info?: typeof info }).info ?? {};
} catch (error) {
throw new Error(`cannot ask the package index for the newest ${name} (${url}): ${(error as Error).message}`, { cause: error });
}
const version = info.version;
if (typeof version !== "string" || !VERSION.test(version)) throw new Error(`${url}: the newest ${name} has no usable version (${String(version)})`);
const requires = Array.isArray(info.requires_dist) ? info.requires_dist.filter((r): r is string => typeof r === "string") : [];
return { version, requires };
}
/** The requirement of `project` on `dependency` that is always installed (no `extra ==` marker), as written. */
function requirementOn(project: Project, dependency: string, of: string): string {
const name = new RegExp(`^${dependency.replace(/[-_.]/g, "[-_.]")}(?![-_.A-Za-z0-9])`, "i");
const found = project.requires.find((r) => name.test(r) && !/extra\s*==/.test(r));
if (!found) throw new Error(`${of} ${project.version} does not require ${dependency}`);
return found;
}
/** Version order as PEP 440 orders plain releases: part by part, numerically. */
export function compareReleases(a: string, b: string): number {
return a.localeCompare(b, "en", { numeric: true, sensitivity: "base" });
}
/**
* The newest release of the Dot's browser, read from the index. Refused, with the reason: a newest wrapper below the
* newest server's floor (a release published halfway), a wrapper that does not pin its core exactly.
*/
export async function latestBrowserRelease(options: { index?: string; download?: FetchVerifiedOptions } = {}): Promise<BrowserRelease> {
const index = options.index ?? PACKAGE_INDEX;
const download = options.download ?? {};
const [mcp, playwright] = await Promise.all([project(index, MCP_PACKAGE, download), project(index, PLAYWRIGHT_PACKAGE, download)]);
const floor = /\s*>=\s*([0-9][0-9A-Za-z.+_-]*)/.exec(requirementOn(mcp, PLAYWRIGHT_PACKAGE, MCP_PACKAGE))?.[1];
if (floor && compareReleases(playwright.version, floor) < 0) {
throw new Error(
`the newest ${MCP_PACKAGE} ${mcp.version} needs ${PLAYWRIGHT_PACKAGE}>=${floor}, and the newest published is ${playwright.version}: ` +
"the release is not complete on the index yet; build again once it is",
);
}
const core = /\s*==\s*([0-9][0-9A-Za-z.+_-]*)\s*(?:;|$)/.exec(requirementOn(playwright, CORE_PACKAGE, PLAYWRIGHT_PACKAGE))?.[1];
if (!core || !VERSION.test(core)) {
throw new Error(`${PLAYWRIGHT_PACKAGE} ${playwright.version} does not pin ${CORE_PACKAGE} to one version, so the image cannot name its browser engine`);
}
return { mcp: mcp.version, playwright: playwright.version, core };
}
+20 -14
View File
@@ -9,14 +9,15 @@ import { chmod, copyFile, mkdir, readFile, rm, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { writeIso } from "@invisible-dots/iso";
import { hostPaths, type HostPaths } from "@invisible-dots/shared";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_USER_DATA, defaultAssetRoot, readGuestAsset } from "./assets.js";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_OTHER_BROWSERS, BUILDER_PROVISION, BUILDER_USER_DATA, defaultAssetRoot, readGuestAsset } from "./assets.js";
import { latestBrowserRelease, type BrowserRelease } from "./browser-release.js";
import { fetchVerified, sha256File, type Fetch, type FetchVerifiedOptions } from "./download.js";
import { acquireLock, type Lock } from "./lock.js";
import { manifestPathFor, type GoldenManifest } from "./manifest.js";
import { BASE_IMAGE, downloadFileName, GUEST_PINS, type BaseImagePin, type GuestPins, type PinnedDownload } from "./pins.js";
import { waitForExit, type ProcessRunner } from "./process.js";
import { PREBUILT_RELEASES, pullPrebuiltGolden } from "./prebuilt.js";
import { parseHashedLock, parsePythonLock, type PythonLock } from "./python-lock.js";
import { parseHashedLock } from "./python-lock.js";
import { builderQemuArgs, type Accelerator, type QemuPrograms } from "./qemu.js";
import { SEED_VOLUME_ID } from "@invisible-dots/vm-manager";
import { builderSeedEntries } from "./seed.js";
@@ -69,6 +70,8 @@ export interface GoldenBuildOptions {
prebuilt?: string | false;
/** Write the image as a compressed qcow2 (smaller to publish; QEMU reads it as it is). */
compress?: boolean;
/** The release of the Dot's browser the image installs; the newest on the package index by default (browser-release.ts). */
browser?: BrowserRelease;
}
export interface GoldenBuildResult {
@@ -101,17 +104,20 @@ export async function buildGoldenImage(options: GoldenBuildOptions): Promise<Gol
const userData = await readGuestAsset(assetRoot, BUILDER_USER_DATA);
const provision = await readGuestAsset(assetRoot, BUILDER_PROVISION);
const pythonLock = await readGuestAsset(assetRoot, BUILDER_PYTHON_LOCK);
const python = parsePythonLock(pythonLock.toString("utf8"));
const engineBuild = await readGuestAsset(assetRoot, BUILDER_ENGINE_BUILD);
const browserBuild = await readGuestAsset(assetRoot, BUILDER_BROWSER_BUILD);
const otherBrowsers = await readGuestAsset(assetRoot, BUILDER_OTHER_BROWSERS);
const engineLock = await readGuestAsset(assetRoot, BUILDER_ENGINE_LOCK);
parseHashedLock(engineLock.toString("utf8"), BUILDER_ENGINE_LOCK);
// The browser is the newest release of invisible-playwright-mcp, asked of the index now: it is an input, so a new
// release is a new image, not the image of the last one reused or downloaded under the same name.
const browser = options.browser ?? (await latestBrowserRelease({ download: { ...options.download, log, ...(options.fetch ? { fetch: options.fetch } : {}) } }));
log(`the browser: invisible-playwright-mcp ${browser.mcp} (invisible-playwright ${browser.playwright}, invisible-core ${browser.core})`);
// The disk size is an input: the same pins at another size are another image.
// The locks are too, so a changed transitive dependency is another image, and
// The engine's lock is too, so a changed transitive dependency is another image, and
// so are the scripts that build the engine's environment and the browser. The engine's own
// source is not: it travels on the runtime disk (section 3.3).
const digest = inputsDigest([JSON.stringify(base), JSON.stringify(pins), userData, provision, pythonLock, diskSize, engineLock, engineBuild, browserBuild]);
const digest = inputsDigest([JSON.stringify(base), JSON.stringify(pins), userData, provision, JSON.stringify(browser), diskSize, engineLock, engineBuild, browserBuild, otherBrowsers]);
const { version, image, manifest, existing } = await resolveImageVersion({
kind: "golden",
@@ -142,7 +148,7 @@ export async function buildGoldenImage(options: GoldenBuildOptions): Promise<Gol
}
return await buildLocked(
{ ...options, log, paths, base, pins, diskSize, now, lock },
{ version, image, manifest, digest, userData, provision, pythonLock, python, engineLock, engineBuild, browserBuild },
{ version, image, manifest, digest, userData, provision, browser, engineLock, engineBuild, browserBuild, otherBrowsers },
);
} finally {
await lock.release();
@@ -167,11 +173,11 @@ interface Target {
digest: string;
userData: Buffer;
provision: Buffer;
pythonLock: Buffer;
python: PythonLock;
browser: BrowserRelease;
engineLock: Buffer;
engineBuild: Buffer;
browserBuild: Buffer;
otherBrowsers: Buffer;
}
async function buildLocked(options: GoldenBuildOptions & Resolved, target: Target): Promise<GoldenBuildResult> {
@@ -214,13 +220,13 @@ async function buildLocked(options: GoldenBuildOptions & Resolved, target: Targe
pins,
userData: target.userData,
provision: target.provision,
pythonLock: target.pythonLock,
python: target.python,
browser: target.browser,
uvTarball,
tunnelBinary,
engineLock: target.engineLock,
engineBuild: target.engineBuild,
browserBuild: target.browserBuild,
otherBrowsers: target.otherBrowsers,
}),
{ volumeId: SEED_VOLUME_ID, timestamp: options.now() },
);
@@ -250,9 +256,9 @@ async function buildLocked(options: GoldenBuildOptions & Resolved, target: Targe
pinned: {
uv: { version: pins.uv.version, sha256: pins.uv.sha256, url: pins.uv.url },
tunnel: { version: pins.tunnel.version, sha256: pins.tunnel.sha256, url: pins.tunnel.url },
"invisible-playwright-mcp": target.python.mcpVersion,
"invisible-playwright": target.python.playwrightVersion,
"mcp-requirements.lock": createHash("sha256").update(target.pythonLock).digest("hex"),
"invisible-playwright-mcp": target.browser.mcp,
"invisible-playwright": target.browser.playwright,
"invisible-core": target.browser.core,
apt_snapshot: pins.apt_snapshot,
apt_packages: [...pins.apt_packages],
},
+2 -1
View File
@@ -22,7 +22,8 @@ export {
type RuntimeManifest,
} from "./manifest.js";
export { BASE_IMAGE, GUEST_PINS, type BaseImagePin, type GuestPins, type PinnedDownload } from "./pins.js";
export { normalizePackageName, parseHashedLock, parsePythonLock, type PythonLock } from "./python-lock.js";
export { normalizePackageName, parseHashedLock } from "./python-lock.js";
export { latestBrowserRelease, type BrowserRelease } from "./browser-release.js";
export type { ProcessRunner, RunningProcess } from "./process.js";
export { builderQemuArgs, type Accelerator, type QemuPrograms } from "./qemu.js";
export {
+2 -2
View File
@@ -34,10 +34,10 @@ export interface GoldenManifest {
pinned: {
uv: PinnedComponent;
tunnel: PinnedComponent;
/** The release of the Dot's browser the image installed: the newest on the index when it was built. */
"invisible-playwright-mcp": string;
"invisible-playwright": string;
/** SHA-256 of builder/mcp-requirements.lock: the whole Python environment, transitive packages included. */
"mcp-requirements.lock": string;
"invisible-core": string;
/** The moment of the Ubuntu archive apt_packages came from (pins.json). */
apt_snapshot: string;
apt_packages: string[];
+4 -3
View File
@@ -31,8 +31,9 @@ export interface BaseImagePin {
}
/**
* The Python packages are not here: builder/mcp-requirements.lock pins them
* with their hashes, and is the one place their versions live (python-lock.ts).
* The Dot's browser is not here: the image installs the newest release of
* invisible-playwright-mcp, resolved from the index at each build
* (browser-release.ts), and no version of it is written in the repository.
*/
export interface GuestPins {
uv: PinnedDownload;
@@ -123,7 +124,7 @@ export function parseBaseImagePin(value: unknown): BaseImagePin {
export function parseGuestPins(value: unknown): GuestPins {
const where = "guest/image-builder/pins.json";
const o = asObject(value, where);
if ("python_packages" in o) throw new Error(`${where}: python_packages moved to builder/mcp-requirements.lock, which pins them with their hashes`);
if ("python_packages" in o) throw new Error(`${where}: python_packages has no place here: the image installs the newest release of the Dot's browser (browser-release.ts)`);
const apt = o.apt_packages;
if (!Array.isArray(apt) || apt.length === 0) throw new Error(`${where}: apt_packages must be a non-empty array`);
const snapshot = field(o, "apt_snapshot", where);
+6 -41
View File
@@ -1,37 +1,13 @@
/**
* The hashed locks of the golden image's Python environments: every package
* at an exact version with the SHA-256 of its files, installed with
* `uv pip install --require-hashes`, so nothing is resolved from the index at
* build time (architecture section 3.3). One parser owns the rules for both.
* The hashed lock of the engine's Python environment, builder/engine-requirements.lock: every package at an exact
* version with the SHA-256 of its files, installed with `uv pip install --require-hashes`, so nothing is resolved
* from the index at build time (architecture section 3.3). It is the dependencies of
* invisible_engine_dots/pyproject.toml and what they need.
*
* builder/mcp-requirements.lock is the whole environment of
* invisible-playwright-mcp. It is the one place that server's two top-level
* versions live: they are read from it here for pins.env and the manifest,
* never written down a second time, and the file itself is an input of the
* golden image's digest.
*
* builder/engine-requirements.lock is the engine's environment: the
* dependencies of invisible_engine_dots/pyproject.toml and what they need.
* The Dot's browser is not locked: the image installs the newest release (browser-release.ts).
*/
/** The two packages the golden image installs on purpose; everything else in the lock is what they need. */
export const MCP_PACKAGE = "invisible-playwright-mcp";
export const PLAYWRIGHT_PACKAGE = "invisible-playwright";
export interface PythonLock {
/** Every package of the environment and its exact version, by normalized name. */
packages: ReadonlyMap<string, string>;
/** The version of invisible-playwright-mcp the lock pins. */
mcpVersion: string;
/** The version of invisible-playwright the lock pins. */
playwrightVersion: string;
}
/**
* Package names as PEP 503 compares them, versions restricted to what
* pins.env carries without quoting: the two top-level versions end up in a
* shell file inside the builder VM.
*/
/** Package names as PEP 503 compares them, versions as the lock writes them. */
const REQUIREMENT = /^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9][A-Za-z0-9.+_-]*) \\$/;
const HASH = /^ {4}--hash=sha256:[0-9a-f]{64}( \\)?$/;
@@ -80,14 +56,3 @@ export function parseHashedLock(text: string, where: string): ReadonlyMap<string
if (current !== undefined) throw new Error(`${where}: ${current} ends without its hashes`);
return packages;
}
/** Parse and check the MCP server's lock: the rules above, and both top-level packages pinned. */
export function parsePythonLock(text: string, where = "builder/mcp-requirements.lock"): PythonLock {
const packages = parseHashedLock(text, where);
const mcpVersion = packages.get(MCP_PACKAGE);
const playwrightVersion = packages.get(PLAYWRIGHT_PACKAGE);
if (!mcpVersion || !playwrightVersion) {
throw new Error(`${where}: it must pin both ${MCP_PACKAGE} and ${PLAYWRIGHT_PACKAGE}; regenerate it as its header says`);
}
return { packages, mcpVersion, playwrightVersion };
}
+3 -4
View File
@@ -9,7 +9,7 @@
* /bin/dot-agentd the computer daemon (linux/amd64)
* /bin/dot-desktop ExecStart of dot-desktop.service
* /bin/dot-install the package installer dot may run with sudo
* /engine/nanobot/... the engine's source: its .py files, templates and data (.json)
* /engine/nanobot/... the engine's source: its .py files and templates
* /engine/skills/<name>/SKILL.md the built-in skills the prompt names and the Dot reads
* /engine/LICENSE, /engine/UPSTREAM.md the engine's license and where it was forked from
* /units/*.service the guest systemd units
@@ -152,8 +152,7 @@ async function stageFile(path: string, hostFile: string): Promise<StagedFile> {
/**
* What the engine's package ships (the wheel's include list in its
* pyproject.toml): every .py file, the .md templates, and the .json data (the browser server's capture,
* nanobot/dots/invisible_playwright_mcp.json, which the engine reads at start). The tests and
* pyproject.toml): every .py file and the .md templates. The tests and
* __pycache__ are not part of it.
*/
async function engineSourcePaths(root: string, relative: string, out: string[]): Promise<void> {
@@ -163,7 +162,7 @@ async function engineSourcePaths(root: string, relative: string, out: string[]):
if (entry.isDirectory()) {
if (entry.name !== "__pycache__") await engineSourcePaths(root, path, out);
} else if (entry.isFile()) {
if (entry.name.endsWith(".py") || entry.name.endsWith(".json") || (entry.name.endsWith(".md") && path.startsWith("nanobot/templates/"))) out.push(path);
if (entry.name.endsWith(".py") || (entry.name.endsWith(".md") && path.startsWith("nanobot/templates/"))) out.push(path);
} else {
throw new Error(`${join(root, path)} is neither a file nor a directory; the engine's source must hold only plain files`);
}
+14 -14
View File
@@ -7,24 +7,23 @@
*/
import type { IsoEntry } from "@invisible-dots/iso";
import { downloadFileName, type GuestPins } from "./pins.js";
import type { PythonLock } from "./python-lock.js";
import type { BrowserRelease } from "./browser-release.js";
/** The lock's name on the builder seed, where provision.sh reads it. */
export const SEED_PYTHON_LOCK = "mcp-requirements.lock";
/** The engine's lock and the script that builds its Python environment from it, on the builder seed. */
export const SEED_ENGINE_LOCK = "engine-requirements.lock";
export const SEED_ENGINE_BUILD = "build-engine-env.sh";
/** The script that builds the Dot's browser from the MCP lock, on the builder seed. */
/** The script that builds the Dot's browser, on the builder seed. */
export const SEED_BROWSER_BUILD = "build-browser-env.sh";
/** The browsers build-browser-env.sh refuses, beside it on the seed, where it reads them. */
export const SEED_OTHER_BROWSERS = "other-browsers.txt";
export interface BuilderSeedInput {
version: string;
pins: GuestPins;
userData: Uint8Array;
provision: Uint8Array;
/** builder/mcp-requirements.lock, as read, and what it pins. */
pythonLock: Uint8Array;
python: PythonLock;
/** The release of the Dot's browser to install (browser-release.ts). */
browser: BrowserRelease;
/** Host path of the verified uv tarball, streamed into the image. */
uvTarball: string;
/** The verified hev-socks5-tunnel binary (pins.tunnel). */
@@ -32,8 +31,9 @@ export interface BuilderSeedInput {
/** builder/engine-requirements.lock and builder/build-engine-env.sh, as read. */
engineLock: Uint8Array;
engineBuild: Uint8Array;
/** builder/build-browser-env.sh, as read. */
/** builder/build-browser-env.sh and builder/other-browsers.txt, as read. */
browserBuild: Uint8Array;
otherBrowsers: Uint8Array;
}
/**
@@ -42,15 +42,15 @@ export interface BuilderSeedInput {
* quotes are only a second guard; the check below keeps it that way if a
* caller builds GuestPins by hand.
*/
export function pinsEnv(pins: GuestPins, python: PythonLock): string {
export function pinsEnv(pins: GuestPins, browser: BrowserRelease): string {
const values: Array<[string, string]> = [
["UV_VERSION", pins.uv.version],
["UV_TARBALL", downloadFileName(pins.uv)],
["TUNNEL_VERSION", pins.tunnel.version],
["TUNNEL_BINARY", downloadFileName(pins.tunnel)],
["MCP_VERSION", python.mcpVersion],
["PLAYWRIGHT_VERSION", python.playwrightVersion],
["PYTHON_LOCK", SEED_PYTHON_LOCK],
["MCP_VERSION", browser.mcp],
["PLAYWRIGHT_VERSION", browser.playwright],
["CORE_VERSION", browser.core],
["ENGINE_LOCK", SEED_ENGINE_LOCK],
["ENGINE_BUILD", SEED_ENGINE_BUILD],
["BROWSER_BUILD", SEED_BROWSER_BUILD],
@@ -75,12 +75,12 @@ export function builderSeedEntries(input: BuilderSeedInput): IsoEntry[] {
{ path: "user-data", data: input.userData },
{ path: "meta-data", data: builderMetaData(input.version) },
{ path: "provision.sh", data: input.provision },
{ path: "pins.env", data: pinsEnv(input.pins, input.python) },
{ path: SEED_PYTHON_LOCK, data: input.pythonLock },
{ path: "pins.env", data: pinsEnv(input.pins, input.browser) },
{ path: downloadFileName(input.pins.uv), file: input.uvTarball },
{ path: downloadFileName(input.pins.tunnel), file: input.tunnelBinary },
{ path: SEED_ENGINE_LOCK, data: input.engineLock },
{ path: SEED_ENGINE_BUILD, data: input.engineBuild },
{ path: SEED_BROWSER_BUILD, data: input.browserBuild },
{ path: SEED_OTHER_BROWSERS, data: input.otherBrowsers },
];
}
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from "vitest";
import { compareReleases, latestBrowserRelease } from "../src/browser-release.js";
import { startFakeHttp, type FakeHttp } from "./http-fixture.js";
let http: FakeHttp | undefined;
afterEach(async () => {
await http?.close();
http = undefined;
});
/** PyPI's JSON API for one project: what browser-release.ts reads of it. */
const project = (version: string, requires: string[] = []) => ({ body: JSON.stringify({ info: { version, requires_dist: requires } }) });
async function index(mcp: ReturnType<typeof project>, playwright: ReturnType<typeof project>): Promise<string> {
http = await startFakeHttp({ "/pypi/invisible-playwright-mcp/json": mcp, "/pypi/invisible-playwright/json": playwright });
return http.url("/pypi");
}
const DOWNLOAD = { attempts: 1, retryDelayMs: 0 };
describe("latestBrowserRelease", () => {
it("is the newest server, the newest wrapper and the core that wrapper pins", async () => {
const at = await index(
project("0.72.2", ["invisible-playwright>=0.35.4", 'invisible-core>=34.31.0; extra == "test"']),
project("0.35.8", ["invisible-core==47.39.0", "platformdirs>=4"]),
);
expect(await latestBrowserRelease({ index: at, download: DOWNLOAD })).toEqual({ mcp: "0.72.2", playwright: "0.35.8", core: "47.39.0" });
});
it("refuses a newest wrapper below the newest server's floor: a release published halfway", async () => {
const at = await index(project("0.73.0", ["invisible-playwright>=0.36.0"]), project("0.35.8", ["invisible-core==47.39.0"]));
await expect(latestBrowserRelease({ index: at, download: DOWNLOAD })).rejects.toThrow(
/the newest invisible-playwright-mcp 0\.73\.0 needs invisible-playwright>=0\.36\.0, and the newest published is 0\.35\.8: the release is not complete/,
);
});
it("refuses a wrapper that does not pin its core, since the image could not name its browser engine", async () => {
const at = await index(project("0.72.2", ["invisible-playwright>=0.35.4"]), project("0.35.8", ["invisible-core>=47"]));
await expect(latestBrowserRelease({ index: at, download: DOWNLOAD })).rejects.toThrow(/does not pin invisible-core to one version/);
});
it("says which project it could not read when the index does not answer", async () => {
http = await startFakeHttp({});
await expect(latestBrowserRelease({ index: http.url("/pypi"), download: DOWNLOAD })).rejects.toThrow(/cannot ask the package index for the newest invisible-playwright/);
});
});
describe("compareReleases", () => {
it("orders releases part by part, numerically", () => {
expect(compareReleases("0.35.10", "0.35.9")).toBeGreaterThan(0);
expect(compareReleases("0.35.4", "0.35.4")).toBe(0);
expect(compareReleases("0.9.0", "0.35.0")).toBeLessThan(0);
});
});
+26 -15
View File
@@ -4,11 +4,11 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { hostPaths, type HostPaths } from "@invisible-dots/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_USER_DATA, defaultAssetRoot } from "../src/assets.js";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_OTHER_BROWSERS, BUILDER_PROVISION, BUILDER_USER_DATA, defaultAssetRoot } from "../src/assets.js";
import type { BrowserRelease } from "../src/browser-release.js";
import { buildGoldenImage, GOLDEN_DEFAULTS, GoldenBuildError, type GoldenBuildOptions } from "../src/golden.js";
import { readManifest, verifyImage, type GoldenManifest } from "../src/manifest.js";
import type { BaseImagePin, GuestPins } from "../src/pins.js";
import { parsePythonLock } from "../src/python-lock.js";
import { fakeRunner, type VmScript } from "./fake-runner.js";
import { sha256, startFakeHttp, type FakeHttp } from "./http-fixture.js";
@@ -17,6 +17,8 @@ const UV = Buffer.from("uv tarball ".repeat(1000));
const TUNNEL = Buffer.from("tunnel binary ".repeat(1000));
const UV_FILE = "uv-x86_64-unknown-linux-gnu.tar.gz";
const TUNNEL_FILE = "hev-socks5-tunnel-linux-x86_64";
/** The release the builds below install, as browser-release.ts would read it off the index. */
const BROWSER: BrowserRelease = { mcp: "0.72.2", playwright: "0.35.8", core: "47.39.0" };
const OK_CONSOLE = [
"[ 0.000000] Linux version 6.8.0",
@@ -84,6 +86,7 @@ function options(script: VmScript, extra: Partial<GoldenBuildOptions> = {}) {
serialPollMs: 5,
// A prebuilt image is looked for in the release of these inputs on GitHub; these tests build.
prebuilt: false,
browser: BROWSER,
...extra,
};
return { runner, opts };
@@ -133,7 +136,8 @@ describe("buildGoldenImage", () => {
pinned: {
uv: { version: "0.12.22", sha256: sha256(UV) },
"invisible-playwright-mcp": "0.72.2",
"invisible-playwright": "0.35.7",
"invisible-playwright": "0.35.8",
"invisible-core": "47.39.0",
apt_snapshot: "20261010T000000Z",
apt_packages: ["xvfb", "imagemagick"],
},
@@ -173,7 +177,7 @@ describe("buildGoldenImage", () => {
/** A copy of the guest files the digest reads, for a test that changes one of them. */
async function assetCopy(): Promise<string> {
const assetRoot = join(home, "assets");
for (const relative of [BUILDER_USER_DATA, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_ENGINE_LOCK, BUILDER_ENGINE_BUILD, BUILDER_BROWSER_BUILD]) {
for (const relative of [BUILDER_USER_DATA, BUILDER_PROVISION, BUILDER_ENGINE_LOCK, BUILDER_ENGINE_BUILD, BUILDER_BROWSER_BUILD, BUILDER_OTHER_BROWSERS]) {
await mkdir(join(assetRoot, dirname(relative)), { recursive: true });
await copyFile(join(defaultAssetRoot(), relative), join(assetRoot, relative));
}
@@ -217,6 +221,17 @@ describe("buildGoldenImage", () => {
expect(second.version).not.toBe(first.version);
});
it("builds a new version when only the list of browsers the image refuses changes", async () => {
const first = await buildGoldenImage(options({ console: OK_CONSOLE, exit: 0 }).opts);
const assetRoot = await assetCopy();
const listPath = join(assetRoot, BUILDER_OTHER_BROWSERS);
await writeFile(listPath, `${await readFile(listPath, "utf8")}servo\n`);
const second = await buildGoldenImage(options({ console: OK_CONSOLE, exit: 0 }, { assetRoot }).opts);
expect(second.created).toBe(true);
expect(second.version).not.toBe(first.version);
});
it("refuses an engine lock with a requirement that has no hash, before booting anything", async () => {
const assetRoot = await assetCopy();
const lockPath = join(assetRoot, BUILDER_ENGINE_LOCK);
@@ -227,22 +242,18 @@ describe("buildGoldenImage", () => {
expect(runner.spawns).toHaveLength(0);
});
it("builds a new version when only the Python lock changes, a transitive package included", async () => {
it("builds a new image when a new release of the browser is published, and records that release", async () => {
const first = await buildGoldenImage(options({ console: OK_CONSOLE, exit: 0 }).opts);
// A copy of the guest files whose lock differs in one hash of one dependency.
const assetRoot = await assetCopy();
const lockPath = join(assetRoot, BUILDER_PYTHON_LOCK);
const lock = await readFile(lockPath, "utf8");
const hash = /--hash=sha256:([0-9a-f]{64})/.exec(lock.slice(lock.indexOf("\nanyio==")))![1]!;
await writeFile(lockPath, lock.replace(hash, "0".repeat(64)));
// Everything the same but the newest invisible-playwright on the index: an image of the last release is not reused.
const next: BrowserRelease = { ...BROWSER, playwright: "0.35.9", core: "47.40.0" };
const second = await buildGoldenImage(options({ console: OK_CONSOLE, exit: 0 }, { browser: next }).opts);
const second = await buildGoldenImage(options({ console: OK_CONSOLE, exit: 0 }, { assetRoot }).opts);
expect(second.created).toBe(true);
expect(second.version).not.toBe(first.version);
const manifest = (await readManifest(second.manifest)) as GoldenManifest;
expect(manifest.pinned["mcp-requirements.lock"]).toBe(sha256(Buffer.from(await readFile(lockPath))));
// The versions in the manifest are the lock's own.
expect(manifest.pinned["invisible-playwright-mcp"]).toBe(parsePythonLock(lock).mcpVersion);
expect(manifest.pinned).toMatchObject({ "invisible-playwright-mcp": "0.72.2", "invisible-playwright": "0.35.9", "invisible-core": "47.40.0" });
expect(logs).toContain("the browser: invisible-playwright-mcp 0.72.2 (invisible-playwright 0.35.9, invisible-core 47.40.0)");
});
it("fails with the provisioner's reason and keeps the work directory", async () => {
+27 -21
View File
@@ -7,9 +7,8 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { GUEST_PATHS } from "@invisible-dots/shared";
import { describe, expect, it } from "vitest";
import { BUILDER_PYTHON_LOCK, defaultAssetRoot, GUEST_ASSETS, GUEST_UNITS, unitAsset } from "../src/assets.js";
import { BUILDER_OTHER_BROWSERS, defaultAssetRoot, GUEST_ASSETS, GUEST_UNITS, unitAsset } from "../src/assets.js";
import { GUEST_PINS } from "../src/pins.js";
import { parsePythonLock } from "../src/python-lock.js";
const root = defaultAssetRoot();
const text = (relative: string) => readFileSync(join(root, relative), "utf8");
@@ -175,47 +174,54 @@ describe("the Dot's browser", () => {
const provision = text("builder/provision.sh");
const build = text("builder/build-browser-env.sh");
it("is built by the script the seed carries, from the hashed lock on the seed, never resolved from the index", () => {
expect(provision).toContain('bash "$payload/$BROWSER_BUILD" "$payload/$PYTHON_LOCK" "$mcp_env"');
it("is the release the build resolved, installed exactly by the script the seed carries", () => {
expect(provision).toContain('bash "$payload/$BROWSER_BUILD" "$mcp_env" \\\n');
expect(provision).toContain('"invisible-playwright-mcp==$MCP_VERSION" "invisible-playwright==$PLAYWRIGHT_VERSION" "invisible-core==$CORE_VERSION"');
expect(provision).toContain("mcp_env=/home/dot/.local/share/invisible-dots/mcp\n");
expect(build).toContain('as_dot uv venv --quiet --python /usr/bin/python3 "$env_dir"');
expect(build).toContain('as_dot uv pip install --no-cache --python "$env_dir/bin/python" --require-hashes --only-binary :all: -r "$dot_lock"');
// Wheels only, the requirements given or the newest server.
expect(build).toContain('as_dot uv pip install --no-cache --python "$env_dir/bin/python" --only-binary :all: "${requirements[@]}"');
expect(build).toContain('[ "${#requirements[@]}" -gt 0 ] || requirements=(invisible-playwright-mcp)');
expect(build).not.toMatch(/uv tool install/);
expect(build).toContain('as_dot ln -sfn "$env_dir/bin/invisible-playwright-mcp" "$bin_dir/invisible-playwright-mcp"');
expect(build).toContain('as_dot "$env_dir/bin/invisible-playwright" fetch');
});
it("the lock in this checkout pins every package with hashes, both top-level packages included", () => {
const lock = parsePythonLock(text(BUILDER_PYTHON_LOCK));
expect(lock.mcpVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(lock.playwrightVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(lock.packages.size).toBeGreaterThan(10);
it("records the versions the environment holds, read from it, not the ones it was asked for", () => {
expect(provision).toContain("installed_version() { as_dot \"$mcp_env/bin/python\" -c 'import sys, importlib.metadata as m; print(m.version(sys.argv[1]))' \"$1\"; }");
for (const name of ["invisible-playwright-mcp", "invisible-playwright", "invisible-core"]) {
expect(provision).toContain(`component ${name} "$(installed_version ${name})"`);
}
});
/**
* Owner's rule: the only browser of a Dot is invisible-playwright-mcp. No other browser, headless
* browser or browser library enters the guest image: not as an apt package, not in the MCP server's
* lock, not as a command of the scripts that build it.
* Owner's rule: the only browser of a Dot is invisible-playwright-mcp. No other browser, headless browser or browser
* library enters the guest image: not as an apt package, not among the Python packages the browser's environment
* installs (build-browser-env.sh refuses them, reading the same list), not as a command of the scripts that build it.
*/
const OTHER_BROWSER =
/^(chromium|chrome|google-chrome|firefox|epiphany|midori|falkon|konqueror|qutebrowser|lynx|links2?|w3m|elinks|selenium|seleniumbase|puppeteer|pyppeteer|playwright|patchright|undetected-chromedriver|chromedriver|geckodriver|webkit|webkit2gtk)(-.*)?$/;
const otherBrowsers = text(BUILDER_OTHER_BROWSERS)
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
const isOtherBrowser = (name: string) => otherBrowsers.some((other) => name === other || name.startsWith(`${other}-`));
it("installs no other browser, headless browser or browser library", () => {
expect(GUEST_PINS.apt_packages.filter((name) => OTHER_BROWSER.test(name))).toEqual([]);
const lock = parsePythonLock(text(BUILDER_PYTHON_LOCK));
expect([...lock.packages.keys()].filter((name) => OTHER_BROWSER.test(name))).toEqual([]);
expect(GUEST_PINS.apt_packages.filter(isOtherBrowser)).toEqual([]);
expect(build).toContain('other_browsers="$(dirname "${BASH_SOURCE[0]}")/other-browsers.txt"');
expect(build).toContain('installed="$(as_dot uv pip freeze --python "$env_dir/bin/python"');
expect(build).toContain("another browser would enter the image (owner's rule)");
for (const [name, content] of [["provision.sh", provision], ["build-browser-env.sh", build]] as const) {
expect(content, name).not.toMatch(/\b(chromium|google-chrome|selenium|puppeteer|patchright|chromedriver|geckodriver)\b/i);
expect(content, name).not.toMatch(/\bplaywright install\b/);
}
});
it("the check above refuses what it is meant to refuse and lets the Dot's browser through", () => {
it("the list refuses what it is meant to refuse and lets the Dot's browser through", () => {
for (const name of ["chromium", "chromium-browser", "firefox", "google-chrome-stable", "selenium", "playwright", "patchright", "pyppeteer", "lynx", "webkit2gtk-4.1"]) {
expect(OTHER_BROWSER.test(name), name).toBe(true);
expect(isOtherBrowser(name), name).toBe(true);
}
for (const name of ["invisible-playwright", "invisible-playwright-mcp", "invisible-core", "libgtk-3-0t64", "xvfb", "imagemagick", "fonts-dejavu-core"]) {
expect(OTHER_BROWSER.test(name), name).toBe(false);
expect(isOtherBrowser(name), name).toBe(false);
}
});
});
+3 -3
View File
@@ -20,7 +20,7 @@ describe("the pins in this checkout", () => {
// The tunnel publishes no checksum list: its pinned sha256 is the only record.
expect(GUEST_PINS.tunnel.url).toContain(`/${GUEST_PINS.tunnel.version}/`);
expect(GUEST_PINS.tunnel.shasums_url).toBeUndefined();
// The Python packages are pinned, with hashes, by builder/mcp-requirements.lock alone.
// The Dot's browser is never pinned: the image installs the newest release (browser-release.ts).
expect(Object.keys(GUEST_PINS)).not.toContain("python_packages");
});
@@ -65,8 +65,8 @@ describe("pin validation", () => {
expect(() => parseGuestPins(unpinned)).toThrow(/apt_snapshot/);
});
it("refuses python_packages, which would be a second copy of the lock's versions", () => {
expect(() => parseGuestPins({ ...good, python_packages: { "invisible-playwright-mcp": "0.70.2" } })).toThrow(/mcp-requirements\.lock/);
it("refuses python_packages: the Dot's browser is the newest release, never a version written here", () => {
expect(() => parseGuestPins({ ...good, python_packages: { "invisible-playwright-mcp": "0.70.2" } })).toThrow(/newest release of the Dot's browser/);
});
it("rejects a base image name that is a path", () => {
+13 -33
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { MCP_PACKAGE, parseHashedLock, parsePythonLock, PLAYWRIGHT_PACKAGE } from "../src/python-lock.js";
import { parseHashedLock } from "../src/python-lock.js";
const HASH_A = `--hash=sha256:${"a".repeat(64)}`;
const HASH_B = `--hash=sha256:${"b".repeat(64)}`;
@@ -10,39 +10,8 @@ function lock(...entries: string[]): string {
const pkg = (name: string, version: string, hashes: string[] = [HASH_A]) =>
[`${name}==${version} \\`, ...hashes.map((hash, i) => ` ${hash}${i < hashes.length - 1 ? " \\" : ""}`)].join("\n");
describe("parsePythonLock", () => {
it("reads every package and the two top-level versions", () => {
const parsed = parsePythonLock(lock(pkg("Typing_Extensions", "4.16.0", [HASH_A, HASH_B]), pkg(MCP_PACKAGE, "0.70.2"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")));
expect(parsed.mcpVersion).toBe("0.70.2");
expect(parsed.playwrightVersion).toBe("0.25.7");
// Names compare as PEP 503 normalizes them.
expect(parsed.packages.get("typing-extensions")).toBe("4.16.0");
expect(parsed.packages.size).toBe(3);
});
it("refuses a requirement without a hash, which --require-hashes would refuse inside the builder VM", () => {
expect(() => parsePythonLock(lock("idna==3.20", pkg(MCP_PACKAGE, "0.70.2"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")))).toThrow(/expected/);
expect(() => parsePythonLock(lock("idna==3.20 \\", pkg(MCP_PACKAGE, "0.70.2"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")))).toThrow(/idna ends without its hashes/);
});
it("refuses anything that is not a pinned requirement: ranges, URLs, editable installs", () => {
for (const line of ["idna>=3 \\", "-e ./local", "idna @ https://example.invalid/idna.whl \\"]) {
expect(() => parsePythonLock(lock(line, ` ${HASH_A}`, pkg(MCP_PACKAGE, "0.70.2"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")))).toThrow(/expected/);
}
});
it("refuses a package listed twice and a lock without both top-level packages", () => {
expect(() => parsePythonLock(lock(pkg("idna", "3.20"), pkg("IDNA", "3.21"), pkg(MCP_PACKAGE, "0.70.2"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")))).toThrow(/listed twice/);
expect(() => parsePythonLock(lock(pkg(MCP_PACKAGE, "0.70.2")))).toThrow(/must pin both/);
});
it("refuses a version that pins.env could not carry without quoting", () => {
expect(() => parsePythonLock(lock(pkg(MCP_PACKAGE, "0.70.2;x"), pkg(PLAYWRIGHT_PACKAGE, "0.25.7")))).toThrow(/expected/);
});
});
describe("parseHashedLock", () => {
it("applies the same rules without asking for the MCP server's packages", () => {
it("reads every package at its exact version, names compared as PEP 503 normalizes them", () => {
const packages = parseHashedLock(lock(pkg("idna", "3.20"), pkg("Typing_Extensions", "4.16.0", [HASH_A, HASH_B])), "engine.lock");
expect([...packages]).toEqual([
["idna", "3.20"],
@@ -50,6 +19,17 @@ describe("parseHashedLock", () => {
]);
});
it("refuses a requirement without a hash, which --require-hashes would refuse inside the builder VM", () => {
expect(() => parseHashedLock(lock("idna==3.20"), "engine.lock")).toThrow(/expected/);
expect(() => parseHashedLock(lock("idna==3.20 \\"), "engine.lock")).toThrow(/idna ends without its hashes/);
});
it("refuses anything that is not a pinned requirement: ranges, URLs, editable installs", () => {
for (const line of ["idna>=3 \\", "-e ./local", "idna @ https://example.invalid/idna.whl \\"]) {
expect(() => parseHashedLock(lock(line, ` ${HASH_A}`), "engine.lock")).toThrow(/expected/);
}
});
it("names the file it was given in every refusal", () => {
expect(() => parseHashedLock(lock("idna==3.20"), "engine.lock")).toThrow(/^engine\.lock:2: expected/);
expect(() => parseHashedLock(lock(pkg("idna", "3.20"), pkg("IDNA", "3.21")), "engine.lock")).toThrow(/engine\.lock:4: idna is listed twice/);
+4 -6
View File
@@ -27,7 +27,7 @@ async function writeEngineTree(root: string): Promise<void> {
"nanobot/dots/main.py": "def main(): ...\n",
"nanobot/templates/agent/tool_contract.md": "# contract\n",
"nanobot/README.md": "not a template: left out\n",
"nanobot/dots/server.json": "{}\n",
"nanobot/dots/data.json": "not source: left out\n",
"nanobot/__pycache__/main.cpython-312.pyc": "bytecode",
"skills/web-forms/SKILL.md": "---\nname: web-forms\ndescription: Fill a form.\n---\n",
"skills/web-forms/notes.txt": "beside the skill: left out\n",
@@ -119,7 +119,6 @@ describe("buildRuntimeIso", () => {
"engine/UPSTREAM.md",
"engine/nanobot/__init__.py",
"engine/nanobot/dots/main.py",
"engine/nanobot/dots/server.json",
"engine/nanobot/templates/agent/tool_contract.md",
"engine/skills/web-forms/SKILL.md",
"install.sh",
@@ -136,8 +135,6 @@ describe("buildRuntimeIso", () => {
expect(read("units/dot-agentd.service")).toEqual(await readFile(join(defaultAssetRoot(), "units", "dot-agentd.service")));
// The engine's source as it is.
expect(read("engine/nanobot/dots/main.py").toString()).toBe("def main(): ...\n");
// And its data: the engine reads the browser server's capture at start.
expect(read("engine/nanobot/dots/server.json").toString()).toBe("{}\n");
expect(read("engine/LICENSE").toString()).toBe("MIT\n");
const manifest = (await readManifest(result.manifest)) as RuntimeManifest;
@@ -194,12 +191,13 @@ describe("buildRuntimeIso", () => {
"engine/nanobot/dots/main.py",
"engine/nanobot/agent/tools/shell.py",
"engine/nanobot/templates/agent/tool_contract.md",
"engine/nanobot/dots/invisible_playwright_mcp.json",
// Run by the installed browser server's own interpreter, which reads it from the runtime disk (server_interface.py).
"engine/nanobot/dots/server_capture.py",
"engine/skills/invisible-playwright/SKILL.md",
]) {
expect(staged).toContain(path);
}
expect(staged.filter((path) => path.startsWith("engine/") && !/\.(py|md|json)$|LICENSE$/.test(path))).toEqual([]);
expect(staged.filter((path) => path.startsWith("engine/") && !/\.(py|md)$|LICENSE$/.test(path))).toEqual([]);
expect(staged.some((path) => path.includes("tests/") || path.includes("__pycache__"))).toBe(false);
// The ISO's own limits (8 levels, names of 64 characters) hold for every one of them.
const result = await buildRuntimeIso({ inputs: real, paths, now: () => at("08:00:00") });
+23 -20
View File
@@ -3,14 +3,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeIso } from "@invisible-dots/iso";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_PROVISION, BUILDER_PYTHON_LOCK, BUILDER_USER_DATA, defaultAssetRoot, readGuestAsset } from "../src/assets.js";
import { BUILDER_BROWSER_BUILD, BUILDER_ENGINE_BUILD, BUILDER_ENGINE_LOCK, BUILDER_OTHER_BROWSERS, BUILDER_PROVISION, BUILDER_USER_DATA, defaultAssetRoot, readGuestAsset } from "../src/assets.js";
import type { BrowserRelease } from "../src/browser-release.js";
import { GUEST_PINS } from "../src/pins.js";
import { parsePythonLock } from "../src/python-lock.js";
import { SEED_VOLUME_ID } from "@invisible-dots/vm-manager";
import { builderMetaData, builderSeedEntries, pinsEnv } from "../src/seed.js";
const pythonLock = await readGuestAsset(defaultAssetRoot(), BUILDER_PYTHON_LOCK);
const python = parsePythonLock(pythonLock.toString("utf8"));
/** The release a build resolved from the index (browser-release.ts). */
const browser: BrowserRelease = { mcp: "0.72.2", playwright: "0.35.8", core: "47.39.0" };
let dir: string;
beforeEach(async () => {
@@ -22,13 +22,13 @@ afterEach(async () => {
describe("pins.env", () => {
it("carries every value provision.sh reads, single-quoted", async () => {
const text = pinsEnv(GUEST_PINS, python);
const text = pinsEnv(GUEST_PINS, browser);
expect(text).toContain(`UV_VERSION='${GUEST_PINS.uv.version}'\n`);
expect(text).toContain("UV_TARBALL='uv-x86_64-unknown-linux-gnu.tar.gz'\n");
// The two Python versions come from the lock, the one place they are written.
expect(text).toContain(`MCP_VERSION='${python.mcpVersion}'\n`);
expect(text).toContain(`PLAYWRIGHT_VERSION='${python.playwrightVersion}'\n`);
expect(text).toContain("PYTHON_LOCK='mcp-requirements.lock'\n");
// The release the build resolved, which provision.sh installs exactly.
expect(text).toContain("MCP_VERSION='0.72.2'\n");
expect(text).toContain("PLAYWRIGHT_VERSION='0.35.8'\n");
expect(text).toContain("CORE_VERSION='47.39.0'\n");
expect(text).toContain("ENGINE_LOCK='engine-requirements.lock'\n");
expect(text).toContain("ENGINE_BUILD='build-engine-env.sh'\n");
expect(text).toContain("BROWSER_BUILD='build-browser-env.sh'\n");
@@ -42,12 +42,13 @@ describe("pins.env", () => {
});
it("refuses a value that could break out of the quotes", () => {
expect(() => pinsEnv({ ...GUEST_PINS, apt_packages: ["xvfb", "it's"] }, python)).toThrow(/APT_PACKAGES/);
expect(() => pinsEnv({ ...GUEST_PINS, apt_packages: ["xvfb", "it's"] }, browser)).toThrow(/APT_PACKAGES/);
expect(() => pinsEnv(GUEST_PINS, { ...browser, mcp: "1.0'; rm" })).toThrow(/MCP_VERSION/);
});
});
describe("the builder seed", () => {
it("holds the NoCloud files, the provisioner, its pins, the Python lock, the pinned downloads, the engine's lock with the script that builds its environment, and the script that builds the browser", async () => {
it("holds the NoCloud files, the provisioner, its pins, the pinned downloads, the engine's lock with the script that builds its environment, and the script that builds the browser with the browsers it refuses", async () => {
const uvTarball = join(dir, "uv.tar.gz");
await writeFile(uvTarball, "uv bytes");
const tunnelBinary = join(dir, "hev");
@@ -55,6 +56,7 @@ describe("the builder seed", () => {
const engineLock = await readGuestAsset(defaultAssetRoot(), BUILDER_ENGINE_LOCK);
const engineBuild = await readGuestAsset(defaultAssetRoot(), BUILDER_ENGINE_BUILD);
const browserBuild = await readGuestAsset(defaultAssetRoot(), BUILDER_BROWSER_BUILD);
const otherBrowsers = await readGuestAsset(defaultAssetRoot(), BUILDER_OTHER_BROWSERS);
const userData = await readGuestAsset(defaultAssetRoot(), BUILDER_USER_DATA);
const provision = await readGuestAsset(defaultAssetRoot(), BUILDER_PROVISION);
const entries = builderSeedEntries({
@@ -62,13 +64,13 @@ describe("the builder seed", () => {
pins: GUEST_PINS,
userData,
provision,
pythonLock,
python,
browser,
uvTarball,
tunnelBinary,
engineLock,
engineBuild,
browserBuild,
otherBrowsers,
});
expect(entries.map((entry) => entry.path)).toEqual([
@@ -76,20 +78,21 @@ describe("the builder seed", () => {
"meta-data",
"provision.sh",
"pins.env",
"mcp-requirements.lock",
"uv-x86_64-unknown-linux-gnu.tar.gz",
"hev-socks5-tunnel-linux-x86_64",
"engine-requirements.lock",
"build-engine-env.sh",
"build-browser-env.sh",
"other-browsers.txt",
]);
expect(entries[0]).toEqual({ path: "user-data", data: userData });
expect(entries[4]).toEqual({ path: "mcp-requirements.lock", data: pythonLock });
expect(entries[5]).toEqual({ path: entries[5]!.path, file: uvTarball });
expect(entries[6]).toEqual({ path: "hev-socks5-tunnel-linux-x86_64", file: tunnelBinary });
expect(entries[7]).toEqual({ path: "engine-requirements.lock", data: engineLock });
expect(entries[8]).toEqual({ path: "build-engine-env.sh", data: engineBuild });
expect(entries[9]).toEqual({ path: "build-browser-env.sh", data: browserBuild });
expect(entries[4]).toEqual({ path: entries[4]!.path, file: uvTarball });
expect(entries[5]).toEqual({ path: "hev-socks5-tunnel-linux-x86_64", file: tunnelBinary });
expect(entries[6]).toEqual({ path: "engine-requirements.lock", data: engineLock });
expect(entries[7]).toEqual({ path: "build-engine-env.sh", data: engineBuild });
expect(entries[8]).toEqual({ path: "build-browser-env.sh", data: browserBuild });
// Beside the script on the seed, where it reads the list (build-browser-env.sh).
expect(entries[9]).toEqual({ path: "other-browsers.txt", data: otherBrowsers });
// The real writer accepts it under the label NoCloud looks for.
const summary = await writeIso(join(dir, "seed.iso"), entries, { volumeId: SEED_VOLUME_ID });
+6 -5
View File
@@ -49,8 +49,8 @@ disk lay them out. This smoke does, in one Linux container, with no QEMU:
The Dot's browser is `invisible-playwright-mcp`, one process per open identity, started by the
engine through `dot-agentd`'s relay so that it runs as `dot`. The smoke installs a stand-in for it as
`INVISIBLE_DOTS_MCP_COMMAND`: the engine's own test fake (`invisible_engine_dots/tests/fakes/fake_mcp_server.py`,
the one owner of what a stand-in answers) on the engine's python, serving what the engine captured from
the pinned server (`invisible_engine_dots/nanobot/dots/invisible_playwright_mcp.json`). It records its
the one owner of what a stand-in answers) serving the fixture `tests/fakes/invisible_playwright_mcp.json`,
installed the way the real server is, so the engine reads its tools as it reads the real server's. It records its
environment, its working directory and every call it receives in `$INVISIBLE_MCP_HOME/record.jsonl`,
and the checks read that file. What they pin:
@@ -86,8 +86,8 @@ The real server and a real Firefox are not run by this smoke: they are the brows
`browser/smoke.sh` runs the Dot's real browser. The golden image's apt packages (`pins.json`:
the desktop, Firefox's libraries, ImageMagick) are installed, the browser is built by
`builder/build-browser-env.sh` on the hashed `builder/mcp-requirements.lock` (the MCP server's
environment and the browser engine: the script `provision.sh` runs), the
`builder/build-browser-env.sh` at the newest release (the MCP server's environment and the
browser engine: the script `provision.sh` runs), the
runtime disk's `dot-desktop` script starts Xvfb and an XFCE session on `:0` as dot, and the Dot
runs as in the engine smoke with no stand-in for the browser: the engine finds
`invisible-playwright-mcp` on its PATH. Only the model is a stand-in, and the pages the browser
@@ -130,7 +130,8 @@ engine) and at a launch (the egress address, for the timezone, and the library's
## Run it
Only docker is needed, with the images `golang:1.26.8` and `ubuntu:24.04` (pulled when
absent; the container downloads the pinned `uv` and the locked wheels).
absent; the container downloads the pinned `uv`, the engine's locked wheels and, for the
browser smoke, the newest browser).
```sh
guest/image-builder/test/smoke/run.sh # the repository this script is in
@@ -12,12 +12,12 @@
# engine lock (the script provision.sh runs), after the same
# packages and the same uv the builder VM has
# runtime disk: the engine's source at /opt/invisible-dots/engine, the files
# runtime.ts stages (every .py and .json, the .md templates, the lock,
# runtime.ts stages (every .py, the .md templates, the lock,
# LICENSE, UPSTREAM.md), world-readable as on the ISO
# The browser suite builds one more thing, as the golden image does it: the apt packages of
# pins.json (the desktop, Firefox's libraries, ImageMagick) and the Dot's browser
# (builder/build-browser-env.sh on the hashed mcp-requirements.lock: the MCP server's
# environment and the engine of the browser).
# (builder/build-browser-env.sh: the newest invisible-playwright-mcp's environment and
# the engine of the browser).
set -euo pipefail
: "${TREE:?run.sh sets TREE}" "${AGENTD_BIN:?run.sh sets AGENTD_BIN}"
suite=${SMOKE_SUITE:-engine}
@@ -58,7 +58,7 @@ bash "$builder/build-engine-env.sh" "$builder/engine-requirements.lock" /opt/inv
# The runtime disk's files, at the paths the ISO has them (it carries no __pycache__, no tests).
mkdir -p /opt/invisible-dots/engine
(cd "$engine_src" && find nanobot \( -name '*.py' -o -name '*.json' -o -path 'nanobot/templates/*.md' \) -type f -exec cp --parents {} /opt/invisible-dots/engine/ \;)
(cd "$engine_src" && find nanobot \( -name '*.py' -o -path 'nanobot/templates/*.md' \) -type f -exec cp --parents {} /opt/invisible-dots/engine/ \;)
cp "$builder/engine-requirements.lock" /opt/invisible-dots/engine/requirements.lock
cp "$engine_src/LICENSE" "$engine_src/UPSTREAM.md" /opt/invisible-dots/engine/
chmod -R a+rX,go-w /opt/invisible-dots
@@ -79,8 +79,9 @@ if [ "$suite" = browser ]; then
snapshot=$(jq -r '.apt_snapshot' "$TREE/guest/image-builder/pins.json")
apt-get --snapshot "$snapshot" --error-on=any update -qq >/dev/null
apt-get --snapshot "$snapshot" install -y -qq --no-install-recommends $(jq -r '.apt_packages[]' "$TREE/guest/image-builder/pins.json") >/dev/null
# The Dot's browser, with the script provision.sh runs on the same lock. dot reaches its home, not the tree.
bash "$builder/build-browser-env.sh" "$builder/mcp-requirements.lock" /home/dot/.local/share/invisible-dots/mcp
# The Dot's browser, with the script provision.sh runs: the newest release, as the next image installs it. dot
# reaches its home, not the tree.
bash "$builder/build-browser-env.sh" /home/dot/.local/share/invisible-dots/mcp
fi
exec bash "$checks"
+23 -12
View File
@@ -99,17 +99,28 @@ check "dotagentd is in no group but its own, and dot is in neither of the two gr
# --- the stand-in for OpenRouter (the key is lib.sh's) ---
start_fake_openrouter
# --- the stand-in for invisible-playwright-mcp ---
# The engine's own test fake (the one owner of what a stand-in answers) and what it serves, the engine's capture
# of the pinned server (nanobot/dots/invisible_playwright_mcp.json, where the fake looks for it two directories
# up), copied out of the tree under test where dot may reach them. It runs on
# the engine's python, which has the `mcp` package, as dot: the engine starts it through dot-agentd's relay.
# The engine's own test fake (the one owner of what a stand-in answers), the capture it serves and the package that
# makes it look installed, copied out of the tree under test where dot may reach them, and installed the way the real
# server is: an environment whose python imports `invisible_playwright_mcp` (the engine's python lends it the `mcp`
# package), with the command beside that python. The engine reads what it serves the way it reads the real server's
# (server_interface.py) and starts it as dot through dot-agentd's relay.
ENGINE_TESTS=$(cd "$HERE/../../../../invisible_engine_dots/tests" && pwd)
FAKE_MCP_DIR=/usr/local/lib/smoke-fake/mcp
install -D -m 0644 "$ENGINE_TESTS/fakes/fake_mcp_server.py" "$FAKE_MCP_DIR/fakes/fake_mcp_server.py"
install -D -m 0644 "$ENGINE_TESTS/../nanobot/dots/invisible_playwright_mcp.json" "$(dirname "$FAKE_MCP_DIR")/nanobot/dots/invisible_playwright_mcp.json"
FAKE_MCP=/usr/local/lib/smoke-fake/invisible-playwright-mcp
printf '#!/bin/sh\nexec /opt/invisible-dots-engine/bin/python -I -B %s/fakes/fake_mcp_server.py "$@"\n' "$FAKE_MCP_DIR" > "$FAKE_MCP"
for file in fake_mcp_server.py invisible_playwright_mcp.json server_package/invisible_playwright_mcp/__init__.py \
server_package/invisible_playwright_mcp/__main__.py server_package/invisible_playwright_mcp/mcp.py; do
install -D -m 0644 "$ENGINE_TESTS/fakes/$file" "$FAKE_MCP_DIR/fakes/$file"
done
FAKE_ENV=/usr/local/lib/smoke-fake/env
/opt/invisible-dots-engine/bin/python -m venv --without-pip "$FAKE_ENV"
fake_site=$("$FAKE_ENV/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
engine_site=$(/opt/invisible-dots-engine/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
printf '%s\n' "$FAKE_MCP_DIR/fakes" "$FAKE_MCP_DIR/fakes/server_package" "$engine_site" > "$fake_site/fake_server.pth"
FAKE_MCP=$FAKE_ENV/bin/invisible-playwright-mcp
printf '#!/bin/sh\nexec %s/bin/python -I -B -m invisible_playwright_mcp "$@"\n' "$FAKE_ENV" > "$FAKE_MCP"
chmod 0755 "$FAKE_MCP"
# A running stand-in, as pgrep -f finds it: the command line the wrapper execs. The engine's read of the server at
# start runs the same module without -I -B, so it is never counted as a server of an identity.
FAKE_PROCESS="$FAKE_ENV/bin/python -I -B -m invisible_playwright_mcp"
# --- the stand-in for an MCP server the person declares: the engine's test fake of any server, run as dot the same way ---
install -D -m 0644 "$ENGINE_TESTS/fakes/fake_tool_server.py" "$FAKE_MCP_DIR/fakes/fake_tool_server.py"
FAKE_TOOLS=/usr/local/lib/smoke-fake/fake-tools
@@ -540,7 +551,7 @@ check "the host pushes the allow-everything config once more (204 204)" "[ \"\$(
BROWSERS=/home/dot/browsers
MCP_HOMES=/var/lib/invisible-dots/mcp # the stand-ins' homes, outside /home/dot (architecture 4.2)
rec() { echo "$MCP_HOMES/$1/record.jsonl"; }
fakes_running() { pgrep -u dot -f fake_mcp_server.py | wc -l; }
fakes_running() { pgrep -u dot -f "$FAKE_PROCESS" | wc -l; }
wait_fakes() { # n: the stand-in's processes of dot number n within 10 s
for _ in $(seq 1 50); do [ "$(fakes_running)" = "$1" ] && return 0; sleep 0.2; done
return 1
@@ -577,7 +588,7 @@ check "no browser runs for a closed identity, and /health counts one identity, n
check "the model launches the identity" "tool_turn 1 browser_identity_launch '{\"identity_id\":\"$ID1\"}'"
check "browser.identity.launched names it" "launched $ID1"
check "the MCP server runs as dot, one process, and none of it runs as dotengine" "[ \"\$(fakes_running)\" = 1 ] && ! pgrep -u dotengine -f fake_mcp_server.py >/dev/null"
check "the MCP server runs as dot, one process, and none of it runs as dotengine" "[ \"\$(fakes_running)\" = 1 ] && ! pgrep -u dotengine -f \"\$FAKE_PROCESS\" >/dev/null"
check "its environment has the profile, the display, the session id and its home, no self-repair of the library, no proxy, none of the engine's variables and no key; its working directory is the identity's" "mcp_env_ok $ID1 ''"
check "browser_open was called with no argument (the server serves the identity's browser alone), once" "call_seen $ID1 browser_open '.args=={}' && [ \"\$(jq -s '[.[] | select(.kind==\"call\" and .name==\"browser_open\")] | length' $(rec $ID1))\" = 1 ]"
check "/health counts one identity, one open" "health_is 1 1"
@@ -636,7 +647,7 @@ check "the parked delete did not run: the identity and its directory are there,
LASTD=$(grep '^id: ' "$STREAM" | tail -1 | sed 's/^id: //')
check "three servers run as dot before the kill" "[ \"\$(fakes_running)\" = 3 ]"
pkill -9 -u dotengine
check "kill -9 of the engine ended every MCP server within 10 s (dot-agentd ended their groups)" "gone_within 10 fake_mcp_server.py"
check "kill -9 of the engine ended every MCP server within 10 s (dot-agentd ended their groups)" "gone_within 10 \"\$FAKE_PROCESS\""
start_engine
check "the restarted engine answers /health (delete pending)" "wait_health && wait_key"
check "the delete is still pending after kill -9" "st | jq -e --arg id \"$APD\" '.pending_approval==\$id' >/dev/null"
@@ -674,7 +685,7 @@ check "closing a closed identity answers 204 and emits nothing; an unknown one i
check "the model launches it again, so the idle kill below meets an open identity" "tool_turn 12 browser_identity_launch '{\"identity_id\":\"$ID5\"}' && wait_fakes 1 && health_is 4 1"
# A server that dies while nothing calls it (its relay ends with it): the engine hears of it when it happens.
CLOSED_BEFORE=$(closed_total $ID5)
check "a server killed while idle closes its identity at once, with no call: closed is emitted once and /health counts none open" "pkill -9 -u dot -f fake_mcp_server.py; wait_fakes 0 && wait_closed_total $ID5 $((CLOSED_BEFORE + 1)) && health_is 4 0 && [ \"\$(api $A/browser-identities/$ID5 | jq -r .status)\" = available ]"
check "a server killed while idle closes its identity at once, with no call: closed is emitted once and /health counts none open" "pkill -9 -u dot -f \"\$FAKE_PROCESS\"; wait_fakes 0 && wait_closed_total $ID5 $((CLOSED_BEFORE + 1)) && health_is 4 0 && [ \"\$(api $A/browser-identities/$ID5 | jq -r .status)\" = available ]"
check "the model launches it once more, so the delete below meets an open identity" "tool_turn 13 browser_identity_launch '{\"identity_id\":\"$ID5\"}' && wait_fakes 1 && health_is 4 1"
check "DELETE /browser-identities/:id of the open identity answers 204" "[ \"\$(api -o /dev/null -w '%{http_code}' -X DELETE $A/browser-identities/$ID5)\" = 204 ]"
check "its browser was closed, then it was deleted: closed, then deleted" "wait_event $STREAM '.type==\"browser.identity.deleted\" and .data.identity_id==\"$ID5\"' && grep '^data: ' $STREAM | sed 's/^data: //' | jq -s -e '[.[] | select(.data.identity_id==\"$ID5\" and (.type==\"browser.identity.closed\" or .type==\"browser.identity.deleted\")) | .type] | .[-2:] == [\"browser.identity.closed\",\"browser.identity.deleted\"]' >/dev/null"
@@ -43,7 +43,7 @@ class ContextBuilder:
text of its MEMORY.md (what the Dot always knows); `skills` are the Dot's
skills (nanobot/dots/skills.py), named in the prompt with their descriptions and paths.
`browser_instructions` are invisible-playwright-mcp's own, carried as an MCP host carries a server's
when its page tools are offered (browser_tools.INSTRUCTIONS); empty when they are not.
when its page tools are offered (browser_tools.server_instructions); empty when they are not.
`mcp_servers` are the MCP servers the person declared whose tools may be offered, each with its `name`, its
`instructions` and, when it is not connected, the `error` that says why (nanobot/dots/mcp_servers.py).
"""
+36 -30
View File
@@ -36,13 +36,11 @@ from __future__ import annotations
import asyncio
import base64
import json
import posixpath
import re
import sqlite3
from collections.abc import Callable, Coroutine, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from loguru import logger
@@ -59,33 +57,12 @@ from nanobot.dots.identity_rules import (
new_identity_id,
)
from nanobot.dots.protocol import BROWSER_ENV, BROWSERS_DIR, GUEST_DISPLAY, MCP_HOMES_DIR
from nanobot.dots.server_interface import ServerInterface, ServerInterfaceError
from nanobot.dots.store import BrowserIdentityRow, DotStore
# The name the private registry of one identity knows its one MCP server by.
SERVER_NAME = "browser"
# What invisible-playwright-mcp tells a model, at the version the image installs: written from the real server by
# guest/image-builder/builder/capture-mcp-interface.py, and a test holds its version to the lock's.
SERVER_INTERFACE: Mapping[str, Any] = json.loads(Path(__file__).with_name("invisible_playwright_mcp.json").read_bytes())
# What `browser_open`'s answer starts with when the browser started: the package's OPENED for the main browser, as
# invisible_playwright_mcp.json records it. Anything else that is not an error is the engine's download progress, and
# it is asked again.
_OPENED = SERVER_INTERFACE["answers"]["opened"] % SERVER_INTERFACE["browser"]
# What the server answers when its browser closed under it while the process lives on (Firefox crashed): its
# NOT_OPEN and GONE sentences for the main browser, as invisible_playwright_mcp.json records them from the pinned
# package's own constants (the package lives in the MCP's own environment, so there is nothing importable here).
# The sentence is the WHOLE error, behind the prefix FastMCP puts on what a tool raises. A phrase anywhere in
# a longer error is not it: a failed click or select echoes text the page controls (the covering element's
# text, the option asked for), and a page that says "the main browser is gone" must not close the identity.
_BROWSER_LOST = re.compile(
r"(?:Error executing tool \w+: )?(?:"
+ "|".join(
re.escape(SERVER_INTERFACE["answers"][answer] % SERVER_INTERFACE["browser"]) for answer in ("not_open", "gone")
)
+ ")"
)
# Why a session ended without a close of ours, as the log says it.
_PROCESS_ENDED = "the MCP server exited unexpectedly"
_BROWSER_GONE = "the MCP server reports its browser gone (Firefox crashed or its window was closed)"
@@ -215,6 +192,7 @@ class BrowserManager:
store: DotStore,
computer: Computer,
mcp_command: str,
interface: ServerInterface | ServerInterfaceError,
max_open: int,
max_identities: int,
browsers_dir: str = BROWSERS_DIR,
@@ -252,11 +230,26 @@ class BrowserManager:
self._unrecorded: set[_Session] = set()
# Orders `create`, whose count check and insert are apart by the directory it makes.
self._create_lock = asyncio.Lock()
# What the server `mcp_command` runs tells a model (server_interface.py), read when the engine started; or
# why it could not be read, and then no browser opens and the launch says that.
self._interface = interface if isinstance(interface, ServerInterface) else None
self._interface_error = (
"" if isinstance(interface, ServerInterface) else f"the browser server on this computer could not be read: {interface}"
)
# ------------------------------------------------------------------
# What callers read
# ------------------------------------------------------------------
@property
def interface(self) -> ServerInterface | None:
"""What the installed server tells a model; None when it could not be read (`interface_error` says why)."""
return self._interface
@property
def interface_error(self) -> str:
return self._interface_error
@property
def limits(self) -> tuple[int, int]:
"""`(max_open, max_identities)`."""
@@ -349,6 +342,8 @@ class BrowserManager:
A launch of an identity that is opening waits for that opening and shares its outcome; one of an
identity that is closing waits for the close and then opens it.
"""
# Nothing is reserved or started for a server whose interface could not be read: it says why instead.
self._require_interface("launch_failed")
while True:
row = self._require(identity_id)
session = self._sessions.get(identity_id)
@@ -568,8 +563,8 @@ class BrowserManager:
BROWSER_ENV["PROFILE_DIR"]: profile,
BROWSER_ENV["HEADLESS"]: "0",
BROWSER_ENV["DISPLAY"]: self._display,
# invisible_core reinstalls itself from the package index when its version drifts; the image installed it
# from a hashed lock, and a drift has to fail loudly instead of bringing in files nobody checked.
# invisible_core reinstalls itself from the package index when its version drifts: the image installed
# the browser it was built with, and a browser changing under a running Dot has to fail loudly instead.
BROWSER_ENV["CORE_AUTOFIX"]: "off",
# The engine opens and closes this browser and the model has the page tools only: the server serves
# `main` alone (no tool takes `browser`, so a caller cannot choose another) and its instructions are
@@ -691,6 +686,7 @@ class BrowserManager:
so this asks again, waiting 2 s doubling to 30 s, until the deadline.
"""
identity_id = session.identity_id
interface = self._require_interface("launch_failed")
loop = asyncio.get_running_loop()
deadline = loop.time() + self._open_deadline_s
wait = self._open_retry_initial_s
@@ -703,7 +699,7 @@ class BrowserManager:
)
if result_is_error(result):
raise BrowserIdentityError("launch_failed", f'browser_open failed for identity "{identity_id}": {text}')
if text.startswith(_OPENED):
if text.startswith(interface.opened):
return
if loop.time() + wait > deadline:
raise BrowserIdentityError(
@@ -760,9 +756,19 @@ class BrowserManager:
"""
await asyncio.wait({self._end_session(session, _BROWSER_GONE)})
@staticmethod
def _is_browser_lost(result: Any) -> bool:
return result_is_error(result) and _BROWSER_LOST.fullmatch(result_text(result).strip()) is not None
def _is_browser_lost(self, result: Any) -> bool:
interface = self._interface
return (
interface is not None
and result_is_error(result)
and interface.browser_lost.fullmatch(result_text(result).strip()) is not None
)
def _require_interface(self, code: str) -> ServerInterface:
"""The installed server's interface; a BrowserIdentityError with `code` and the reason when there is none."""
if self._interface is None:
raise BrowserIdentityError(code, self._interface_error)
return self._interface
async def _close_ended(self, session: _Session, why: str) -> None:
"""Close the identity of a session that cannot be used any more, and emit `closed` once."""
@@ -6,10 +6,10 @@ Two kinds:
identities. Opening is explicit. A page tool on an identity that is not open does not open it, it says
so, so `browser.identity.launch` alone decides whether a browser starts.
* The page tools are invisible-playwright-mcp's own, offered as an MCP host offers a server's tools: the
server's name, description and input schema, as `invisible_playwright_mcp.json` captured them from the
pinned version, plus the `identity_id` that says which open identity's server the call goes to. Which of
the server's tools the model is offered is the permission table's (`permissions.py`); the server's
instructions go into the system prompt (`INSTRUCTIONS`). The server serves the identity's browser alone
name, description and input schema the server installed on the computer serves (`server_interface.py`, read
when the engine starts), plus the `identity_id` that says which open identity's server the call goes to.
Which of the server's tools the model is offered is the permission table's (`permissions.py`); the server's
instructions go into the system prompt. The server serves the identity's browser alone
(`BROWSER_ENV["HOST_MANAGED"]`), so its tools take no `browser`.
An image the server answers with is shown to the model and not stored (images.py): the tool's result is its
@@ -22,13 +22,12 @@ import base64
import json
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.mcp import _normalize_schema_for_openai
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.dots.browser import (
SERVER_INTERFACE,
BrowserIdentity,
BrowserIdentityError,
BrowserManager,
@@ -38,11 +37,9 @@ from nanobot.dots.browser import (
from nanobot.dots.computer import Computer, ComputerError
from nanobot.dots.identity_rules import IDENTITY_ID_MAX
from nanobot.dots.images import ToolImage, current_turn_images, placeholder, show_images
from nanobot.dots.server_interface import ServerTool
from nanobot.dots.store import iso_from_ms
# The server's instructions, which the system prompt carries as an MCP host carries them (context.py).
INSTRUCTIONS: str = SERVER_INTERFACE["instructions"]
_IDENTITY_ID = {
"type": "string",
"minLength": 1,
@@ -61,26 +58,6 @@ _OPEN_IDENTITY_ID = {
}
@dataclass(frozen=True)
class ServerTool:
"""One tool of invisible-playwright-mcp as the server serves it: name, description, input schema."""
name: str
description: str
input_schema: Mapping[str, Any]
# The server's tools by name, as captured.
SERVER_TOOLS: Mapping[str, ServerTool] = {
tool["name"]: ServerTool(
tool["name"],
tool.get("description") or tool["name"],
tool.get("inputSchema") or {"type": "object", "properties": {}},
)
for tool in SERVER_INTERFACE["tools"]
}
# What browser_navigate may open. The server passes the URL to the page unchecked, so file:///home/dot/... would
# put any file the dot user can read into browser_read_text, whatever files.read says, and about:, view-source:,
# data: and javascript: reach browser internals and run script. Each permission decides only its own action.
@@ -258,12 +235,24 @@ class BrowserIdentityCloseTool(_IdentityActionTool):
return f"The browser of identity {identity_id} is closed; its profile is kept."
def server_instructions(tools: ToolRegistry) -> str:
"""The browser server's instructions when `tools` offers one of its page tools, else empty."""
for name in tools.tool_names:
tool = tools.get(name)
if isinstance(tool, BrowserPageTool):
return tool.server_instructions
return ""
class BrowserPageTool(_BrowserTool):
"""A tool of invisible-playwright-mcp, called on the open identity the model names."""
def __init__(self, browser: BrowserManager, tool: ServerTool) -> None:
def __init__(self, browser: BrowserManager, tool: ServerTool, server_instructions: str) -> None:
super().__init__(browser)
self._tool = tool
# The server's instructions, which the system prompt carries as an MCP host carries them (context.py)
# whenever a turn offers one of its tools.
self.server_instructions = server_instructions
# The server's schema as nanobot's MCP client offers any server's (`mcp.MCPToolWrapper`), with the identity
# in front of the server's own arguments.
schema = _normalize_schema_for_openai(dict(tool.input_schema))
+15 -1
View File
@@ -36,6 +36,7 @@ from nanobot.dots.permissions import ToolDeps, build_registry
from nanobot.dots.provider import OpenRouterProviders
from nanobot.dots.secrets import KeyHolder, McpSecrets
from nanobot.dots.server import AgentServer
from nanobot.dots.server_interface import ServerInterface, ServerInterfaceError, read_server_interface
from nanobot.dots.store import DotStore, StoreOwnedError
from nanobot.utils.token_encoding import warmup_token_encoding
@@ -139,11 +140,13 @@ async def serve(environment: Environment, stop: asyncio.Event) -> None:
computer = AgentdComputer(environment.agentd_bin, environment.agentd_socket, environment.workspace)
exec_sessions = ExecSessionManager()
cron = CronService(state_dir / "cron" / "jobs.json")
# The Dot's one browser: invisible-playwright-mcp, one process per open identity, run as `dot`.
# The Dot's one browser: invisible-playwright-mcp, one process per open identity, run as `dot`. What it tells
# a model is read from the server installed on this computer first: the page tools are that server's.
browser = BrowserManager(
store=store,
computer=computer,
mcp_command=environment.mcp_command,
interface=await _browser_interface(computer, environment.mcp_command),
max_open=DEFAULT_MAX_OPEN,
max_identities=DEFAULT_MAX_IDENTITIES,
)
@@ -204,6 +207,17 @@ async def serve(environment: Environment, stop: asyncio.Event) -> None:
store.close()
async def _browser_interface(computer: AgentdComputer, mcp_command: str) -> ServerInterface | ServerInterfaceError:
"""The interface of the server `mcp_command` runs on this computer, or why it could not be read (logged)."""
try:
interface = await read_server_interface(computer, mcp_command)
except ServerInterfaceError as error:
logger.error("the browser server could not be read, so no browser opens: {}", error)
return error
logger.info("browser server: invisible-playwright-mcp {} serving {} tools", interface.version, len(interface.tools))
return interface
async def _retry(engine: Engine) -> None:
while True:
await asyncio.sleep(RETRY_INTERVAL_S)
@@ -56,18 +56,22 @@ class ToolEntry:
"""What the contract knows of one tool.
permission: the key of the host's permission map the tool exercises.
build: factory taking ToolDeps to instantiate the Tool.
build: factory taking ToolDeps to instantiate the Tool; None for a page tool the installed browser server does
not serve.
target: from the call's arguments, the one line `tool.called` shows of it (None: nothing).
starts_terminal: from the call's arguments, whether it starts a terminal session.
arguments: from the call's arguments, the ones an `approval.requested` shows the person.
"""
permission: str
build: Callable[[ToolDeps], Tool]
build: Callable[[ToolDeps], Tool | None]
target: Callable[[Mapping[str, Any]], str | None]
# Whether the call starts a terminal session, which `tool.called` marks (`tty`); no other tool does.
starts_terminal: Callable[[Mapping[str, Any]], bool] = targets.never_starts_terminal
arguments: Callable[[Mapping[str, Any]], dict[str, Any]] = targets.all_arguments
# A page tool: one of invisible-playwright-mcp's, which exists only when the server installed on the computer
# serves it (`build` returns None otherwise).
served_by_browser: bool = False
def _build_exec(deps: ToolDeps) -> Tool:
@@ -166,13 +170,17 @@ def _build_browser_identity_close(deps: ToolDeps) -> Tool:
return BrowserIdentityCloseTool(deps.browser)
def _build_page_tool(name: str) -> Callable[[ToolDeps], Tool]:
"""The builder of a page tool: the tool of invisible-playwright-mcp of that name (browser_tools.SERVER_TOOLS)."""
def _build_page_tool(name: str) -> Callable[[ToolDeps], Tool | None]:
"""The builder of a page tool: the tool of that name of the invisible-playwright-mcp installed on the computer
(`BrowserManager.interface`); None when that server does not serve it, or could not be read."""
def build(deps: ToolDeps) -> Tool:
from nanobot.dots.browser_tools import SERVER_TOOLS, BrowserPageTool
def build(deps: ToolDeps) -> Tool | None:
from nanobot.dots.browser_tools import BrowserPageTool
return BrowserPageTool(deps.browser, SERVER_TOOLS[name])
interface = deps.browser.interface
if interface is None or name not in interface.tools:
return None
return BrowserPageTool(deps.browser, interface.tools[name], interface.instructions)
return build
@@ -208,20 +216,20 @@ TOOL_PERMISSIONS: Mapping[str, ToolEntry] = MappingProxyType(
"browser_identity_delete": ToolEntry("browser.identity.delete", _build_browser_identity_delete, targets.identity_target),
"browser_identity_launch": ToolEntry("browser.identity.launch", _build_browser_identity_launch, targets.identity_target),
"browser_identity_close": ToolEntry("browser.identity.close", _build_browser_identity_close, targets.identity_target),
"browser_navigate": ToolEntry("browser.navigate", _build_page_tool("browser_navigate"), targets.browser_navigate_target, arguments=targets.navigate_arguments),
"browser_snapshot": ToolEntry("browser.read", _build_page_tool("browser_snapshot"), targets.browser_identity_only_target),
"browser_read_text": ToolEntry("browser.read", _build_page_tool("browser_read_text"), targets.browser_selector_target),
"browser_read_html": ToolEntry("browser.read", _build_page_tool("browser_read_html"), targets.browser_identity_only_target),
"browser_take_screenshot": ToolEntry("browser.read", _build_page_tool("browser_take_screenshot"), targets.browser_identity_only_target),
"browser_navigate": ToolEntry("browser.navigate", _build_page_tool("browser_navigate"), targets.browser_navigate_target, served_by_browser=True, arguments=targets.navigate_arguments),
"browser_snapshot": ToolEntry("browser.read", _build_page_tool("browser_snapshot"), targets.browser_identity_only_target, served_by_browser=True),
"browser_read_text": ToolEntry("browser.read", _build_page_tool("browser_read_text"), targets.browser_selector_target, served_by_browser=True),
"browser_read_html": ToolEntry("browser.read", _build_page_tool("browser_read_html"), targets.browser_identity_only_target, served_by_browser=True),
"browser_take_screenshot": ToolEntry("browser.read", _build_page_tool("browser_take_screenshot"), targets.browser_identity_only_target, served_by_browser=True),
# Reads with JavaScript; the server refuses a script that acts on the page and names the tool to use instead.
"browser_evaluate": ToolEntry("browser.read", _build_page_tool("browser_evaluate"), targets.browser_identity_only_target),
"browser_click": ToolEntry("browser.act", _build_page_tool("browser_click"), targets.browser_selector_target),
"browser_click_at": ToolEntry("browser.act", _build_page_tool("browser_click_at"), targets.browser_click_at_target),
"browser_type": ToolEntry("browser.act", _build_page_tool("browser_type"), targets.browser_selector_target),
"browser_press_key": ToolEntry("browser.act", _build_page_tool("browser_press_key"), targets.browser_press_key_target),
"browser_select_option": ToolEntry("browser.act", _build_page_tool("browser_select_option"), targets.browser_selector_target),
"browser_evaluate": ToolEntry("browser.read", _build_page_tool("browser_evaluate"), targets.browser_identity_only_target, served_by_browser=True),
"browser_click": ToolEntry("browser.act", _build_page_tool("browser_click"), targets.browser_selector_target, served_by_browser=True),
"browser_click_at": ToolEntry("browser.act", _build_page_tool("browser_click_at"), targets.browser_click_at_target, served_by_browser=True),
"browser_type": ToolEntry("browser.act", _build_page_tool("browser_type"), targets.browser_selector_target, served_by_browser=True),
"browser_press_key": ToolEntry("browser.act", _build_page_tool("browser_press_key"), targets.browser_press_key_target, served_by_browser=True),
"browser_select_option": ToolEntry("browser.act", _build_page_tool("browser_select_option"), targets.browser_selector_target, served_by_browser=True),
# Attaches files of the dot user, the ones its commands can read, to a file field of the page.
"browser_upload_files": ToolEntry("browser.act", _build_page_tool("browser_upload_files"), targets.browser_selector_target),
"browser_upload_files": ToolEntry("browser.act", _build_page_tool("browser_upload_files"), targets.browser_selector_target, served_by_browser=True),
# Asks the host to add an MCP server to the Dot's config; the host checks the permission again and writes it.
"add_mcp_server": ToolEntry("mcp.servers.add", _build_add_mcp_server, targets.add_mcp_server_target),
}
@@ -296,6 +304,10 @@ def offered_mcp_servers(servers: Collection[str], permissions: Mapping[str, str]
return sorted(server for server in servers if permissions.get(mcp_permission(server)) in ("allow", "ask"))
# What GET /tools says of a page tool of the table the browser server installed on this computer does not serve.
UNSERVED = "not served by the browser server installed on this computer"
def tool_table(registry: ToolRegistry, offered: Collection[str]) -> list[dict[str, Any]]:
"""One row per tool of the table, as `GET /tools` shows it: the name, the permission it exercises,
whether the model is offered it now (`offered` is what the projection offers) and what its schema says
@@ -303,6 +315,10 @@ def tool_table(registry: ToolRegistry, offered: Collection[str]) -> list[dict[st
rows: list[dict[str, Any]] = []
for name, entry in TOOL_PERMISSIONS.items():
tool = registry.get(name)
if tool is None and entry.served_by_browser:
# A page tool the browser server installed on this computer does not serve (build_registry).
rows.append({"name": name, "permission": entry.permission, "offered": False, "description": UNSERVED})
continue
if tool is None:
raise LookupError(f'the registry has no tool "{name}" of the permission table')
rows.append(
@@ -312,11 +328,14 @@ def tool_table(registry: ToolRegistry, offered: Collection[str]) -> list[dict[st
def build_registry(deps: ToolDeps) -> ToolRegistry:
"""Register exactly the tools of the permission table on a new registry."""
"""Register the tools of the permission table on a new registry: every one, but a page tool the browser server
installed on the computer does not serve, which no turn can offer (registry views take only what is registered)."""
from nanobot.agent.tools.registry import ToolRegistry
registry = ToolRegistry()
for entry in TOOL_PERMISSIONS.values():
registry.register(entry.build(deps))
tool = entry.build(deps)
if tool is not None:
registry.register(tool)
return registry
@@ -0,0 +1,49 @@
"""What invisible-playwright-mcp tells a model, read from the server installed on this computer; printed as JSON.
The engine does not run this file: it has it run through the relay, as `dot`, by the interpreter of the installed
server (`server_interface.read_server_interface`), because only that interpreter can import the server's package.
It starts the server the way the engine starts it (`INVISIBLE_MCP_HOST_MANAGED=1`), asks `initialize` for the
version and the instructions and `tools/list` for the tools, and reads the sentences the engine recognizes in the
server's answers from the package's own constants (OPENED, NOT_OPEN, GONE, for the browser DEFAULT_BROWSER_ID).
Listing starts no browser.
The imports are inside `main`: the engine's own environment imports this module along with the rest of its
package, and it has neither the server nor the version of the MCP SDK the server was installed with.
"""
from __future__ import annotations
def main() -> None:
import asyncio
import json
import os
import sys
from invisible_playwright_mcp.mcp import DEFAULT_BROWSER_ID, GONE, NOT_OPEN, OPENED
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def capture() -> dict:
environment = {**os.environ, "INVISIBLE_MCP_HOST_MANAGED": "1"}
server = StdioServerParameters(command=sys.executable, args=["-m", "invisible_playwright_mcp"], env=environment)
async with stdio_client(server) as (read, write), ClientSession(read, write) as session:
started = await session.initialize()
listed = await session.list_tools()
return {
"package": "invisible-playwright-mcp",
"version": started.serverInfo.version,
"instructions": started.instructions or "",
"browser": DEFAULT_BROWSER_ID,
"answers": {"opened": OPENED, "not_open": NOT_OPEN, "gone": GONE},
"tools": [
{"name": tool.name, "description": tool.description, "inputSchema": tool.inputSchema}
for tool in listed.tools
],
}
print(json.dumps(asyncio.run(capture()), ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,104 @@
"""What the Dot's browser server tells a model, read from the server installed on the Dot's computer.
The image installs the newest invisible-playwright-mcp when it is built, and a Dot keeps the image it was made from
while every start runs the newest engine, so the server an engine talks to is whichever its own computer has. The
engine therefore reads the server's interface from that server when it starts (`read_server_interface`), never from
a copy of its own: the page tools are offered with the names, descriptions and input schemas the installed server
serves, its instructions go into the system prompt, and the sentences the engine recognizes in its answers are the
installed package's own.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.dots.computer import Computer, ComputerError
# Run by the installed server's interpreter, as dot, through the relay (server_capture.py says why).
CAPTURE_SCRIPT = Path(__file__).with_name("server_capture.py")
# The installed server's interpreter is the `python` of the environment its command lives in: the command is a
# console script of that environment, linked onto dot's PATH. Resolved as dot, so the engine reads nothing of dot's.
_RUN_WITH_SERVER_PYTHON = 'bin=$(dirname "$(readlink -f "$(command -v "$1")")") && exec "$bin/python" -I "$2"'
READ_TIMEOUT_S = 120.0
class ServerInterfaceError(Exception):
"""The installed server could not be read, or what it said is not an interface."""
@dataclass(frozen=True)
class ServerTool:
"""One tool of invisible-playwright-mcp as the server serves it: name, description, input schema."""
name: str
description: str
input_schema: Mapping[str, Any]
@dataclass(frozen=True)
class ServerInterface:
"""The installed server's version, instructions, tools, and the answers the engine recognizes."""
version: str
instructions: str
tools: Mapping[str, ServerTool]
# What `browser_open`'s answer starts with when the browser started (the package's OPENED for its browser).
opened: str
# The server's whole error when its browser closed under it while the process lives on (NOT_OPEN and GONE),
# behind the prefix FastMCP puts on what a tool raises. A phrase anywhere in a longer error is not it: a failed
# click echoes text the page controls, and a page that says "the main browser is gone" must not close anything.
browser_lost: re.Pattern[str]
def parse_server_interface(data: Any) -> ServerInterface:
"""The interface `server_capture.py` printed, checked; ServerInterfaceError names what is missing."""
try:
browser = data["browser"]
answers = data["answers"]
tools = {
tool["name"]: ServerTool(
tool["name"],
tool.get("description") or tool["name"],
tool.get("inputSchema") or {"type": "object", "properties": {}},
)
for tool in data["tools"]
}
return ServerInterface(
version=str(data["version"]),
instructions=data.get("instructions") or "",
tools=tools,
opened=answers["opened"] % browser,
browser_lost=re.compile(
r"(?:Error executing tool \w+: )?(?:"
+ "|".join(re.escape(answers[answer] % browser) for answer in ("not_open", "gone"))
+ ")"
),
)
except (KeyError, TypeError) as error:
raise ServerInterfaceError(f"the server's interface lacks {error}") from error
async def read_server_interface(computer: Computer, mcp_command: str) -> ServerInterface:
"""Read the interface of the server `mcp_command` runs on `computer`, as dot, with the server's own interpreter."""
argv = ["sh", "-c", _RUN_WITH_SERVER_PYTHON, "sh", mcp_command, str(CAPTURE_SCRIPT)]
try:
result = await computer.run(argv, timeout_s=READ_TIMEOUT_S)
except (OSError, ComputerError) as error:
# The relay itself did not run (no dot-agentd on this computer): the server cannot be read, for that reason.
raise ServerInterfaceError(f"reading {mcp_command}: the relay did not run: {error}") from error
if result.timed_out or result.exit_code != 0:
why = "timed out" if result.timed_out else f"exited {result.exit_code}"
detail = result.stderr.decode("utf-8", "replace").strip().splitlines()[-3:]
raise ServerInterfaceError(f"reading {mcp_command} {why}: {' / '.join(detail) or 'no output'}")
try:
data = json.loads(result.stdout)
except ValueError as error:
raise ServerInterfaceError(f"reading {mcp_command} printed no interface: {error}") from error
return parse_server_interface(data)
+1 -3
View File
@@ -323,9 +323,7 @@ class TurnRunner:
now=datetime.now().astimezone(),
skills=await all_skills(self._computer),
# The browser server's instructions, as an MCP host carries them, when the turn offers its tools.
browser_instructions=browser_tools.INSTRUCTIONS
if any(name in browser_tools.SERVER_TOOLS for name in settings.offered_tools)
else "",
browser_instructions=browser_tools.server_instructions(tools),
# The declared MCP servers whose tools may be offered: their instructions, or why they are not connected.
mcp_servers=self._mcp.prompt_servers(settings.mcp_servers),
)
-1
View File
@@ -56,7 +56,6 @@ allow-direct-references = true
include = [
"nanobot/**/*.py",
"nanobot/templates/**/*.md",
"nanobot/**/*.json",
"skills/*/SKILL.md",
]
@@ -19,7 +19,7 @@ from typing import Any
import pytest
from fakes.browser_manager import mcp_home
from fakes.fake_mcp_server import install_fake_mcp, read_record, write_control
from fakes.fake_mcp_server import install_fake_mcp, read_record, server_interface, write_control
from fakes.local_computer import LocalComputer
from nanobot.agent.tools.base import ToolResult
@@ -56,6 +56,7 @@ class Env:
"store": self.store,
"computer": self.computer,
"mcp_command": str(self.mcp_bin),
"interface": server_interface(),
"max_open": 3,
"max_identities": 20,
"browsers_dir": str(self.browsers),
@@ -601,9 +602,10 @@ NOT_OPEN_SENTENCE = "the main browser is not open. Call browser_open to open it.
("the support browser is gone: it closed or crashed.", False),
],
)
def test_only_the_librarys_whole_sentence_is_a_lost_browser(text: str, lost: bool) -> None:
assert BrowserManager._is_browser_lost(ToolResult.error(text)) is lost
assert BrowserManager._is_browser_lost(text) is False
def test_only_the_librarys_whole_sentence_is_a_lost_browser(env: Env, text: str, lost: bool) -> None:
manager = env.manager()
assert manager._is_browser_lost(ToolResult.error(text)) is lost
assert manager._is_browser_lost(text) is False
async def test_the_launch_that_follows_a_lost_browser_makes_a_new_server_and_the_identity_works_again(env: Env) -> None:
@@ -2,13 +2,14 @@
Each tool is run the way the engine runs one (`run_tool`: registry, cast, validation, error wrapping). The
MCP server is `fake_mcp_server.py`, which serves invisible-playwright-mcp's own tools as the engine captured them
(`nanobot/dots/invisible_playwright_mcp.json`) and records every call it gets, so what a tool sends is read off
(`tests/fakes/invisible_playwright_mcp.json`) and records every call it gets, so what a tool sends is read off
the server and not off the tool.
"""
from __future__ import annotations
import base64
import dataclasses
import json
import re
from collections.abc import AsyncIterator, Callable, Iterator
@@ -18,7 +19,7 @@ from typing import Any
import pytest
from fakes.browser_manager import make_browser_manager, mcp_home
from fakes.dot_config import ALLOW_ALL
from fakes.fake_mcp_server import PNG, read_record, write_control
from fakes.fake_mcp_server import PNG, read_record, server_interface, write_control
from fakes.local_computer import LocalComputer
from fakes.run_tool import run_tool
from fakes.scripted_provider import call, calls, says
@@ -29,10 +30,11 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.cron.service import CronService
from nanobot.dots import store as s
from nanobot.dots.browser import BrowserManager
from nanobot.dots.browser_tools import SERVER_TOOLS
from nanobot.dots.browser import BrowserIdentityError, BrowserManager
from nanobot.dots.browser_tools import server_instructions
from nanobot.dots.images import IMAGES_HEADER, TurnImages, bind_turn_images, reset_turn_images
from nanobot.dots.permissions import TOOL_PERMISSIONS, ToolDeps, build_registry
from nanobot.dots.permissions import TOOL_PERMISSIONS, UNSERVED, ToolDeps, build_registry, tool_table
from nanobot.dots.server_interface import ServerInterfaceError
from nanobot.dots.store import DotStore
from nanobot.dots.turns import OpeningMessage, TurnUnit
@@ -135,6 +137,7 @@ PAGE_CALLS: list[tuple[str, dict[str, Any]]] = [
("browser_select_option", {"selector": "#c", "value": "it"}),
("browser_upload_files", {"selector": "#file", "paths": ["/home/dot/workspace/cv.pdf"]}),
]
SERVER_TOOLS = server_interface().tools
PAGE_TOOLS = {name for name in TOOL_PERMISSIONS if name in SERVER_TOOLS}
@@ -151,6 +154,44 @@ async def test_a_page_tool_calls_the_server_tool_of_its_name_with_the_arguments_
assert env.page_calls(identity_id) == [(tool, arguments)]
def _registry(tmp_path: Path, store: DotStore, interface: Any) -> tuple[BrowserManager, ToolRegistry]:
(tmp_path / "computer").mkdir()
computer = LocalComputer(tmp_path / "computer")
manager = make_browser_manager(tmp_path, store, computer, interface=interface)
deps = ToolDeps(computer, ExecSessionManager(), CronService(tmp_path / "cron" / "jobs.json"), manager, store)
return manager, build_registry(deps)
async def test_with_no_readable_server_no_page_tool_is_offered_and_a_launch_says_why(
tmp_path: Path, dot_store: DotStore
) -> None:
"""A computer whose browser server could not be read (an image without one, a broken install) has the identity
tools and no page tool, and a launch answers with why instead of starting a process that cannot be driven."""
manager, registry = _registry(tmp_path, dot_store, ServerInterfaceError("reading invisible-playwright-mcp exited 127"))
assert not [name for name in PAGE_TOOLS if registry.has(name)]
assert server_instructions(registry.view(list(TOOL_PERMISSIONS))) == ""
identity = await manager.create("shop")
with pytest.raises(BrowserIdentityError, match="could not be read: reading invisible-playwright-mcp exited 127") as raised:
await manager.launch(identity.id)
assert raised.value.code == "launch_failed"
assert not manager.is_open(identity.id)
def test_a_page_tool_the_installed_server_does_not_serve_is_not_offered(tmp_path: Path, dot_store: DotStore) -> None:
"""The permission table names the page tools a Dot may be offered; the installed server decides which exist."""
served = dict(SERVER_TOOLS)
del served["browser_upload_files"]
_, registry = _registry(tmp_path, dot_store, dataclasses.replace(server_interface(), tools=served))
assert not registry.has("browser_upload_files")
assert all(registry.has(name) for name in PAGE_TOOLS - {"browser_upload_files"})
rows = {row["name"]: row for row in tool_table(registry, set(TOOL_PERMISSIONS))}
assert rows["browser_upload_files"] == {
"name": "browser_upload_files", "permission": "browser.act", "offered": False, "description": UNSERVED,
}
def test_every_page_tool_is_exercised_by_the_table_above() -> None:
assert {row[0] for row in PAGE_CALLS} == PAGE_TOOLS
@@ -1,58 +0,0 @@
"""What invisible-playwright-mcp tells a model, as the engine serves it, and the version it was captured at.
`nanobot/dots/invisible_playwright_mcp.json` is the real server's instructions and `tools/list`, captured over stdio by
`guest/image-builder/builder/capture-mcp-interface.py` from the server started as the engine starts it. The engine
offers the model those tools with those descriptions and schemas and puts those instructions in its prompt, and the
fake MCP server of the tests serves the same file, so it has to describe the version the golden image installs:
`guest/image-builder/builder/mcp-requirements.lock` is the one place that version is written.
"""
from __future__ import annotations
import re
from pathlib import Path
from nanobot.dots.browser_tools import INSTRUCTIONS, SERVER_INTERFACE, SERVER_TOOLS
ENGINE_ROOT = Path(__file__).resolve().parents[2]
LOCK = ENGINE_ROOT.parent / "guest" / "image-builder" / "builder" / "mcp-requirements.lock"
PACKAGE = "invisible-playwright-mcp"
def locked_version(package: str) -> str:
match = re.search(rf"^{re.escape(package)}==(\S+)", LOCK.read_text(encoding="utf-8"), re.MULTILINE)
assert match is not None, f"{package} is not pinned in {LOCK.name}"
return match.group(1)
def test_the_capture_is_of_the_version_the_image_installs() -> None:
assert SERVER_INTERFACE["package"] == PACKAGE
assert SERVER_INTERFACE["version"] == locked_version(PACKAGE), (
"the lock pins another invisible-playwright-mcp version than the one captured: run "
"guest/image-builder/builder/capture-mcp-interface.py with the pinned version installed"
)
def test_it_was_captured_from_a_server_started_as_the_engine_starts_it() -> None:
"""A capture of the default server would offer `support` on every tool and tell the model to call browser_open,
which it does not have: the engine starts the server with INVISIBLE_MCP_HOST_MANAGED=1."""
assert "browser_open" not in INSTRUCTIONS and "support" not in INSTRUCTIONS
assert not [name for name, tool in SERVER_TOOLS.items() if "browser" in tool.input_schema.get("properties", {})]
def test_the_capture_holds_the_sentences_a_tool_answers_instead_of_working() -> None:
"""The engine closes an identity on these whole sentences (browser.py `_BROWSER_LOST`) and knows a browser opened
by the first (`_OPENED`): the capture records them from the package's own constants, so a version that rewords
them changes this file and the engine with it."""
assert SERVER_INTERFACE["browser"] == "main"
for answer in ("not_open", "gone"):
sentence = SERVER_INTERFACE["answers"][answer]
assert sentence.count("%s") == 1 and "browser_open" in sentence, answer
assert SERVER_INTERFACE["answers"]["opened"] % "main" == "the main browser is open."
def test_the_capture_holds_the_servers_tools_with_their_descriptions_and_schemas() -> None:
assert {"browser_open", "browser_close", "browser_navigate", "browser_snapshot", "browser_watch"} <= set(SERVER_TOOLS)
for name, tool in SERVER_TOOLS.items():
assert tool.description and tool.description != name, name
assert tool.input_schema.get("type") == "object", name
@@ -342,7 +342,7 @@ def test_every_browser_tool_is_served_by_invisible_playwright_mcp_and_no_other_t
elif entry.permission in PAGE_PERMISSIONS:
assert isinstance(tool, browser_tools.BrowserPageTool), name
assert tool.browser is deps.browser, name
assert name in browser_tools.SERVER_TOOLS, f"{name} is not a tool the server has"
assert name in deps.browser.interface.tools, f"{name} is not a tool the server has"
else:
# Whatever else the table holds is no browser: not by its permission, its name or its class.
assert not name.startswith("browser"), name
@@ -0,0 +1,61 @@
"""What the Dot's browser server tells a model, read from the server installed on the computer.
The engine reads it when it starts (server_interface.read_server_interface): server_capture.py run through the relay
by the installed server's own interpreter. Here the installed server is the fake, laid out like the real one
(fake_mcp_server.install_fake_mcp), so the read takes the same path as in a Dot.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fakes.fake_mcp_server import install_fake_mcp, interface, server_interface
from fakes.local_computer import LocalComputer
from nanobot.dots.server_interface import ServerInterfaceError, parse_server_interface, read_server_interface
async def test_the_interface_is_read_from_the_installed_server_through_its_own_interpreter(tmp_path: Path) -> None:
read = await read_server_interface(LocalComputer(tmp_path), str(install_fake_mcp()))
served = interface()
assert read.version == served["version"]
assert read.instructions == served["instructions"]
assert set(read.tools) == {tool["name"] for tool in served["tools"]}
for tool in served["tools"]:
assert read.tools[tool["name"]].description == tool["description"]
assert read.tools[tool["name"]].input_schema == tool["inputSchema"]
assert read.opened == served["answers"]["opened"] % served["browser"]
async def test_a_command_that_is_not_an_installed_server_is_an_error_that_says_why(tmp_path: Path) -> None:
with pytest.raises(ServerInterfaceError, match=r"reading /nowhere/invisible-playwright-mcp exited \d+"):
await read_server_interface(LocalComputer(tmp_path), "/nowhere/invisible-playwright-mcp")
def test_what_is_not_an_interface_is_refused_naming_what_is_missing() -> None:
data = interface()
del data["answers"]
with pytest.raises(ServerInterfaceError, match="answers"):
parse_server_interface(data)
def test_the_browser_is_lost_only_on_the_servers_whole_sentence() -> None:
"""The engine closes an identity on these whole sentences, behind FastMCP's prefix or not; a phrase inside a
longer error is page text (a failed click echoes what covers the element) and closes nothing."""
lost = server_interface().browser_lost
for answer in ("not_open", "gone"):
sentence = interface()["answers"][answer] % interface()["browser"]
assert lost.fullmatch(sentence)
assert lost.fullmatch(f"Error executing tool browser_click: {sentence}")
assert not lost.fullmatch(f"the element is covered by: {sentence}")
def test_the_fixture_is_of_a_server_started_as_the_engine_starts_it() -> None:
"""A capture of the default server would offer `browser` on every tool and tell the model to call browser_open,
which it does not have: the engine starts the server with INVISIBLE_MCP_HOST_MANAGED=1, and so does the capture."""
fixture = server_interface()
assert "browser_open" not in fixture.instructions and "support" not in fixture.instructions
assert not [name for name, tool in fixture.tools.items() if "browser" in tool.input_schema.get("properties", {})]
assert fixture.opened == "the main browser is open."
@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from fakes.fake_mcp_server import install_fake_mcp
from fakes.fake_mcp_server import install_fake_mcp, server_interface
from nanobot.dots.browser import BrowserManager
from nanobot.dots.computer import Computer
@@ -27,6 +27,7 @@ def make_browser_manager(tmp_path: Path, store: DotStore, computer: Computer, **
"store": store,
"computer": computer,
"mcp_command": str(install_fake_mcp(tmp_path / "bin")),
"interface": server_interface(),
"max_open": 3,
"max_identities": 20,
"browsers_dir": str(tmp_path / "browsers"),
@@ -8,40 +8,73 @@ assert on the process's environment, working directory and calls. Like the real
`$INVISIBLE_MCP_HOME/control.json`, because the engine hands the process an environment of its own
and a test cannot add a variable to it.
Its instructions and `tools/list` are the real server's, as the engine reads them
(`nanobot/dots/invisible_playwright_mcp.json`, captured in the mode the engine starts it in), and the SDK checks
every call against those input schemas. The server also refuses an argument the real tool does not have,
so a parameter renamed in a call fails here and not only inside a Dot.
Its instructions and `tools/list` are a capture of the real server's in the mode the engine starts it in
(`invisible_playwright_mcp.json` beside this file), and the SDK checks every call against those input schemas. The
server also refuses an argument the real tool does not have, so a parameter renamed in a call fails here and not only
inside a Dot.
`install_fake_mcp` writes the executable the manager runs as the MCP command, the way
`local_computer.install_fake_relay` does for the relay.
`install_fake_mcp` lays the fake out like an installed server (server_package/): an environment whose `python`
imports `invisible_playwright_mcp`, and a command beside it that runs `python -m invisible_playwright_mcp`, so the
engine reads its interface the way it reads the real one (server_interface.py) and runs it as the MCP command.
"""
from __future__ import annotations
import asyncio
import functools
import json
import os
import site
import sys
import uuid
import tempfile
import venv
from pathlib import Path
from typing import Any
# Beside the engine's package in the repository, and copied to the same place relative to this file by the smoke.
INTERFACE = Path(__file__).resolve().parents[2] / "nanobot" / "dots" / "invisible_playwright_mcp.json"
SCRIPT = Path(__file__).resolve()
INTERFACE = Path(__file__).resolve().with_name("invisible_playwright_mcp.json")
FAKES = Path(__file__).resolve().parent
# A 1x1 transparent PNG, and a JPEG of one pixel.
PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
JPEG = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k="
def install_fake_mcp(directory: Path) -> Path:
"""Write an executable that runs this file with the interpreter of the tests; returns its path."""
script = directory / f"fake-mcp-{uuid.uuid4().hex}"
script.write_bytes(f"#!/bin/sh\nexec '{sys.executable}' '{SCRIPT}' \"$@\"\n".encode("utf-8"))
script.chmod(0o755)
return script
def install_fake_mcp(directory: Path | None = None) -> Path:
"""The command of a fake server installed like the real one; its environment is made once per process.
The environment's `python` is a real virtual environment's, so it runs `-I` as the engine has it run (isolated:
no PYTHONPATH, no user site); a .pth file gives it this interpreter's packages (the MCP SDK), the fake package
and this directory. `directory` is accepted for the callers that name where their programs go; the one
environment serves them all.
"""
return _fake_server_command()
@functools.cache
def _fake_server_command() -> Path:
env = Path(tempfile.mkdtemp(prefix="fake-invisible-playwright-mcp-"))
venv.EnvBuilder(with_pip=False).create(env)
bin_dir = env / ("Scripts" if os.name == "nt" else "bin")
purelib = Path(next(p for p in (env / "lib").glob("python*/site-packages")))
paths = [str(FAKES), str(FAKES / "server_package"), *site.getsitepackages(), *[p for p in sys.path if p.endswith("site-packages")]]
(purelib / "fake_server.pth").write_text("\n".join(dict.fromkeys(paths)) + "\n", encoding="utf-8")
command = bin_dir / "invisible-playwright-mcp"
command.write_text(f'#!/bin/sh\nexec "{bin_dir / "python"}" -m invisible_playwright_mcp "$@"\n', encoding="utf-8")
command.chmod(0o755)
return command
def interface() -> dict[str, Any]:
"""The captured interface the fake serves, as server_capture.py prints it."""
return json.loads(INTERFACE.read_text(encoding="utf-8"))
@functools.cache
def server_interface() -> Any:
"""The fake's interface as the engine holds it, for the BrowserManagers of the tests."""
from nanobot.dots.server_interface import parse_server_interface
return parse_server_interface(interface())
def read_record(mcp_home: Path) -> list[dict[str, Any]]:
@@ -75,10 +108,6 @@ middle of a call; to `slow://...` it answers after 0.3 s. Each call is recorded
(mcp_home / "control.json").write_bytes(json.dumps(control).encode("utf-8"))
def _interface() -> dict[str, Any]:
return json.loads(INTERFACE.read_text(encoding="utf-8"))
async def _serve() -> None:
import mcp.types as types
from mcp.server.lowlevel import Server
@@ -107,8 +136,8 @@ async def _serve() -> None:
saved.parent.mkdir(parents=True, exist_ok=True)
saved.write_bytes(json.dumps({"browsers": {role: who}, "focus": role}).encode("utf-8"))
interface = _interface()
tools = {tool["name"]: tool for tool in interface["tools"]}
interface_data = interface()
tools = {tool["name"]: tool for tool in interface_data["tools"]}
state: dict[str, Any] = {
"download_left": int(control.get("download_answers", 0)),
"lose_browser": bool(control.get("lose_browser_once", False) or control.get("lose_browser_always", False)),
@@ -131,7 +160,7 @@ async def _serve() -> None:
if unknown:
return text(f"invalid arguments for {name}: it has no argument {', '.join(unknown)}", error=True)
# Started as the engine starts the real one (INVISIBLE_MCP_HOST_MANAGED=1), it serves `main` alone.
role = interface["browser"]
role = interface_data["browser"]
if name == "browser_open":
if control.get("fail_open"):
@@ -144,7 +173,7 @@ async def _serve() -> None:
)
state["open"] = True
remember(role)
return text(interface["answers"]["opened"] % role + " seed: remembered by the profile")
return text(interface_data["answers"]["opened"] % role + " seed: remembered by the profile")
if name == "browser_close":
if control.get("refuse_close"):
return text(f"the {role} browser could not be closed", error=True)
@@ -153,11 +182,11 @@ async def _serve() -> None:
if name == "browser_list":
return text(json.dumps({"focus": "main", "browsers": []}))
if not state["open"]:
return tool_error(name, interface["answers"]["not_open"] % role)
return tool_error(name, interface_data["answers"]["not_open"] % role)
if state["lose_browser"]:
state["lose_browser"] = bool(control.get("lose_browser_always", False))
state["open"] = False
return tool_error(name, interface["answers"]["gone"] % role)
return tool_error(name, interface_data["answers"]["gone"] % role)
if name == "browser_status":
return text(f"the {role} browser is open on {state['url']}")
@@ -199,7 +228,7 @@ async def _serve() -> None:
return text(f"attached {len(args['paths'])} file(s) to {args['selector']}")
return text(f"unknown tool {name}", error=True)
server: Server[Any] = Server("fake-stealth", instructions=interface["instructions"])
server: Server[Any] = Server("fake-stealth", version=interface_data["version"], instructions=interface_data["instructions"])
@server.list_tools()
async def list_tools() -> list[types.Tool]:
@@ -226,5 +255,9 @@ async def _serve() -> None:
record({"kind": "exit", "code": 0})
if __name__ == "__main__":
def main() -> None:
asyncio.run(_serve())
if __name__ == "__main__":
main()
@@ -1,5 +1,5 @@
{
"//": "What invisible-playwright-mcp tells a model, at the version builder/mcp-requirements.lock pins, started as the engine starts it (INVISIBLE_MCP_HOST_MANAGED=1): its instructions and its tools/list as served, and the sentences the engine reads in its answers (the package's OPENED, NOT_OPEN and GONE, %s being the browser). Written by guest/image-builder/builder/capture-mcp-interface.py; capture it again when the lock changes that version.",
"//": "A capture of invisible-playwright-mcp's interface, the way nanobot/dots/server_capture.py prints it from an installed server started as the engine starts it (INVISIBLE_MCP_HOST_MANAGED=1): what the fake server serves and what the engine's tests are given. A Dot's engine reads the interface from its own computer's server.",
"package": "invisible-playwright-mcp",
"version": "0.72.2",
"instructions": "Drive the page the way a person would. Everything here goes\nthrough the real pointer and the real keyboard.\n\nTry things in this order. It matters, because a page can tell the difference.\n\n1. A named tool with a selector: browser_click, browser_type,\n browser_select_option, browser_press_key, browser_upload_files.\n browser_snapshot gives you the selector for each element - pass it\n verbatim, it is built to be unambiguous.\n\n2. Coordinates. browser_snapshot reports `at: [x, y]` for every element it\n lists, in viewport pixels. browser_click_at takes exactly those and moves the\n pointer there. This is the rung for anything a selector does not describe: a\n canvas, a slider, a map, a custom widget built out of divs.\n\n3. Your eyes. browser_take_screenshot, find the thing in the picture, then\n browser_click_at on where it is. For what the snapshot does not list at all.\n\n4. browser_evaluate, last, to READ what none of the tools above can.\n\nbrowser_evaluate refuses the obvious ways to act on the page, and names the tool\nto use instead: assigning to value, checked or selected, or calling click(),\ndispatchEvent(), submit() or requestSubmit(). All of those skip the keyboard and\nthe pointer, so the event arrives with isTrusted false - the single clearest\nsignal that something other than a person is driving, and avoiding it is what\nthis browser is for. When you want that, rung 2 or rung 3 is what you actually\nwant.\n\nThat refusal is a guardrail on the obvious road, not a wall around the field.\nJavaScript has unlimited ways to say the same thing and this catches the ones\nworth catching, so DO NOT read a silent pass as permission: if you find a way to\nchange the page through browser_evaluate, that is the bug, and saying so in your\nanswer is worth more than using it.\n\nYou do not need script to read state back, either. The snapshot carries\n`checked` for a checkbox or radio and `value` for a select, alongside the text.\n\nA cookie or consent dialog covers the page until it is answered: a click on\nanything under it waits and fails. When browser_snapshot shows one, click one of\nits buttons first. A date field that is a calendar wants its days clicked, not\ntyped.\n\nIf you get to the bottom of the ladder and still cannot do the thing, say so in\nyour answer. A task reported as impossible is worth more than a task completed\nin a way that gets you blocked, and a number or a fact you could not read on the\npage is not one to give.\n\nReach a page through a link you have seen or an address you were given, not one\nyou remember: sites move their pages, and an address from memory often answers\n404. browser_snapshot lists a page's links with their URLs, and a site's own\nmenu and search reach what you cannot guess. From nothing, search: navigate to\nhttps://search.brave.com/search?q=<your words> and read the results with\nbrowser_snapshot, whose URLs are real. After a 404, the next address comes from\na page that loaded or from a search, not from memory.\n\nA browser drives ONE page, and there is no way to open, list, choose or close\nanother: browser_navigate opens the page and every other tool acts on it. Going\nsomewhere else and coming back is a navigation, not a second window: to go\nback, navigate to the address you came from, and to load a page again, navigate\nto its own address.",
@@ -0,0 +1,3 @@
"""The fake invisible-playwright-mcp as a package, laid out like the real one so the engine reads its interface the way
it reads the real server's (server_interface.py): `python -m invisible_playwright_mcp` serves, and the sentences the
engine recognizes are constants of `invisible_playwright_mcp.mcp`. What it serves is fake_mcp_server.py's."""
@@ -0,0 +1,3 @@
from fake_mcp_server import main
main()
@@ -0,0 +1,11 @@
"""The constants a host reads, as the real package exports them from `invisible_playwright_mcp.mcp`: here, the ones
the fixture interface records, so the fake answers with the sentences the engine is told to recognize."""
from fake_mcp_server import interface
_ANSWERS = interface()["answers"]
DEFAULT_BROWSER_ID = interface()["browser"]
OPENED = _ANSWERS["opened"]
NOT_OPEN = _ANSWERS["not_open"]
GONE = _ANSWERS["gone"]
@@ -35,6 +35,13 @@ RUNTIME_ONLY = {
}
# Modules of the package another interpreter runs, with the distributions that interpreter has and the engine does
# not: server_capture.py is run by the python of the installed invisible-playwright-mcp (server_interface.py).
OTHER_INTERPRETER = {
"nanobot/dots/server_capture.py": {"invisible-playwright-mcp"},
}
def _normalize(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower()
@@ -61,11 +68,14 @@ def _imported() -> dict[str, set[str]]:
tops = [node.module.split(".")[0]]
else:
continue
module = path.relative_to(ENGINE_ROOT).as_posix()
for top in tops:
if top in sys.stdlib_module_names or top == "nanobot":
continue
distribution = _normalize(IMPORT_TO_DISTRIBUTION.get(top, top))
found.setdefault(distribution, set()).add(path.relative_to(ENGINE_ROOT).as_posix())
if distribution in OTHER_INTERPRETER.get(module, set()):
continue
found.setdefault(distribution, set()).add(module)
return found
+10 -8
View File
@@ -105,10 +105,9 @@ describe("the engine smoke", () => {
expect(read(join(smoke, "prepare-engine.sh"))).toContain("browser/smoke.sh");
});
it("builds the browser smoke's browser with the image builder's own script, lock and apt packages, and runs the real server", () => {
it("builds the browser smoke's browser with the image builder's own script and apt packages, the newest release, and runs the real server", () => {
const prepare = read(join(smoke, "prepare-engine.sh"));
expect(prepare).toContain("build-browser-env.sh");
expect(prepare).toContain("mcp-requirements.lock");
expect(prepare).toContain('bash "$builder/build-browser-env.sh" /home/dot/.local/share/invisible-dots/mcp\n');
expect(prepare).toContain(".apt_packages[]");
const provision = read(join(repo, "guest/image-builder/builder/provision.sh"));
expect(provision).toContain("BROWSER_BUILD");
@@ -124,15 +123,18 @@ describe("the engine smoke", () => {
expect(checks).toContain("pkill -9 -u dotengine");
});
it("runs the browser seams against the engine's one MCP stand-in and the tool list captured from the pinned server", () => {
it("runs the browser seams against the engine's one MCP stand-in, installed the way the real server is", () => {
const checks = read(join(smoke, "smoke.sh"));
const lib = read(join(smoke, "lib.sh"));
const fake = "invisible_engine_dots/tests/fakes/fake_mcp_server.py";
expect(existsSync(join(repo, fake)), fake).toBe(true);
// The smoke copies that file, not a second stand-in of its own, and the engine's capture of the server it serves.
expect(checks).toContain("fakes/fake_mcp_server.py");
expect(checks).toContain("nanobot/dots/invisible_playwright_mcp.json");
expect(existsSync(join(repo, "invisible_engine_dots/nanobot/dots/invisible_playwright_mcp.json"))).toBe(true);
// The smoke copies that file, not a second stand-in of its own, with the capture it serves and the package that
// lets the engine read it as it reads an installed server.
for (const file of ["fake_mcp_server.py", "invisible_playwright_mcp.json", "server_package/invisible_playwright_mcp/mcp.py"]) {
expect(checks).toContain(file);
expect(existsSync(join(repo, "invisible_engine_dots/tests/fakes", file)), file).toBe(true);
}
expect(checks).toContain('FAKE_MCP=$FAKE_ENV/bin/invisible-playwright-mcp');
expect(existsSync(join(smoke, "fake_mcp.py"))).toBe(false);
// It is the program the engine runs for a browser, under the name the engine reads: lib.sh writes the
// engine's environment, and only a smoke that sets MCP_COMMAND gives the engine a program of its own.