mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
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:
23 files changed
+98
-59
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)})`);
|
||||
|
||||
Reference in new issue
Block a user