The engine smoke found its stand-in by fake_mcp_server.py on the command line; installed the way the real server is,
it runs as `python -I -B -m invisible_playwright_mcp`, and that command line is what the checks look for.
test_dependencies names server_capture.py as a module another interpreter runs: its invisible_playwright_mcp import
is the installed server's, not a dependency of the engine.
A Dot keeps the golden image it was made from and boots every new runtime disk, so the engine already talked to
servers other than the one its pinned copy of the interface described. The engine now reads the interface from the
installed server when it starts (nanobot/dots/server_interface.py, server_capture.py run as dot by the server's own
python): the page tools are what that server serves, a page tool it does not serve is not offered (GET /tools says
so), and a Dot whose server cannot be read offers no page tool and its launches say why.
With nothing in the engine tied to a version, the image no longer pins one: each build asks PyPI for the newest
invisible-playwright-mcp, the newest invisible-playwright it accepts and the invisible-core that one pins
(browser-release.ts). The three enter the inputs digest, pins.env and the manifest, which records what was installed.
mcp-requirements.lock and capture-mcp-interface.py go; the copy of the interface becomes a test fixture. The owner's
no-other-browser rule is checked at build on what was actually installed (builder/other-browsers.txt), and the golden
image workflow runs every six hours so a new release is published as a prebuilt image.
The server's snapshot and read_html read again on the page that replaced the one they went to (page.evaluate_read),
and browser_evaluate, the model's code, runs once when its page navigates under it (B285). Under them, through
firefox-45: a page that replaces itself keeps its main world (B286), the real mouse no longer turns a click into a
drag, a session keeps focus when another starts beside it, iframes stay in the frame tree after a few navigations,
go_back/go_forward to a same-document entry answer, a browser that dies ends every wait at once, every input event
is born a few ms before the page sees it, and the first enumerateDevices of a session takes a time the seed
declares. The tools' schemas and descriptions are unchanged: the recaptured interface differs only in its version.
OpenRouter routes a request by the answer it asks for but not by the prompt it carries, so a request sized for
the model's widest window could land on a provider with a narrower one (glm-5.3-flash: Reka at 262144 among
providers at 1048576) and fail there. The providers read with the limits on every turn now route the model's
requests: provider.only is the slugs of the widest window's providers. When none of them can serve, OpenRouter
refuses with "No allowed providers are available" and the turn fails with those words.
invisible-playwright-mcp 0.72.1 drops its last caps: browser_type quotes the value the field kept whole, and an
error detail in its chat and routes comes whole. The tools' schemas are unchanged.
A result comes back whole. exec, glob/grep and read_file lose their fixed caps (10000/50000, 12000 and 2000
per line, 128000) and the general 12000-character cut is gone. A request that outgrows the model's window is
compacted first, as before; only if it still does not fit are the results of its latest round cut to what the
window leaves, measured with the estimate that judges the request, and the cut says how many characters came
through of how many and how to ask for less. A result is stored cut only beyond what no compaction could make
room for.
The window is read on every turn from the providers OpenRouter lists for the model
(GET /models/{model}/endpoints), not once per process from the whole list: of the providers serving it, the
widest window and the longest answer at that window.
The core keeps a frame's navigation history when Firefox re-announces the main frame on a cross-process
navigation, redoes a by-value call whose execution context a navigation destroyed, and answers a goto that
another navigation overtook with Playwright's "interrupted" error instead of waiting 45 s. The wrapper carries
invisible_playwright#321's fix. The server reads an element's text in one command and returns it whole:
max_chars is gone from browser_read_text and browser_snapshot. The interface is captured again from the lock.
The six hero GIFs were rendered from pages that lived only in a scratch folder, so
changing one character meant editing frames. docs/hero/ now holds every piece, in
three layers, each made from the one before:
photos/ characters.png as an image model draws it, on white; cut_out.py turns
it into one cut-out per theme (shadows kept on light, removed on dark,
and only the white connected to the edge touched, so the eyes stay)
html/ wide, mobile and small pages that compose the photos; renderLoop(t)
draws the frame at second t, and opened in a browser each page plays
its own loop
build.mjs photos -> frames (Chromium, 12 a second) -> docs/images/hero-*.gif,
with the same palette and dither as before
A fourth page, html/social.html, builds docs/images/social-preview.png, the
repository's social preview, from the same photos.
The characters are new: the same four, colours inverted, each with a role it can
carry (a hard hat, sunglasses, a beret, a magnifying glass), all still angry. The
scenes, their words and their timing are unchanged.
It added two channels and left them running for the rest of the file and its teardown, the only channels there; on
one Windows run that teardown did not finish in 60 s (not reproduced in twelve runs under load here). A test stops
what it starts: the two channels are removed at its end, and the teardown is what it was before they came.
It failed now and then in CI (four runs since 2026-10-08), always right after the test that polls TOKEN every few
milliseconds with a 409: a poll of that test still on its way when it ended was answered after the next test had
queued its 429 on the same token, as Telegram answers a request whose client has gone, so the stray poll took the
429 and the new poller connected without the error the test waits for. The 429 test now uses a token no other test
polls.
GUEST_PATH was one PATH for the engine and the desktop; with the engine's unit on system directories only, the browser
smoke started the desktop and looked for invisible-playwright-mcp with a variable that no longer existed. It reads
dot's PATH from dot-desktop.service, as lib.sh reads the engine's and the daemon's from theirs.
The step keeps research open "as it would be left at night" so that the server restart and the stop find it open, but
its task never said so; a model closed the browser after reading the title, as a tidy one may, and the step failed on
a choice the prompt left open. The task now asks to leave the browser open.
invisible-playwright-mcp 0.71.2 exports OPENED, the first sentence of browser_open's answer, which the engine matched
by its words ("browser is open") because the library had no constant for it. The lock moves to 0.71.2 (only that
package and its hashes), the capture records OPENED beside NOT_OPEN and GONE, and the engine reads all three from
invisible_playwright_mcp.json: no sentence of the server is written in the engine any more.
The shell's context held the health with everything else, so its poll every 30 seconds gave the whole value a new
identity and re-rendered every user of the shell. useHealth reads it from its own context; only the rail's status,
the offline banner, the setup checklist, the key form and the host settings follow the poll.
The engine's unit put dot's ~/.local/bin first on its PATH, only so the guest check could find
invisible-playwright-mcp by name; but the engine holds the OpenRouter key, and a program a library ran by name would
have been dot's, run as dotengine. The unit's PATH is now system directories only, as dot-agentd's is, and it names
the browser's MCP server by its path (INVISIBLE_DOTS_MCP_COMMAND), which the engine checks and starts as dot through
the relay. The smoke reads both from the unit.
The shell reads the failed tasks and the channels of every Dot with GET /api/tasks and GET /api/channels: the web
tests' control plane answers them as the real one does, and the inbox tests that faked one Dot's list failing now
fail the one read, which the inbox reports as such.
A careful model refused to run a command that searches the system journal for sk-or-: it read like a probe for
secrets. The property the check proves is what the model's user can read, so it runs the same command through
dot-agentd's exec route, which the Dot's exec tool uses, as that user; the hash of the count and a nonce still proves
the whole journal was read.
A careful model looks whether the marker is there after its command was interrupted (cat and pgrep): that is a
second exec call, but not the command again. The step counted every exec of the task and failed on it; it now
requires that no other call runs the interrupted command, and the marker check still proves the command never ran.
The engine's lean pass removed the MCP reconnect, the provider's reasoning options, the event sink, concurrent tool
batches, spilling results to files and the cron methods only tests used, each with its tests: the whole suite now
passes 2483 on Linux (measured in WSL, where CI runs it), down from 2553. The other floors only rise; they are set
from CI's measured counts once it has run.
vm-manager's own Logger, silentLogger and stderrLogger repeated the shared ones; it now takes them from
packages/shared and logs as the rest of the process does (prefixedStderrLogger). vm-manager and the API's VM driver
each had their own exists(): one pathExists in shared replaces both.
- dot-install refused nothing apt reads as an action: "foo-" removed foo, as root. A name can no longer end in "-";
a test runs the real script against stubs.
- The VM proxy's setup is runtime/vmproxy.py, staged on the runtime ISO: a bad value, a host that does not resolve and
no default route each say so (exit 3, 4, 5) instead of all reading "not socks5"; the firewall still drops all. The
smoke runs the file itself.
- dot-agentd is built one way everywhere (-trimpath -buildvcs=false -ldflags=-s -w): a commit no longer changes the
binary and the runtime ISO. go.mod pins the toolchain, setup-go@v6 reads it, a repo test keeps every spelling equal.
- The golden image's apt packages come from an Ubuntu snapshot (pins.json apt_snapshot, in the inputs digest); the
browser environment installs wheels only, with no cache left in the image.
- tests/repo/agentd-protocol.test.ts compares the Go routes and constants (and the engine's defaults) with
packages/shared; a Linux test runs the real dot-agentd under the real GuestClient: proof, auth, exec, files, the
agent proxy and its SSE.
- vm-manager: one assertImages, the VM bounds from CONFIG_BOUNDS, the pid file in its own module; one
resolveImageVersion and publishImage for the golden, runtime and prebuilt images.
- Fixed-sleep tests wait for their signal; the docs no longer say the images are never published or speak of CD-ROMs.
The web's shell, on every page, read each Dot's task list and each Dot's channels, at load and again for every Dot on
each task.failed and channel event: 2N requests for N Dots, as its own comments said, because no route answered for
every Dot. GET /api/tasks?status=&finished_after= lists the tasks of every Dot that ended in a state after a time, and
GET /api/channels every channel with its Dot's id (never a credential); the SDK has endedTasks and everyChannel, and
the shell makes one request for each.
Clock, Logger, silentLogger, errorMessage, ControlPlaneError, notFound and the stderr logger move from
apps/scheduler to packages/shared; packages/channels and apps/api import them from there, and channels keeps the
scheduler for its tests only. One abortableSleep replaces four copies (scheduler, CLI, SDK, WhatsApp); the one
shared had slept the whole time on a signal that had already aborted. The API reports its package's version, as the
CLI does, instead of a hard-coded 0.1.0.
- The tool and permission labels move from the web to packages/shared (labels.ts, still checked against
permissions.py): Telegram, WhatsApp and the CLI ask "The Dot wants to run a command" with "Run commands (high
risk)", as the web card does, and the CLI names the Dot.
- The event reader moves from the web to the SDK: `logs` reads only the newest events and follows on; `approvals
--all` keeps the newest 500 and `status` reads the 10 it shows.
- New CLI commands: cancel, delete --yes, update with the config version (a concurrent save is a 409). The CLI's
version comes from its package.
- The web's tool and approval steps are one model (steps.ts) for the chat, the task story and the activity; one
outcome table and one duration format.
- The inbox keys answer only while the approvals list has the focus (WCAG 2.1.4); useResource and the Dot shell's
context keep their identity across renders.
- setup --all no longer sends the person to a sign-in that does not exist; the MCP panel says the Dot can add a
server; the e2e contract declares every CLI command run.ts runs, and a test holds it; apps/web's second vitest
config is gone; docs follow.
- The provider no longer turns <tool_call> text into calls (architecture 8.3 says such text runs nothing); a test
holds it.
- The MCP client no longer retries a call after a connection error, which could run it twice on an HTTP server
(section 8.7): it fails with its error and reports the server's end.
- Removed what the Dot never reaches (3342 lines, with their tests): the MCP reconnect and enabled_tools, the
provider's reasoning tables and unused options, the event sink, concurrent tool batches, spilling results to files,
the cron methods only tests used and the protected system jobs, and the identity state `archived`.
- The sentences of invisible-playwright-mcp the browser matches are captured from the pinned package into
invisible_playwright_mcp.json, so a version bump that changes them fails a test.
- Fixed-sleep tests wait for their signal; architecture.md, UPSTREAM.md and main.py say what the code does.
- A task whose task.created could not be delivered is failed with a task.failed event in the same transaction
(the guest's type: a client follows one), so the web no longer shows it running for good; a commit point covers it.
- markDelivered marks the row and its run in one statement.
- add_mcp_server: the host uses up the approval that allowed the call (approvals.used_at, migration 0013), found in
one statement with jsonb equality instead of the newest 500 and a hand-made canonical JSON; a call refused for its
entry keeps its approval. The permission is typed.
- Dot DISABLED and VM IDLE, which nothing ever wrote, are gone from the states, the checks (migration 0014), the
queries and the web.
- Unused exports removed (latestId, activeForDot, hasPending, isVmState, isDotState, API_TOKEN_ENV, MIN_TOKEN_LENGTH).
- One BackgroundWork for the scheduler and the lifecycle; syncGuest marks a Dot suspect itself when its push fails.
- The scheduler alone checks a message, a note and "always"; /api/stream resolves its Dot with historyDotId.
- The MCP secrets of a push are read in one query; EventLog.stream keeps one id instead of every replayed one.
- The event schemas and the declared event types are held equal by the typecheck, so the parsers need no cast.
- start.test waits for the three requests instead of 200 ms; the pre-push hook says what only CI runs; docs follow.
.gitignore's *.log kept the data of top-ips (access.log) and log-pipeline (app.log) out of the repository, so a clone
had both tasks without their input; the stored output could also drift from author.py and author_long.py, and did
(hand edits, zlib's bytes). run.sh now writes tests/bench/tasks and tasks-long afresh before each run.
Step m queues a task that adds `clock` with add_mcp_server: the approval is under mcp.servers.add with the entry, and
once approved the server is in the config, with its permission left to ask, and connected. The engine smoke's tool
list for a Dot granted everything has add_mcp_server, and its check that a removed server's tools are gone looks at
the servers' tool names.
add_mcp_server's card lists the name, the command line (or the URL), the environment and headers written in it,
the secrets the person will set and the reason; the activity says what was added, or why it was not. "Add MCP
servers" is a row of the MCP servers group of the permissions.
On `mcp.server.added` the host checks, in the transaction that stores it: mcp.servers.add allows it, or an approved
approval of add_mcp_server has the same arguments (compared key by key, in any order); the name is free; the entry
passes the config's schema. Then it writes the server with its own permission left to ask, logs `dot.updated`, and
pushes the config; otherwise it logs `mcp.server.refused` with why.
The tool checks at once what it can (a name that is free in the stored config, a command or a URL, no user or
password in the URL) and writes `mcp.server.added` with the call's arguments; the host decides again and writes it.
Its permission is the table's mcp.servers.add, so the gate asks the person with the whole entry. Its description
says how to install a server's program first (uvx, `uv tool install git+https://...`, npx after Node.js) and that a
key is named in secrets, never written. The config check no longer reads mcp.servers.add as the permission of a
server named "servers.add", and the prompt says the Dot adds a server this way.
mcp.servers.add asks by default and is high risk; a server's name holds no `.`, so it is never a server's own
permission. `mcp.server.added {arguments}` is the guest's report of an add_mcp_server call that ran, and
`mcp.server.refused {name, reason}` the host's when it does not write the server. parseMcpServerEntry is the one
check of a server's entry, the config's own schema.