Merge pull request #1444 from feder-cr/engine-path-and-shell

The engine runs nothing of the model's by name, reads every sentence of its browser server, and the health is its own context
This commit is contained in:
Federico authored and GitHub committed 2026-10-11 23:31:39 +02:00
commit 6a9cc8c113
23 files changed
+98 -59

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
{
"//": "What each suite must reach on each host, read by .github/scripts/test-guard.mjs in CI (tests.yml) and in the pre-push hook. Raise a floor when tests are added; lowering one is a decision to write down in the commit that does it. Linux skips only the tests that drive a real Windows PowerShell; a Windows host compiles dot-agentd's unix-only test files out and skips the two Go tests that need a Linux guest. The playwright floor is the web client's browser tests (the web job of tests.yml; the same eighty-eight run on a Windows host, which is where they are developed). The pytest floor is the Python engine's (invisible_engine_dots, the engine job of tests.yml, Linux only, nothing may skip). The whatsapp floor is the suite that needs the real WhatsApp client, an opt-in install (npm run test:whatsapp, the whatsapp-client job of tests.yml, which installs the client and runs it with PostgreSQL, so the pg runs of the auth state count); a host without PostgreSQL reaches the win32 number.",
"vitest": {
"linux": { "min_passed": 2207, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
"win32": { "min_passed": 2204, "allow_skipped": "" }
"linux": { "min_passed": 2209, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
"win32": { "min_passed": 2206, "allow_skipped": "" }
},
"postgres": {
"linux": { "min_passed": 744, "allow_skipped": "", "require": { "\\bpg\\b": 295 } }
@@ -6,7 +6,7 @@ import type { ThemePreference } from "../../lib/theme";
import { useThemePreference } from "../../lib/use-theme";
import { cn } from "../../lib/utils";
import { ErrorAlert } from "../ErrorAlert";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { CheckAgainButton, CheckList, CheckListSkeleton } from "../setup/CheckList";
import { KeyForm } from "../setup/KeyForm";
import { Section } from "../setup/Section";
@@ -84,7 +84,7 @@ function Appearance() {
}
function About() {
const { health } = useShell();
const health = useHealth();
return (
<Section id="about" title="About" busy={health.loading && health.data === undefined}>
{health.error !== null && health.data === undefined ? <ErrorAlert error={health.error} title="The control plane does not answer" /> : null}
+2 -2
View File
@@ -7,7 +7,7 @@ import { api } from "../../lib/api";
import { keySavedMessage } from "../../lib/host-settings";
import { ErrorAlert } from "../ErrorAlert";
import { Field } from "../new-dot/Field";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useAction } from "../ui";
@@ -18,7 +18,7 @@ import { useAction } from "../ui";
* answer, and the answer to saving one says how many running Dots were given it at once.
*/
export function KeyForm() {
const { health } = useShell();
const health = useHealth();
const [value, setValue] = useState("");
const [problem, setProblem] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
@@ -2,7 +2,7 @@
import { useState } from "react";
import { isReady, notReadyCount } from "../../lib/preflight";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { CheckAgainButton, CheckList } from "./CheckList";
import { KeyForm } from "./KeyForm";
import { Section } from "./Section";
@@ -16,7 +16,7 @@ import { useHostChecks } from "./use-host-checks";
*/
export function SetupChecklist() {
const checks = useHostChecks();
const { health } = useShell();
const health = useHealth();
const [needed, setNeeded] = useState<{ key: boolean } | null>(null);
const items = checks.items;
const ready = items !== null && isReady(items);
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { api } from "../../lib/api";
import { isReady, preflightItems, type HealthResult, type HostResult, type PreflightItem } from "../../lib/preflight";
import { useResource } from "../ui";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
export interface HostChecks {
/** Null until both answers are in (or have failed). */
@@ -27,7 +27,7 @@ function reason(error: unknown): string {
* still not ready, the checks run again by themselves.
*/
export function useHostChecks(): HostChecks {
const { health } = useShell();
const health = useHealth();
const doctor = useResource(() => api.doctor(), "host:doctor");
const healthResult: HealthResult | null = health.error !== null ? { error: reason(health.error) } : health.data ? { health: health.data } : null;
+4 -4
View File
@@ -3,19 +3,19 @@
import { ServerIcon } from "lucide-react";
import Link from "next/link";
import { cn } from "../../lib/utils";
import { useShell } from "./attention";
import { useHealth } from "./attention";
import { StatusIcon } from "./StatusIcon";
type ApiState = "checking" | "ok" | "down";
function useApiState(): ApiState {
const { health } = useShell();
const health = useHealth();
return health.error !== null ? "down" : health.data ? "ok" : "checking";
}
/** Whether the control plane answers through the proxy, with its version (the shell asks every 30 seconds): an icon, said in full on hover. */
export function ApiStatus() {
const { health } = useShell();
const health = useHealth();
const state = useApiState();
const label = state === "checking" ? "API: checking" : state === "ok" ? "API: ok" : "API: unreachable";
const version = state === "ok" && health.data ? `v${health.data.version}` : null;
@@ -30,7 +30,7 @@ export function ApiStatus() {
/** While the control plane has no OpenRouter key: a link to where it is entered, on a line of its own under the statuses. */
export function MissingKeyNotice({ onNavigate }: { onNavigate?: () => void }) {
const { health } = useShell();
const health = useHealth();
const state = useApiState();
if (state !== "ok" || !health.data || health.data.openrouter_configured) return null;
return (
+16 -5
View File
@@ -26,8 +26,6 @@ const HEALTH_INTERVAL_MS = 30_000;
interface ShellData {
dots: Resource<Dot[]>;
approvals: Resource<Approval[]>;
/** The control plane's answer, asked every 30 seconds and again on `health.reload()` (after a key is saved). */
health: Resource<HealthResponse>;
attention: ReadonlyMap<string, DotAttention>;
/** What needs the person, across every Dot: waiting approvals, Dots in ERROR, failed tasks and channels to link again. The number on the rail's Inbox entry. */
needsYou: number;
@@ -42,6 +40,7 @@ interface ShellData {
}
const Context = createContext<ShellData | null>(null);
const HealthContext = createContext<Resource<HealthResponse> | null>(null);
/**
* What every page of the app shares: the Dots, the approvals that wait, the tasks that failed lately, the channels that
@@ -118,10 +117,22 @@ export function AttentionProvider({ children }: { children: ReactNode }) {
const dismiss = useCallback((dotId: string) => setLive((current) => dismissRestart(current, dotId)), []);
const value = useMemo<ShellData>(
() => ({ dots, approvals, health, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismissRestart: dismiss }),
[dots, approvals, health, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismiss],
() => ({ dots, approvals, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismissRestart: dismiss }),
[dots, approvals, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismiss],
);
return <Context.Provider value={value}>{children}</Context.Provider>;
// The health is a context of its own: its poll every 30 seconds re-renders what reads it, not every user of the shell.
return (
<HealthContext.Provider value={health}>
<Context.Provider value={value}>{children}</Context.Provider>
</HealthContext.Provider>
);
}
/** The control plane's answer, asked every 30 seconds and again on `reload()` (after a key is saved). */
export function useHealth(): Resource<HealthResponse> {
const value = useContext(HealthContext);
if (!value) throw new Error("useHealth must be used inside AttentionProvider");
return value;
}
export function useShell(): ShellData {
@@ -4,7 +4,7 @@ import { WifiOffIcon } from "lucide-react";
import { offlineOf } from "../../lib/offline";
import { useStreamStatus } from "../events";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { useShell } from "./attention";
import { useHealth } from "./attention";
/**
* Says so, at the top of every page, when the control plane does not answer: the live stream is down or the health
@@ -12,7 +12,7 @@ import { useShell } from "./attention";
*/
export function OfflineBanner() {
const stream = useStreamStatus();
const { health } = useShell();
const health = useHealth();
const offline = offlineOf({ stream: stream.status, streamDetail: stream.detail, apiError: health.error });
if (offline === null) return null;
return (
+2 -2
View File
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventStreamProvider } from "../src/components/events";
import { HostSettings } from "../src/components/settings/HostSettings";
import { AttentionProvider, useShell } from "../src/components/shell/attention";
import { AttentionProvider, useHealth } from "../src/components/shell/attention";
import { THEME_KEY } from "../src/lib/theme";
import { stubMatchMedia } from "./support/browser";
import { FakeControlPlane } from "./support/control-plane";
@@ -29,7 +29,7 @@ afterEach(() => {
/** What the shell's rail reads from the same health answer, to see that the page and the rail agree. */
function RailKey() {
const { health } = useShell();
const health = useHealth();
return <p data-testid="rail-key">{health.data ? String(health.data.openrouter_configured) : "unknown"}</p>;
}
+8 -3
View File
@@ -495,7 +495,11 @@ poweroff`, `/bin/bash`, `/usr/bin/import`): a `sudo` planted in dot's
`~/.local/bin` (dot owns its home and can open it) would otherwise be run as
`dotagentd` by the next poweroff. The `PATH` of the model's commands, dot's
`~/.local/bin` in front of the system's, is built by the daemon with the rest of
their environment (`execEnv`), not inherited from its own.
their environment (`execEnv`), not inherited from its own. The engine, which holds
the OpenRouter key, has the same rule: its unit's `PATH` is system directories
only, and the one program of dot's it needs, the browser's MCP server, is named by
its path (`INVISIBLE_DOTS_MCP_COMMAND=/home/dot/.local/bin/invisible-playwright-mcp`)
and started as dot through the relay.
What this closes: no process of the model's shares a uid with a daemon, so the
model can neither read the Dot's token nor read the memory of dot-agentd or of
@@ -1599,7 +1603,7 @@ once, in `main.py`: `INVISIBLE_DOTS_AGENT_SOCKET`,
`INVISIBLE_DOTS_AGENTD_SOCKET`, `INVISIBLE_DOTS_AGENTD_BIN`,
`INVISIBLE_DOTS_WORKSPACE`, `INVISIBLE_DOTS_ENGINE_STATE`,
`INVISIBLE_DOTS_NETWORK_CHECK` (the host the network check reaches),
`INVISIBLE_DOTS_MCP_COMMAND` (the browser server's command),
`INVISIBLE_DOTS_MCP_COMMAND` (the browser server's command; the unit names it by its path in dot's `~/.local/bin`),
`INVISIBLE_DOTS_MODEL_USER` (the user of the model's commands, whom the API
socket refuses), and for tests and the smoke `INVISIBLE_DOTS_OPENROUTER_URL`.
Before it serves it checks that no other engine owns the state.
@@ -1786,7 +1790,8 @@ Before it serves it checks that no other engine owns the state.
`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
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. The manager hears of the end
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
when it happens, from the client's transport, so a process that dies while idle
is closed at once, frees its slot of `max_open`, and the next action says
`not_open`; a file never claims an open browser for a process that is gone. What a page
@@ -7,8 +7,8 @@
#
# build-browser-env.sh <lock> <environment directory>
#
# The command is linked into ~dot/.local/bin, which the units put on the PATH of dot-agentd and
# of the engine.
# 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 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
@@ -5,9 +5,10 @@ schemas, and puts the server's instructions in its prompt, as an MCP host does (
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 any tool can answer with instead of
working (the browser is not open, the browser is 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.
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:
@@ -24,7 +25,7 @@ import os
import sys
from pathlib import Path
from invisible_playwright_mcp.mcp import DEFAULT_BROWSER_ID, GONE, NOT_OPEN
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
@@ -32,7 +33,7 @@ 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 a tool answers instead of working (the package's NOT_OPEN and GONE, %s being the browser). Written by "
"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."
)
@@ -49,7 +50,7 @@ async def capture() -> dict:
"version": started.serverInfo.version,
"instructions": started.instructions,
"browser": DEFAULT_BROWSER_ID,
"answers": {"not_open": NOT_OPEN, "gone": GONE},
"answers": {"opened": OPENED, "not_open": NOT_OPEN, "gone": GONE},
"tools": [
{
"name": tool.name,
@@ -469,9 +469,9 @@ invisible-core==39.35.0 \
invisible-playwright==0.32.0 \
--hash=sha256:9c9884015bda2e4a197a3247728d4b1df461cbbea1c9debc2431af126d60417b \
--hash=sha256:d83bf71f4918275107b80c21419a26d6b56df2183befae91cc19119a26590d09
invisible-playwright-mcp==0.71.1 \
--hash=sha256:8847784c83cb4b982a442181cc3489aaebb0a04739f511c26bdeaf649d32df93 \
--hash=sha256:9c5cf6fe3a739839cb81525968b29b7e486d48da0a2532a43825cb590254fcb2
invisible-playwright-mcp==0.71.2 \
--hash=sha256:68176769fb05d75f6ca851523ff829032806037d78c1c4597cd938fadb3ce26a \
--hash=sha256:db2cc9b46be13589457ef7b2b2081fc92a2a82e6f9211f84da71551725722317
jiter==0.17.0 \
--hash=sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245 \
--hash=sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5 \
+1 -1
View File
@@ -132,7 +132,7 @@ describe("buildGoldenImage", () => {
base: { sha256: sha256(BASE), serial: "20260926" },
pinned: {
uv: { version: "0.12.22", sha256: sha256(UV) },
"invisible-playwright-mcp": "0.71.1",
"invisible-playwright-mcp": "0.71.2",
"invisible-playwright": "0.32.0",
apt_snapshot: "20261010T000000Z",
apt_packages: ["xvfb", "imagemagick"],
+12 -2
View File
@@ -235,16 +235,26 @@ describe("guest units", () => {
return lines[0]!.slice("Environment=PATH=".length).split(":");
};
it.each(GUEST_UNITS.filter((name) => name !== "dot-agentd.service"))("%s runs with the display and uv's bin dir, as its own user", (name) => {
it.each(GUEST_UNITS.filter((name) => name !== "dot-agentd.service"))("%s runs with the display, as its own user", (name) => {
const unit = text(unitAsset(name));
expect(unit).toMatch(new RegExp(`^User=${UNIT_USER[name]}$`, "m"));
expect(unit).toMatch(/^Environment=DISPLAY=:0$/m);
expect(unitPath(name)[0]).toBe("/home/dot/.local/bin");
expect(unit).toMatch(/^RequiresMountsFor=\/opt\/invisible-dots$/m);
expect(unit).toMatch(/^Restart=on-failure$/m);
expect(unit).toMatch(/^WantedBy=multi-user\.target$/m);
});
it("runs the desktop, which is dot's, with uv's bin dir first", () => {
expect(unitPath("dot-desktop.service")[0]).toBe("/home/dot/.local/bin");
});
it("runs the engine, which holds the key, with a PATH of system directories only, and names the browser's server by its path", () => {
// Nothing the engine runs by name may come from a directory dot (the model) can write. The browser's MCP server
// is dot's (build-browser-env.sh links it into ~dot/.local/bin): the unit names it, and the engine starts it as dot.
for (const dir of unitPath("invisible-dots-agent.service")) expect(dir, `PATH entry ${dir}`).toMatch(/^\/(usr\/local\/|usr\/)?s?bin$/);
expect(text(unitAsset("invisible-dots-agent.service"))).toMatch(/^Environment=INVISIBLE_DOTS_MCP_COMMAND=\/home\/dot\/\.local\/bin\/invisible-playwright-mcp$/m);
});
it("runs dot-agentd with the display, as its own user, and a PATH of system directories only", () => {
const unit = text(unitAsset("dot-agentd.service"));
expect(unit).toMatch(/^User=dotagentd$/m);
@@ -29,7 +29,7 @@ start_fake_openrouter
DOT_UID=$(id -u dot)
install -d -o dot -g dot -m 0700 "/run/user/$DOT_UID"
install -D -m 0755 "$RUNTIME/dot-desktop.sh" /opt/invisible-dots/bin/dot-desktop
su -s /bin/bash dot -c "HOME=/home/dot DISPLAY=:0 DOT_SCREEN=1920x1080x24 PATH=$GUEST_PATH /opt/invisible-dots/bin/dot-desktop" > /tmp/desktop.log 2>&1 &
su -s /bin/bash dot -c "HOME=/home/dot DISPLAY=:0 DOT_SCREEN=1920x1080x24 PATH=$DOT_PATH /opt/invisible-dots/bin/dot-desktop" > /tmp/desktop.log 2>&1 &
wait_display() { for _ in $(seq 1 60); do [ -S /tmp/.X11-unix/X0 ] && pgrep -u dot -x xfwm4 >/dev/null && return 0; sleep 1; done; return 1; }
check "the desktop is up: Xvfb on :0 and an XFCE session with its window manager, all dot's" "wait_display && pgrep -u dot -x Xvfb >/dev/null && ! pgrep -u root -x Xvfb >/dev/null"
@@ -83,7 +83,7 @@ unset MCP_COMMAND
start_guest_daemons
init_host_side
check "the engine answers /health through dot-agentd" "wait_health"
check "invisible-playwright-mcp is on dot's PATH, where the golden image links it" "[ \"\$(su -s /bin/bash dot -c 'PATH=$GUEST_PATH command -v invisible-playwright-mcp')\" = /home/dot/.local/bin/invisible-playwright-mcp ]"
check "invisible-playwright-mcp is on dot's PATH, where the golden image links it" "[ \"\$(su -s /bin/bash dot -c 'PATH=$DOT_PATH command -v invisible-playwright-mcp')\" = /home/dot/.local/bin/invisible-playwright-mcp ]"
reset_config
echo '{"computer.exec":"allow","computer.screenshot":"allow","browser.identity.list":"allow","browser.identity.create":"allow","browser.identity.delete":"allow","browser.identity.launch":"allow","browser.identity.close":"allow","browser.navigate":"allow","browser.read":"allow","browser.act":"allow"}' > /tmp/perms.json
write_push_script
+10 -5
View File
@@ -18,8 +18,13 @@ ENGINE_PY=/opt/invisible-dots-engine/bin/python
# The key has this one owner; the stand-in gets it on its command line (not its environment: a check
# asserts that no process environment holds it) and refuses to start without it.
KEY=sk-or-v1-smoke-0123456789abcdef
# What the units give the engine and the desktop: the display and uv's bin dir (invisible-dots-agent.service).
GUEST_PATH=/home/dot/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# What the engine's unit gives it, read from the unit itself: a PATH of system directories, and the browser's MCP
# server named by its path (dot's, in ~/.local/bin).
ENGINE_UNIT="$SMOKE_DIR/../../units/invisible-dots-agent.service"
ENGINE_PATH=$(sed -n 's/^Environment=PATH=//p' "$ENGINE_UNIT")
ENGINE_MCP_COMMAND=$(sed -n 's/^Environment=INVISIBLE_DOTS_MCP_COMMAND=//p' "$ENGINE_UNIT")
# What the desktop's unit gives dot: uv's bin dir in front of the system's, read from the unit itself.
DOT_PATH=$(sed -n 's/^Environment=PATH=//p' "$SMOKE_DIR/../../units/dot-desktop.service")
# What the unit gives dot-agentd, read from the unit itself so the smoke starts the daemon with the PATH the
# image has: system directories only, none of them dot's (a program the daemon starts as itself, the poweroff,
# is found through it).
@@ -82,7 +87,7 @@ start_fake_openrouter() {
# engine as dotengine, restarted by
# start_engine when it dies as systemd would (KillMode=control-group: every process of the engine goes
# with it). The engine's environment is its unit's, plus the program it runs for a browser when
# MCP_COMMAND names one (without it the engine finds invisible-playwright-mcp on its PATH).
# MCP_COMMAND names one (without it, the unit's: the real server in dot's ~/.local/bin).
start_guest_daemons() {
setpriv --reuid=dotagentd --regid=dotagentd --init-groups \
--inh-caps=+setuid,+setgid,+kill --ambient-caps=+setuid,+setgid,+kill \
@@ -90,14 +95,14 @@ start_guest_daemons() {
/opt/invisible-dots/bin/dot-agentd --listen 127.0.0.1:1024 > /tmp/agentd.log 2>&1 &
cat > /tmp/engine.sh <<EOF
export HOME=/home/dotengine
export PATH=$GUEST_PATH
export PATH=$ENGINE_PATH
export DISPLAY=:0
export TIKTOKEN_CACHE_DIR=/opt/invisible-dots-engine/share/tiktoken
export INVISIBLE_DOTS_ENGINE_STATE=/home/dotengine/state
export INVISIBLE_DOTS_AGENT_SOCKET=/run/invisible-dots-agent/agent.sock INVISIBLE_DOTS_AGENTD_SOCKET=/run/invisible-dots/agentd.sock
export INVISIBLE_DOTS_AGENTD_BIN=/opt/invisible-dots/bin/dot-agentd INVISIBLE_DOTS_WORKSPACE=/home/dot/workspace
export INVISIBLE_DOTS_OPENROUTER_URL=http://127.0.0.1:9999/api/v1 INVISIBLE_DOTS_NETWORK_CHECK=127.0.0.1:9999
${MCP_COMMAND:+export INVISIBLE_DOTS_MCP_COMMAND=$MCP_COMMAND}
export INVISIBLE_DOTS_MCP_COMMAND=${MCP_COMMAND:-$ENGINE_MCP_COMMAND}
umask 0002
cd /home/dotengine
exec /opt/invisible-dots-engine/bin/python -I -B -m nanobot
@@ -17,7 +17,11 @@ UMask=0002
WorkingDirectory=/home/dotengine
Environment=HOME=/home/dotengine
Environment=DISPLAY=:0
Environment=PATH=/home/dot/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# System directories only: the engine holds the OpenRouter key, and nothing it runs by name may come from a
# directory dot (the model) can write. The one program of dot's it needs, the browser's MCP server, is named by
# its path; the engine starts it as dot, through dot-agentd's relay.
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=INVISIBLE_DOTS_MCP_COMMAND=/home/dot/.local/bin/invisible-playwright-mcp
# The tokenizer's table, fetched when the golden image was built: the engine never fetches it.
Environment=TIKTOKEN_CACHE_DIR=/opt/invisible-dots-engine/share/tiktoken
Environment=INVISIBLE_DOTS_ENGINE_STATE=/home/dotengine/state
@@ -68,10 +68,10 @@ SERVER_NAME = "browser"
# 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` answers when the browser started. Anything else that is not an error is the engine's download
# progress, and it is asked again. The library has no constant for this answer (its `Work.open` writes it), so it is
# the one sentence of the server matched here by its words.
_OPENED = re.compile(r"\bbrowser is open\b", re.IGNORECASE)
# 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).
@@ -703,7 +703,7 @@ class BrowserManager:
)
if result_is_error(result):
raise BrowserIdentityError("launch_failed", f'browser_open failed for identity "{identity_id}": {text}')
if _OPENED.search(text):
if text.startswith(_OPENED):
return
if loop.time() + wait > deadline:
raise BrowserIdentityError(
@@ -1,10 +1,11 @@
{
"//": "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 a tool answers instead of working (the package's 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.",
"//": "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.",
"package": "invisible-playwright-mcp",
"version": "0.71.1",
"version": "0.71.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.",
"browser": "main",
"answers": {
"opened": "the %s browser is open.",
"not_open": "the %s browser is not open. Call browser_open to open it.",
"gone": "the %s browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person."
},
@@ -41,12 +41,14 @@ def test_it_was_captured_from_a_server_started_as_the_engine_starts_it() -> None
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`): the capture records them
from the package's own constants, so a version that rewords them changes this file and the engine with it."""
"""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:
@@ -144,7 +144,7 @@ async def _serve() -> None:
)
state["open"] = True
remember(role)
return text(f"the {role} browser is open. seed: remembered by the profile")
return text(interface["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)
+1 -1
View File
@@ -778,7 +778,7 @@ async function main(): Promise<void> {
// research open, as it would be left at night
const relaunch = async (what: string): Promise<void> => {
const task = await runTask(
`With the existing browser identity whose id is ${research.id}: open its browser with browser_identity_launch if it is not open, open https://example.com in it with browser_navigate, and answer with only the page title. Do not create a new identity. (${what})`,
`With the existing browser identity whose id is ${research.id}: open its browser with browser_identity_launch if it is not open, open https://example.com in it with browser_navigate, and answer with only the page title. Do not create a new identity, and leave its browser open when you are done. (${what})`,
);
const all = await events(dotId);
assert(toolOk(all, task.id, "browser_navigate"), `the ${what} task did not navigate (tools: ${describeTools(all, task.id)})`);