Compare commits

...
417 Commits
Author SHA1 Message Date
feder-cr 0e502db1c0 dot-browser: a link to nothing is no browser, PATH checked where the image is made, failures say what they did
The check that dot's PATH reaches the server is the image's: it runs at install, not after a switch has already moved
the link, so install.sh's message on a failed switch is true (the browser is either the one the Dot had or the staged
one). A link whose environment is gone is no current browser. newest's awk reads every line instead of exiting early
under pipefail, and the browser smoke's preparation fails at the resolution that failed.
2026-10-15 04:27:16 +02:00
feder-cr c16e144f89 browser: every Dot follows the newest release, and install.sh alone starts the Dot's units
A Dot kept the browser of the golden image it was made from. runtime/dot-browser.sh (the old build-browser-env.sh,
now on the runtime disk as well as the builder seed) is the one owner of where the Dot's browser lives: each release
in an environment of its own, browser/<mcp>-<playwright>-<core>, and the Dot's browser the one
~dot/.local/bin/invisible-playwright-mcp links to. At every start install.sh switches that link to a release an
earlier start staged, in one rename and before the Dot's units, and after them stages the newest release as uv
resolves it, in the background: its environment and its browser engine, through the Dot's own way out. A start never
waits for a release; without the network the Dot keeps its browser. provision.sh installs the golden image's browser
with the same script.

The switch has to come before the engine, and that ordering did not hold: the units were enabled, so on every boot
after the first systemd started them at multi-user.target, before cloud-init's final stage ran install.sh
(cloud-final.service is After=multi-user.target; a Dot's serial log shows dot-agentd and the engine up two seconds
before install.sh). A Dot with a VM proxy went out before its proxy was set. The units are no longer enabled:
install.sh starts them, after the proxy and the switch, and removes the links an earlier runtime made.
2026-10-15 04:22:28 +02:00
Federico Elia ef69f5e19c 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
2026-10-14 18:38:56 -07:00
feder-cr ed406afb14 smoke and dependencies: the stand-in is a server package, and the capture runs on the server's python
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.
2026-10-15 02:18:52 +02:00
feder-cr 2968ca8372 browser: never pinned; the image takes the newest release and the engine reads the server it finds
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.
2026-10-15 02:04:31 +02:00
Federico 4d20e947a1 Merge pull request #1450 from feder-cr/libs-0722
image: the browser is invisible-playwright-mcp 0.72.2 (invisible-playwright 0.35.7, invisible-core 45.39.0, firefox-45)
2026-10-13 14:33:32 -07:00
feder-cr 6712104ea5 image: the browser is invisible-playwright-mcp 0.72.2 (invisible-playwright 0.35.7, invisible-core 45.39.0, firefox-45)
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.
2026-10-13 22:25:33 +02:00
Federico a2584f2619 Merge pull request #1448 from feder-cr/no-char-caps
engine: no fixed cap on a tool's result; the cut and the routing follow the model's window, read from OpenRouter on every turn
2026-10-12 08:35:49 -07:00
feder-cr fe03ee6325 floors: the engine suite at the 2493 tests CI measured on this branch 2026-10-12 17:20:59 +02:00
feder-cr 5c920517e4 engine: a model's requests go only to the providers of its widest window; image: invisible-playwright-mcp 0.72.1
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.
2026-10-12 16:35:10 +02:00
feder-cr 440e43a1f5 engine: no fixed cap on a tool's result; the cut is the model's window, read from OpenRouter on every turn
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.
2026-10-12 12:43:40 +02:00
Federico 00f63e88c1 Merge pull request #1447 from feder-cr/browser-libs
image: the browser is invisible-playwright-mcp 0.72.0 (invisible-playwright 0.35.0, invisible-core 41.36.0)
2026-10-12 07:53:06 +02:00
feder-cr 9610d12e29 image: the browser is invisible-playwright-mcp 0.72.0 (invisible-playwright 0.35.0, invisible-core 41.36.0)
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.
2026-10-12 07:27:49 +02:00
Federico 81eb3ba067 Merge pull request #1446 from feder-cr/hero-from-photos
docs: the README animation is built from photos in the tree, with new characters
2026-10-12 03:01:05 +02:00
feder-cr 9151ce3386 docs: the README animation is built from photos in the tree, with new characters
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.
2026-10-12 02:45:30 +02:00
Federico 96eee2ba8c Merge pull request #1445 from feder-cr/telegram-429-test
tests: the 429 poll test has a bot of its own, and the API test removes the channels it adds
2026-10-12 00:11:32 +02:00
feder-cr fc470314f2 api tests: the test of every Dot's channels removes the channels it added
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.
2026-10-12 00:01:03 +02:00
feder-cr 361b770285 channels tests: the 429 poll test has a bot of its own
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.
2026-10-11 23:45:40 +02:00
Federico 6a9cc8c113 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
2026-10-11 23:31:39 +02:00
feder-cr fb3b667c01 ci: the vitest floors are what this branch's CI measured: 2209 on Linux and 2206 on Windows 2026-10-11 23:17:59 +02:00
feder-cr c5d48ac017 smoke: the browser smoke takes dot's PATH from the desktop's unit
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.
2026-10-11 23:03:02 +02:00
feder-cr b82ca890ff e2e: step j asks the model to leave the browser open, which the step goes on to need
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.
2026-10-11 22:58:37 +02:00
feder-cr 780fb3b46a engine: a browser opened is known by invisible-playwright-mcp's OPENED, captured like its other sentences
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.
2026-10-11 22:43:34 +02:00
feder-cr 3d0da4e090 web: the health answer is a context of its own
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.
2026-10-11 19:29:55 +02:00
feder-cr bd1b5866cc guest: the engine runs nothing by name from a directory the model writes
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.
2026-10-11 19:29:54 +02:00
Federico 1935f47e5a Merge pull request #1443 from feder-cr/fixes-batch
Fixes, removals and one owner per fact, from a read of the whole repository
2026-10-11 19:02:23 +02:00
feder-cr 08d6786421 ci: the floors are what this branch's CI measured: vitest 2207 on Linux and 2204 on Windows, PostgreSQL 744 with 295 pg runs 2026-10-11 18:54:30 +02:00
feder-cr b50f09ed40 web tests: the fake control plane answers the two routes of every Dot, and the inbox says what one read cannot
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.
2026-10-11 18:39:45 +02:00
feder-cr 9ee1f32094 e2e: the journal check reads the journal as the Dot's user, without asking the model
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.
2026-10-11 18:20:38 +02:00
feder-cr 373d837e29 e2e: step l checks that the interrupted command is not run again, not that the model runs nothing else
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.
2026-10-11 18:12:04 +02:00
feder-cr 2e762e77d5 ci: the pytest floor is 2483, what the engine suite measures without the code no Dot runs
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.
2026-10-11 17:58:03 +02:00
feder-cr 528be98890 vm-manager, api: one logger and one pathExists, from shared
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.
2026-10-11 17:51:54 +02:00
feder-cr ac26cec1bf Merge branch 'guest-fixes' into fixes-batch 2026-10-11 17:50:35 +02:00
feder-cr 5316d43308 guest: dot-install only installs, the images are what their inputs say, and the Go copies of the protocol are held
- 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.
2026-10-11 17:50:35 +02:00
feder-cr 5a65ada65d api, web: the shell reads the failed tasks and the channels of every Dot in one request each
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.
2026-10-11 17:49:03 +02:00
feder-cr b0514eac62 host: the pieces every package uses live in shared, so a package never depends on an app
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.
2026-10-11 17:44:28 +02:00
feder-cr c8cb603941 Merge branch 'clients-fixes' into fixes-batch 2026-10-11 17:38:05 +02:00
feder-cr 825740351d Merge branch 'engine-lean' into fixes-batch 2026-10-11 17:38:05 +02:00
feder-cr 4e376c1927 clients: one wording for an approval everywhere, the CLI reads what it shows and can stop what it starts
- 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.
2026-10-11 17:37:54 +02:00
feder-cr 1cbf4609d8 engine: a call written in the text runs nothing, a call runs once, and the code no Dot runs is gone
- 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.
2026-10-11 17:34:00 +02:00
feder-cr 8c6f3cb010 host: every task end is told, an approval adds one server once, and no state nobody sets
- 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.
2026-10-11 17:32:20 +02:00
feder-cr 61e6dc21b4 bench: the tasks are written by their generators before every run, never stored
.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.
2026-10-11 17:32:09 +02:00
Federico 21500338a3 Merge pull request #1442 from feder-cr/dot-mcp-add
A Dot adds an MCP server itself, with the person's approval
2026-10-11 11:16:27 +02:00
feder-cr 0060eee57a ci: the floors are what this branch's CI measured with add_mcp_server's tests: vitest 2148 on Linux and 2151 on Windows, PostgreSQL 738 with 293 pg runs, pytest 2553 2026-10-11 10:59:24 +02:00
feder-cr 0c51c42c60 tests: the real-VM run has the Dot add an MCP server itself and the person approve it; the smoke offers add_mcp_server
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.
2026-10-11 10:44:27 +02:00
feder-cr e03edb06d4 docs: a Dot adds an MCP server itself, with the person's approval, and how the agent-hosts compare
Sections 5.4, 7 and 8.3, the guide, and the wiki article, with what NanoClaw, Hermes, OpenClaw, nanobot and Goose let
an agent do, from their source.
2026-10-11 10:44:27 +02:00
feder-cr 4f4cbca2f4 web: the approval of an MCP server the Dot adds shows what it runs, the secrets you will set and why
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.
2026-10-11 10:44:27 +02:00
feder-cr bd3cf77274 host: a server the Dot adds is written only when allowed or approved for exactly these arguments
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.
2026-10-11 10:44:27 +02:00
feder-cr a33c706731 engine: add_mcp_server, a Dot adds an MCP server to its own config, as NanoClaw's agents do
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.
2026-10-11 10:44:27 +02:00
feder-cr 7067f0357b shared: the permission to add an MCP server, mcp.servers.add, and the events of a server the Dot adds
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.
2026-10-11 10:44:26 +02:00
Federico 2c803ef5b5 Merge pull request #1441 from feder-cr/dot-mcp-servers
A Dot uses the MCP servers its person declares
2026-10-11 05:49:34 +02:00
feder-cr 72cd7d4df6 ci: the floors are what this branch's CI measured with the MCP servers' tests: vitest 2143 on Linux and 2146 on Windows, PostgreSQL 732 with 290 pg runs, pytest 2535, Playwright 88 2026-10-11 05:40:51 +02:00
feder-cr 33d82c57d7 Merge remote-tracking branch 'origin/main' into dot-mcp-servers 2026-10-11 05:33:26 +02:00
Federico 440f6837d7 Merge pull request #1440 from feder-cr/browser-host-mcp
The page tools are invisible-playwright-mcp's own, and its instructions are in the prompt
2026-10-11 05:33:21 +02:00
feder-cr 7e103cc636 Merge remote-tracking branch 'origin/browser-host-mcp' into dot-mcp-servers 2026-10-11 05:29:09 +02:00
feder-cr c5c8c07e66 ci: the pytest floor is 2507, what this branch's CI measured: the page tools are the server's now, so the 88 tests of the engine's own (its 16-tool fixture, browser_scroll, browser_screenshot, the browser argument) gave way to 84 that hold them to the server's capture, with the cases of browser_evaluate, read_html, take_screenshot and upload_files 2026-10-11 05:27:15 +02:00
feder-cr 803d03b0ac wiki: the 30-second Browser.newPage bound is reported as invisible_playwright#321 2026-10-11 05:21:22 +02:00
feder-cr 441c96d4e0 Merge remote-tracking branch 'origin/browser-host-mcp' into dot-mcp-servers 2026-10-11 05:20:27 +02:00
feder-cr 7378d76e19 Merge remote-tracking branch 'origin/main' into browser-host-mcp 2026-10-11 05:18:37 +02:00
Federico ba6227765c Merge pull request #1439 from feder-cr/browser-audit
Browser audit: back, forward and reload removed, and the calls that lead nowhere by cause
2026-10-11 05:18:23 +02:00
feder-cr a6c727a5f1 web test: the Tasks page waits for a new task by the list's length, not by a page-wide text query over two hundred cards that used up waitFor's second on a loaded machine 2026-10-11 05:14:52 +02:00
feder-cr cd7e36f190 ci: the pytest floor is 2511, what this branch's CI measured: the 33 tests that left are the cases of browser_back, browser_forward and browser_reload (24 of the approval matrix, 9 of the page tools), gone with the tools 2026-10-11 05:12:15 +02:00
feder-cr a4472f0394 wiki: MCP servers a person declares, how other hosts do it, and a live Dot that installed a server's program and used it 2026-10-11 04:47:39 +02:00
feder-cr a0c5aefb0e guide: the acceptance run passes its 16 steps, the MCP servers' step with them 2026-10-11 04:43:30 +02:00
feder-cr f542349ce7 smoke: the MCP server's secret compared by hash, per server process, never printed 2026-10-11 04:39:08 +02:00
feder-cr 46ba8330c8 smoke: the MCP server's user, environment and command lines in three checks, each saying what it found 2026-10-11 04:33:54 +02:00
feder-cr 97fea69aee smoke and e2e: look for the MCP secret on the command lines with a pattern that does not match the grep's own 2026-10-11 04:28:31 +02:00
feder-cr 4d5e59fd0d mcp servers: a start bound of their own, startup_timeout_s, and one that timed out holds up no turn after
A server that never answers would be started again at every turn, and every message would wait out the bound for
it. As Codex has `startup_timeout_sec` beside `tool_timeout_sec`, a server's entry has `startup_timeout_s` (1 to 600,
60 by default, MCP_STARTUP_TIMEOUT_BOUNDS) beside `timeout_s`; one that does not start within it is not started again
until its entry or its secrets change, and its error says so. The fork's client tests fake the SDK's stdio_client
and connect_mcp_servers with their real parameters (errlog, the failures and errlogs sinks).
2026-10-11 04:23:32 +02:00
feder-cr 6875851a43 smoke: the MCP checks read the server's environment as dot, give the missing server a permission so the prompt says why, and group the jq test 2026-10-11 04:10:19 +02:00
feder-cr c0cbb552db web e2e: an MCP server added in the settings through the real host, its secret pushed to the guest and never on the page 2026-10-11 04:09:31 +02:00
feder-cr e6ebfc79f8 tests: the MCP servers in the engine smoke and in the real-VM run, and the e2e contract that knows them
The engine smoke declares the engine's fake MCP server and a program that is not installed: through the image's
relay the first connects as dot with its secret in its environment and on no command line, its tool runs under its
permission and its instructions reach the prompt, the second fails saying what the relay found, and a config without
them stops the server. The real-VM run's new step m runs mcp-server-time through uvx: its tool asks, is approved
always and runs as dot; a second server waits for its secret, set through the CLI. Steps n and p look for that secret
as for the key.
2026-10-11 04:02:57 +02:00
feder-cr 2afd73b0fe docs: MCP servers in the architecture and the guide; no longer out of scope
Section 7 declares them and their permission, 8.3 says where they run, when they start and what the model gets, 4.3
how their secrets travel, 5.3 and 9.6 the routes, 8.8 the engine's manager. The guide has a section on them, the two
commands, and the README one row.
2026-10-11 04:02:57 +02:00
feder-cr 71bdf9db85 web: an MCP servers panel in a Dot's settings, and each server's permission among the others
The panel lists each declared server with where it is on the computer and why one is not connected, adds, edits and
removes one in the draft like any setting, and sets or clears its secrets write-only once the config that names them
is saved. Each server is a row of Permissions and tools, asked by default and high risk; a preset leaves the
servers' permissions alone, the review lists a server's change by what it runs, a change put on top of a newer config
keeps both sides' servers, and an approval of a server's tool is the kind to stop and think about.
2026-10-11 04:02:57 +02:00
feder-cr a60ee568e3 host: MCP secrets are set write-only, pushed with the key, and none outlives the config that names it
A secret is stored encrypted under the Dot's scope as mcp/<server>/<NAME>, set and cleared through
PUT and DELETE /api/dots/:id/mcp-secrets/:server/:name (the SDK, `invisible-dots secret mcp`), listed without its
value, and pushed with the key on every READY and agent.started. A config that drops a server or a name drops its
value in the same transaction. `GET /api/dots/:id/tools` answers the engine's whole answer, the servers' states with
the tools, and `invisible-dots mcp <dot>` shows them. Always allowing the permission of a server the config no
longer declares is refused (409 permission_gone).
2026-10-11 04:02:56 +02:00
feder-cr 2b354e2a29 engine: the MCP servers a person declares, started as dot through the relay, their tools behind their permission
nanobot/dots/mcp_servers.py keeps one MCPProvider per declared server, as the BrowserManager keeps one per identity.
A command runs through dot-agentd relay as dot, its secrets by the relay's environment (--env-from); a URL is reached
with its secrets as headers. A server starts when declared, restarts alone when its entry or a secret changes, and
one that failed starts again when the next turn begins, which waits for those starting (60 s each at most). Its
tools join the registry as mcp_<server>_<tool> under mcp.<server>; the prompt carries each server's instructions, or
why it is not connected, with what its stderr said and its secrets masked. The fork's client keeps a server's
initialize instructions, says why a connection failed and takes a stdio server's stderr (UPSTREAM.md). A config
stored by a release without servers declares none.
2026-10-11 04:02:56 +02:00
feder-cr 05bd036027 shared: a Dot's config declares MCP servers, each with its own permission and secrets kept apart
`mcp_servers` takes the entries Claude Code, Codex and nanobot take: a command with its args and env, or a URL with
its headers, plus `secrets`, the names of the variables or headers whose values are set apart from the config, and
`timeout_s`. Each server has a permission, `mcp.<server>`, that asks by default; one of a server the config does not
declare is refused, and denied. The engine's tool table, `approval.requested` and `GET /tools` name such a
permission, and `GET /tools` says where each server is (connecting, connected, failed and why). `POST /secrets`
carries the servers' secrets with the key; a secret's value is one rule, MCP_SECRET_PATTERN, as the key's is. Servers
are listed by name, since the database keeps a config as jsonb.
2026-10-11 04:02:56 +02:00
feder-cr 6791bd1e34 wiki: browser_evaluate measured on 0.71.1, on the same four tasks as on 0.71.0 2026-10-11 03:14:26 +02:00
feder-cr 5c9d964423 image: invisible-playwright-mcp 0.71.1, whose browser_evaluate says it comes last and names the tool for each read
In the four verification tasks on 0.71.0 browser_evaluate was the call the model made most (14 of 72), mostly to
read text, a heading or a link a reading tool returns. 0.71.1 changes only that tool's description and the rung of
the server's instructions that names it: last, and which tool reads text, markup or a control instead.
mcp-requirements.lock is regenerated with the command in its header, and nanobot/dots/invisible_playwright_mcp.json
captured again from the package installed from PyPI, so the engine offers the published wording.
2026-10-11 03:03:43 +02:00
feder-cr 26ed9228e7 smoke: the engine smoke's browser checks for a server started in host mode: no browser argument on any call, the reading tools the server has, and browser_take_screenshot under its own name 2026-10-10 23:07:36 +02:00
feder-cr 540f21b6c5 image: the engine's .json data travels with its source, so the browser server's capture reaches the runtime disk
The engine reads nanobot/dots/invisible_playwright_mcp.json at start, and the runtime disk, the wheel and the smoke's
staging took only the .py files and the .md templates: the browser smoke's engine stopped at import with no such
file. The three readers of what the package holds (pyproject's include list, runtime.ts, prepare-engine.sh) now
take the package's .json too, and runtime.test.ts holds a .json of the package on the disk and the capture among
the real engine's files.
2026-10-10 23:07:35 +02:00
feder-cr fc07373f9d image: invisible-playwright-mcp 0.71.0 and invisible-playwright 0.32.0, the versions that serve a host its page rules and main alone, with the engine's capture taken again from the published package
0.71.0 is the release the engine's page tools were built for (INVISIBLE_MCP_HOST_MANAGED, the page rules, a read
that resolves its selector through the engine); it requires invisible-playwright 0.32.0, which brings
invisible-core 39.35.0. mcp-requirements.lock is regenerated with the command in its header for both, and
nanobot/dots/invisible_playwright_mcp.json is captured again from the package installed from PyPI: only its
version changed from the capture taken from the repository, so the published instructions and tools are the ones
the engine was tested against, and the test that holds the capture to the lock passes. golden.test.ts builds from
the lock and names the new pins.
2026-10-10 23:07:35 +02:00
feder-cr 1d6827065b wiki: the browser server's own tools and words, how other hosts carry a server's tools and instructions, the host mode a Dot needs, and what it costs
The audit article said the consent rule was in the skill and that browser_read_text takes CSS; both moved to the
server (its page rules, and a read that resolves its selector through the engine), and the article says so and
links the new one.
2026-10-10 23:07:35 +02:00
feder-cr f9a21766ad engine: the page tools are invisible-playwright-mcp's own, offered as an MCP host offers a server's tools, and its instructions are in the prompt; the skill keeps what only a Dot knows, the identities
The engine rewrote every page tool of the browser server under names and descriptions of its own, and its skill
copied the server's instructions (the order to try things in, never act by script, say when a task is impossible),
then gained three rules of its own from the audit of the browser calls, all true of any page. Two copies of a rule
drift, and the copy the model read was not the one the server's authors keep. Claude Code and opencode offer a
server's tools with the server's descriptions and put its instructions in the system prompt; nanobot, the engine's
base, offers the descriptions and drops the instructions.

The server could not be passed as it was: it serves one person and a helper, `main` and `support`, and tells its
model to open them with browser_open, which a Dot's model does not have. invisible-playwright-mcp 0.71.0 reads
INVISIBLE_MCP_HOST_MANAGED=1, under which it serves `main` alone, no tool takes `browser`, and its instructions are
the page rules. The BrowserManager starts it that way (BROWSER_ENV["HOST_MANAGED"], in the shared table too) and
stops adding `browser: "main"` to every call; it still opens and closes the browser with browser_open and
browser_close, which a first launch (the engine's download, answered in steps) and a close (Firefox writing its
profile before the client ends the process) need.

The page tools are now built from nanobot/dots/invisible_playwright_mcp.json, the server's instructions and
tools/list as guest/image-builder/builder/capture-mcp-interface.py captures them from the real server started that
way: the server's name, description and input schema (normalized as nanobot's MCP client does), read-only from its
annotation, plus the identity_id of the open identity, described once on that argument. The permission table says
which the model is offered: browser_read_html, browser_evaluate and browser_upload_files come in, and
browser_take_screenshot under its own name; browser_open, browser_close, browser_list and browser_status are the
identity tools' and browser_watch the UI's frame. browser_scroll, which the engine invented over PageDown, goes, and
so does the 214-character cap on browser_type: the server answers within 45 s and goes on typing in the background.
An image the server answers with is shown, so browser_click_at returns the page after the click. The engine still
refuses a URL that is not http or https before it reaches the server. The system prompt carries the server's
instructions whenever a page tool is offered. The capture replaces tests/fixtures/mcp-tools-0.70.2.json: the fake
MCP server and the smoke serve the same file.

The server's click_at takes `number` coordinates, so store.canonical_arguments writes a whole number one way: 10
and 10.0 are one call for an approval, 10.5 stays itself (the test that forbade a number type said to do exactly
this before adding one). A null for an optional page argument is now refused by the server's schema with what it
should be, as nanobot refuses it for any MCP tool, instead of being dropped by the engine.

Measured with tiktoken, every permission allowed: the page tools' definitions went from 1632 tokens (10 tools) to
2885 (12), and the instructions add 765, about 2000 tokens more on each request that offers the browser. The fake
model of the tests published a 32000-token window, under the engine's cautious estimate of a turn with every
permission (about 34000); it now publishes 128000, the window of the smaller models Dots run on.

The capture is of the server at 0.71.0 from its repository; the lock still pins 0.70.2, so the test that holds the
capture to the lock fails until 0.71.0 is published and the lock moves to it, with the capture taken again from the
published package.
2026-10-10 23:07:35 +02:00
feder-cr 7788fbc068 smoke: GET /tools is checked against the permission table's own count, which the removal of back, forward and reload made 27, not the 30 written by hand 2026-10-10 23:04:53 +02:00
feder-cr 669a4f0350 wiki: browser calls that lead nowhere, the audit of 45 browser tasks of real Dots by cause, with what was changed for each and what was not
90 of 773 page calls led nowhere and took 961 of the 2823 seconds spent on pages. Addresses from memory are 58 of
them (27 of the 54 navigations dropped unread answer 404 today, against 1 of 40 that were read), which the
finding-a-page rule of PR #1438 is for, and its measure is written here too. Then two timeouts of the library (the
first page after a launch fails at the 30 seconds invisible_playwright gives Browser.newPage; pages over 45
seconds), reads that fail once right after a navigation, clicks under Google's consent dialog, and back, forward
and reload, which never worked. The snapshot with the navigation is measured and not adopted: a snapshot follows 7%
of the navigations that loaded, a text read 72%.
2026-10-10 18:23:12 +02:00
feder-cr b47df8936b engine: browser_back, browser_forward and browser_reload are gone, since a key pressed on the page never moves the browser's history: back and forward failed every time ("unknown key: 'Left'") and reload answered that it reloaded a page it did not; browser_navigate does both, and the skill says so
An audit of the 45 tasks of the saved runs that drove a page (773 page calls) counted browser_back three times and
three failures, and browser_reload five times and five "reloaded the page". The three tools pressed Alt+Left,
Alt+Right and F5 through browser_press_key. Run on invisible_playwright itself: Alt+Left and Alt+Right are refused
because the library's key names are ArrowLeft and ArrowRight; Alt+ArrowLeft and Alt+ArrowRight are pressed and the
page stays where it is; F5 is pressed and the page is not loaded again (a value set on the window survived it). A
key the server presses reaches the page, never the browser's own shortcuts. PageDown does scroll (0 to 913 pixels),
so browser_scroll stays, and a navigation to the page's own address loads it again (HTTP 200, the value gone).

So the three tools are removed from PAGE_TOOLS, the permission table, the web client's labels, the smoke's list of
offered tools and the architecture's table; an old event naming one is shown as the model called it. The skill says
that going back is a navigation to the address the page came from, and loading a page again a navigation to its own
address.

The same audit gave two more lines to the skill. browser_read_text takes a CSS selector, and its argument now says
so: the server reads with document.querySelector, so a snapshot selector in Playwright's syntax (:nth-match(...),
text=...) fails there, as one of the ten failed reads did. And a cookie or consent dialog is answered first: four
clicks on a Google result waited their 15 seconds under Google's dialog and failed before the model answered it
(66 seconds). On the same Google task, run once with the old skill and once with the new one on a fresh Dot
(z-ai/glm-5.3-flash), both answered the dialog before clicking, so that run shows no difference; the line is for
the four clicks of the runs.

wire_shapes.json is written again for the tool list and the skill's content.
2026-10-10 18:23:12 +02:00
Federico 37d58b616b Merge pull request #1438 from feder-cr/browser-finding
skill: the browser skill says how a page is found, never an address from memory
2026-10-10 14:46:58 +02:00
feder-cr 6815e3a253 skill: the browser skill says how a page is found: a link seen in a snapshot or a search on Brave, never an address from memory, and a 404 sends the Dot back to a page that loaded
The skill said how to act on a page and how to keep identities, and nothing about how to reach a page. A model
left alone fills that gap from memory: it writes an address it remembers, the site answers 404, it writes
another, and a task's steps go on guesses. The new section says where an address comes from (a link in
browser_snapshot, the site's own menu or search, or a search engine's results), that the status browser_navigate
answers is read before the page is trusted (the tool's own words), that after a 404 the next address comes from a
page that loaded or from a search, and that an address from memory is for a site's home page at most, once. The
skill's description names finding a page and searching the web, so a research task is one it covers.

Brave is the search engine named because it is the one that answered the Dot's own Firefox with real results: a
Dot asked to open the three engines reported Brave 200 with usable results, DuckDuckGo's html version 200 (its
result links wrapped in a redirect), and Bing 200 with an empty results area, then results unrelated to the query.
DuckDuckGo is the fallback the skill names.

Measured on one Dot (z-ai/glm-5.3-flash) asked for the exact URL and first heading of three documentation pages
whose addresses are not guessable (Tailscale SSH, Hetzner Cloud firewalls, Fly.io autostop): with the old skill
28 calls, 8 navigations, 2 addresses from memory that answered 404 before it recovered from the sites' indexes,
$0.019; with the new one 29 calls, 7 navigations, 0 addresses from memory (each page came from a Brave result or
a link of the site), $0.037, the difference being the snapshots of the result pages. Both answered correctly.

wire_shapes.json is written again for the new description and content; architecture.md's line on the skill says
what it now covers.
2026-10-10 14:36:50 +02:00
Federico 80f4709da6 Merge pull request #1437 from feder-cr/memory-incremental
engine: a memory pass sends only the messages it has not taken yet
2026-10-10 06:32:48 +02:00
feder-cr 1ece62d3e3 engine: a memory pass sends the summary model only the messages it has not taken yet: a conversation file is written again whole after every turn but only grows at its end, so a long day's chat was read again in full at every pass, at a cost that grew through the day; docs and the event's description said it read only what changed, which is now so 2026-10-10 04:38:59 +02:00
Federico e00898b9f0 Merge pull request #1436 from feder-cr/bench-lock
A LongMemEval run holds its directory; the engine drops the provider-side conversation state it never used
2026-10-10 04:38:55 +02:00
feder-cr 6a324467e7 tests: the exec relay tests no longer bet on a login shell printing within 200 to 1000 ms: a session is read until its command exits, and a command that ends is given the longest yield, which it answers within as soon as it ends (two full runs on an overloaded workstation failed them) 2026-10-10 03:41:10 +02:00
feder-cr a7d48bc94c ci: the pytest floor is 2540: the 22 tests removed with the provider-side conversation state tested only that code (test_conversation_state.py's 11, and 11 runner, consolidator and retry cases), none a live path 2026-10-10 03:07:47 +02:00
feder-cr 7406d05d0c engine: the provider-side conversation state and provider-native compaction are removed: no provider of a Dot keeps state or compacts on its side (OpenAICompatProvider overrides none of it), so every one of its paths was a no-op; request_pressure, which nothing called, goes with them 2026-10-10 03:07:47 +02:00
feder-cr 02a17d5b36 bench: a LongMemEval run or judge holds its directory, so a second one on it stops at once instead of answering and grading the same questions twice 2026-10-10 02:05:02 +02:00
Federico 71e9408975 Merge pull request #1435 from feder-cr/memory
A Dot remembers: conversations as files, MEMORY.md in every prompt, a background memory pass
2026-10-10 01:55:21 +02:00
feder-cr 9e42bfd8b3 ci: the test floors are what this branch's CI run measured: vitest 2110 on Linux and 2113 on Windows, pytest 2562, PostgreSQL 717 with 283 pg runs 2026-10-10 01:07:47 +02:00
feder-cr 5e163e969a smoke: the summary role is tried with windows a Dot can work in (24000 tokens: its prompt and tools alone outgrew 2880, so every turn failed while the old checks still passed), and every long message must be answered by the model, not with a failure; docs: the product run's numbers (90.4%, $0.025 a pass) 2026-10-10 00:06:29 +02:00
feder-cr e8e3d71d15 engine: the conversation files and MEMORY.md hold no secret the events mask: a decision on a call is its public line (call and target, never the arguments), and every proxy the Dot's identities ever had and the OpenRouter key are masked in any text, since /home/dot is what the host's file routes serve (the smokes found a proxy password there) 2026-10-09 19:41:01 +02:00
feder-cr 3820581c6a docs: the wiki is written in docs/wiki and published on every push to main by publish-wiki, with invisible_playwright_mcp's converter; its first articles are the studies behind a Dot's memory, its token sizing, its compaction and its benchmarks 2026-10-09 19:19:59 +02:00
feder-cr ad4fbf3306 bench: LongMemEval waits for the Dot's own memory pass before each question; the memory-method study is recorded in the README and its adapters removed (kept in de3d9a1b) 2026-10-09 19:15:49 +02:00
feder-cr f0227ede4d engine: once the Dot has been quiet for 5 minutes, a memory pass has the summary model rewrite MEMORY.md from the conversation files that changed, in one request with no tools; its spend is capped like a task's and reported by memory.updated, which the host counts and the web shows (on LongMemEval: preferences 65 to 83%, updates 78 to 94%) 2026-10-09 19:15:49 +02:00
feder-cr 933f12f3b3 bench: every bridge call of a LongMemEval run has a bound (the command's own wait, with room), so a call that never returns fails its question instead of holding the run: four questions once waited seven hours on a finished exec 2026-10-09 18:07:51 +02:00
feder-cr de3d9a1bd9 bench: adapters that run Mem0, Hindsight and Letta as their own benchmarks do (memory_<name>.py, pinned in requirements-<name>.txt), a pilot limit for LongMemEval runs, and Hindsight's recall sized to the 25,000 characters of MEMORY.md 2026-10-09 15:00:19 +02:00
feder-cr 469b853ecc bench: LongMemEval can have Mem0, Hindsight or Letta make MEMORY.md (memory_<name>.py, each in its own venv), their model calls going through openrouter_meter.py, which records what each question cost 2026-10-09 07:57:55 +02:00
Federico 569ccda475 Merge pull request #1434 from feder-cr/readme-hero-small
README: a phone animation for narrow phones, up to 374px
2026-10-09 02:36:00 +02:00
feder-cr 1fa281b70d README: a phone animation for narrow phones, up to 374px
On a 320 or 360px phone (the first iPhone SE, many Android phones) GitHub draws the 720px phone animation 254-294px wide, so its 28px text read at 9.9-11.4px. Up to 374px the README now loads hero-small-{light,dark}.gif: the same 720x620 animation, scenes and timing, with every text at 35px or more and fewer words where a line would not fit, so it reads at 12.3px on 320 and 14.3px on 360. The badge keeps its 30px: at 35px its sentence would not fit the width.

Measured with Playwright on seventeen device profiles through a local copy of the README laid out with the column github.com gives each width: every one now shows its smallest text at 12px or more, from 320px phones to 1920px desktops, in light and dark.
2026-10-09 02:14:03 +02:00
Federico 504100259b Merge pull request #1433 from feder-cr/readme-hero-social
README: the desktop animation reads at the size GitHub draws it
2026-10-09 02:04:21 +02:00
feder-cr 7f0c202aff README: the desktop animation's titles a little larger, the line under them a little bolder
The scene titles go from 42px to 46px and the line under each from regular 23px to semibold 24px, so at the 838px GitHub draws the animation the title reads at 30px and the line under it at 15.7px in a weight that holds up on a light and a dark page. The caption column is 16px wider to keep every title on two lines.
2026-10-09 01:57:07 +02:00
feder-cr c0cbcd0b05 README: the desktop animation reads at the size GitHub draws it
GitHub draws the 1280px animation in an 838px column, at 0.655 of its size, so its 16px text reached a desktop visitor at 10.5px. Every text in it is now 19px or more, 12.4px on screen: the terminal at 20px with a short prompt, Firefox's list at 18px with three invoices instead of five, two lines per month on the phone, and the window titles, file names, dates and the fleet's cards at 19-21px. Same size, scenes and timing; the phone animation is unchanged.

Measured with Playwright on fourteen device profiles before and after, through a local copy of the README laid out with the column github.com gives each width: every phone and tablet already gets the phone animation at 12.6-29px, and the desktop one now clears 12px at 1280, 1366, 1440 and 1920.
2026-10-09 01:50:29 +02:00
feder-cr 17464b6f8a bench: LongMemEval can write MEMORY.md by Mastra's Observational Memory, LangMem's profile manager or Memobase's profile extraction (their own prompts, ported in memory_variants.py) besides our one request, to weigh them on the same questions 2026-10-09 01:28:45 +02:00
feder-cr c3a26d50b4 engine: templates/agent/memory_update.md, the one request that writes MEMORY.md from the new conversations; the bench can run it before each LongMemEval question (--profile) to weigh it against the agentic memory pass 2026-10-09 00:20:43 +02:00
feder-cr 3e276ae414 engine: a prompt is taken for its whole estimate scaled by the largest tokenizer ratio measured (or the model's own, when larger): OpenRouter refuses by a count of its own, which on a Dot ran 2.4% above the model's count plus a scaled estimate of what was added; each request logs its size, how it was measured and the answer limit 2026-10-08 22:15:15 +02:00
feder-cr cd9f24992e engine: memory/MEMORY.md, what the Dot always knows about the person and what its other notes hold, is carried in every prompt (cut at 25000 characters, as Claude Code does), and templates/agent/dream.md is the memory pass's prompt, after upstream nanobot's Dream; the bench can run it before each LongMemEval question (--dream) 2026-10-08 21:07:02 +02:00
feder-cr 857ccf40cb engine: the prompt says to look in past conversations before advice or a recommendation for the person (LongMemEval: 4 of the 5 wrong preference answers had searched nothing) 2026-10-08 20:37:17 +02:00
feder-cr 0070890f84 bench: LongMemEval runs every question of one kind (--kind) and keeps each Dot's engine log, since which answers are wrong is known only once judged 2026-10-08 20:37:16 +02:00
feder-cr 62132694ea engine: the system prompt names the day, not the minute, so it stays the same all day and the provider's cache and count of it hold from one turn to the next 2026-10-08 20:18:34 +02:00
feder-cr 066738f9b8 engine: a request is sized by what the provider counted of the prompt it extends plus an estimate of the rest scaled to the worst tokenizer measured, not by tiktoken with a fixed margin (Claude counts code a third above it, and a task on a 1M window was refused for asking an answer 2779 tokens too long) 2026-10-08 20:18:34 +02:00
feder-cr 5eb2b5ceb2 engine: the chat and every task are written as files on the Dot's computer (conversations/chat/<day>.md, conversations/tasks/<day>-<task>.md) after each turn, and the prompt says to search them, so what was said before a summary or in another task can be found again 2026-10-08 18:19:09 +02:00
feder-cr f44071d4db bench: LongMemEval on real Dots: each question gets a new Dot, its history reaches it the way the product keeps past conversations, the question is a task, and LongMemEval's own prompts grade the answer with GPT-4o outside the Dot 2026-10-08 18:19:08 +02:00
Federico 824be653ab Merge pull request #1432 from feder-cr/model-limits
Every request uses its model's own limits; compaction only at the model's window
2026-10-08 15:34:31 +02:00
Federico 5d6c16412d Merge pull request #1431 from feder-cr/tests-approvals
Tests of approvals, many Dots, upgrades and agent benchmarks on real Dots; two engine fixes
2026-10-08 15:27:52 +02:00
feder-cr 71e88ac69e bench: a task that does not complete keeps the Dot's engine log with the trial, as the Dot is deleted after it 2026-10-08 14:24:41 +02:00
feder-cr dd4c6a75cd engine: a refusal for credit says what the provider said after the explanation (OpenRouter tells how much the balance still allows and where to add credit), instead of the explanation alone 2026-10-08 14:24:41 +02:00
feder-cr 578739dc11 engine: the person's latest messages a summary keeps whole take at most a quarter of the budget of the requests it goes into, so a model with a small window still has room after it (the smoke's 8000 token window failed every turn with Codex's 20000 alone) 2026-10-08 14:24:41 +02:00
feder-cr c5fbb26973 engine: every request uses its model's own limits, and a thread is compacted only at its model's window
The engine read no limit of the model: it sent max_tokens 4096 on every request and kept the thread under the
Dot's limits.context_tokens (32000 by default), so an answer could be cut at 4096 tokens and a thread was
summarized at a fraction of the window its model has. Now each model's limits are read once from OpenRouter's
list of models (the window and the longest answer of the provider it routes to by default), every request sends
the longest answer the window leaves room for (without a max_tokens each provider applies a default of its own),
and the summary model works within its own limits. A model OpenRouter does not list fails the turn in words.

Compacting, at the window less the room for the answer (20000 tokens, or the longest answer when shorter:
opencode's rule), now takes two steps. Old tool results are cleared first, the newest 40000 tokens of them kept,
with no model call: on SWE-bench that costs half as much as a summary for the same solve rate (JetBrains, "The
Complexity Trap"). Only when that is not enough is the thread summarized, as a handoff for the model that resumes
the work: Codex's framing under the headings of OpenHands' summarizing prompt, plus the approvals and files of a
Dot, then the person's latest messages as they wrote them, newest first up to 20000 tokens (Codex). A thread too
long for the summary model loses its oldest messages first, each with its calls' results, instead of becoming a
16000 character dump; no fixed cap is left on the summary. The prompt replaces the long-term-memory one the
fork came with. The request is measured once, from the provider's reported usage when it matches, for both the
compaction and the answer limit.

The smoke's stand-in OpenRouter publishes its models' limits, and the summary section narrows the window by
choosing a model with a small one.
2026-10-08 14:24:41 +02:00
feder-cr 62f3500c59 host: a Dot sets no token limit; its model's own window and longest answer are used
limits.context_tokens goes from the config's schema, its bounds, the CLI's template and the web client's
settings: a Dot's requests use the whole context window and the longest answer its model has, which the
engine reads from OpenRouter. Migration 0012 takes the setting out of the configs saved with it, moving
their version on so a form opened on an old config cannot save it back, and a config that names it is
refused as an unknown key.
2026-10-08 14:24:41 +02:00
feder-cr e0d9596967 bench: two graders ask for what their task says and no more: a language's section may carry its year or a number in its heading, and an undefined variable's error needs the error: prefix, not given words 2026-10-08 14:24:41 +02:00
feder-cr 4d2c514aa2 engine: a response cut at the output limit before any text is told that nothing ran, not asked to continue
When the whole output budget went to a tool call that never finished (a model writing a large file in one
call), the response had no text and the call was dropped, but the recovery prompt asked the model to
continue "from its exact endpoint". With nothing to continue and no word that the call did not run, the
model made the same call again, was cut again, and after three recoveries the task failed with "the run
ended without an answer". Seen on real Dots in the long benchmark tasks (kv-store, lisp-interpreter); one
passing run of kv-store spent all three recoveries in a row. Now that response is told that none of it was
delivered, that no tool call ran, and to do the work in smaller steps.
2026-10-08 14:24:40 +02:00
feder-cr a5b8121fd8 bench: five long tasks (two to three hours) where the Dot builds or researches something whole
A chess move generator graded on published and hidden perft counts, a Scheme interpreter graded on 30
hidden programs, a key-value server graded through kill -9 and a restart, a web research on 18
programming languages graded on facts that do not change, and a fraud investigation over 292,467
generated transactions graded on the planted cases. The reference solutions are in references/ and
author_long.py writes the tasks from them. In Dots the oracle scores 5/5 and doing nothing 0/5.

Also: the Dot's events of each trial are kept with its logs, and bash-to-python's grader shows the diff.
2026-10-08 14:24:40 +02:00
feder-cr c0005a91e3 bench: agent benchmarks on real Dots, through Harbor, and a suite of 25 tasks of our own
Harbor (the harness of Terminal-Bench) runs each task in a new Dot: dots_harbor/ is its environment (the Dot
is created through the API, the task's Dockerfile is replayed as dot, with apt through dot-install, and the
task's root paths are moved under /home/dot/bench) and its agent (the instruction goes to the Dot as a task).
Every call goes through bridge.ts, on the end-to-end run's outside driver. Harbor's registry datasets run
the same way.

tasks/, written by author.py from fixed seeds: shell, files, coding, data analysis, the computer's
settings, questions with one answer, two long tasks, and what a Dot is for (its own automation, a careful
cleanup, a request it must refuse). In Dots: the oracle scores 25/25 and doing nothing 0/25; the Dot with
z-ai/glm-5.3-flash scored 23/25 in 9 minutes, 4 at a time (one miss was the grader's rounding, now fixed).
2026-10-08 14:24:39 +02:00
feder-cr 564bed5198 e2e: the scale run, many Dots on one host
scale.ts creates 1, 2, 4 and 8 Dots at once on real VMs; each must come up, answer a message and do a task,
while the API, the host's memory and QEMU's are sampled, within budgets that are ratios to one Dot. The
end-to-end run's outside driver moves to driver.ts, which both runs use, and a contract test keeps every
file of the runs within what Node's type stripping runs.

On a Linux host with KVM (16 cores): READY p95 21 s with one Dot and 45 s with eight, a reply in 4 to 8 s,
a task in 3 to 6 s, the API's p95 6 to 10 ms, about 700 MB of QEMU memory per Dot.
2026-10-08 14:24:39 +02:00
feder-cr 44ca4cd1fc scheduler tests: the database the release golden-b326f2a1d4fe left (two Dots, tasks in every state, a pending and an always approval, a Telegram binding, sealed secrets) migrates under this build on PGlite and PostgreSQL, reads back, and goes on working: its pending approval is answered, new tasks queue, new events follow the old ones 2026-10-08 14:24:39 +02:00
feder-cr 50d7016204 e2e: step g also rejects a write through the CLI (it never runs) and approves one with --always (it runs, and the next write runs as allowed without asking); 15/15 on a fresh Linux host with KVM 2026-10-08 14:24:39 +02:00
feder-cr b2e21fe972 engine tests: the state the release golden-b326f2a1d4fe left on a Dot's disk (its engine database and automations) opens under this engine: a task waiting for its decision runs once approved, the chat goes on from its history, the identity and the automation are there 2026-10-08 14:24:39 +02:00
feder-cr 09b0212c11 scheduler test: an approve (always) and a reject sent at the same moment, five times: one wins, the other is a 409, the guest hears one decision and the permission changes only if the approve won 2026-10-08 14:24:39 +02:00
feder-cr 5997873166 engine tests: every tool under allow, deny, ask then approve and ask then reject, in the chat and in a task; an approved call made with other arguments asks again; a decision sent again runs nothing; a call waiting across a restart runs once when approved 2026-10-08 14:24:39 +02:00
feder-cr d859756de0 engine: a task cancelled while its call waits for approval ends that approval, and the agent is idle again
The host expires the approval of a cancelled task, so no decision for it ever came: the engine kept it
pending, /state named it, and the agent stayed WAITING_APPROVAL. The Dot showed "Waiting for you" and,
not being READY, never went to sleep. The cancel now ends the task's pending approvals in the
transaction that records it, and an agent that was only waiting for them is IDLE.
2026-10-08 14:24:38 +02:00
Federico 5afd5f066e Merge pull request #1430 from feder-cr/readme-gif-v4
README: the line under the animation, and a compact logo card
2026-10-08 03:36:13 +02:00
feder-cr b6264738c0 README: under the animation, the line the About opens with: an open-source, self-hosted alternative to OpenAI Dots and Grok Bot, built to be undetectable by anti-bot systems; the animation's logo card is the name and the four, centred, and its second scene says what the computer has 2026-10-08 03:28:21 +02:00
Federico 733d29f568 Merge pull request #1429 from feder-cr/readme-gif-v3
README: the animation shows the whole product, not a browser
2026-10-08 03:16:22 +02:00
feder-cr bf5a374995 README: the animation shows the whole product, not a browser: a computer with a terminal and files, skills and memory it writes itself, approvals, a monthly job and several Dots, on the shop-invoices example 2026-10-08 03:08:13 +02:00
Federico 21d1b8d1d7 Merge pull request #1428 from feder-cr/readme-gif-breakpoint
README: the phone animation up to 1239 px wide
2026-10-08 02:37:24 +02:00
feder-cr 0dbb164d8b README: the phone animation up to 1239 px, where GitHub's README column is under 800 px wide (measured: 406-797 px from 701 to 1239), and the wide one's smallest text 16 px 2026-10-08 02:29:48 +02:00
Federico 4d4d346eef Merge pull request #1427 from feder-cr/readme-gif-mobile
README: the animation reads on a phone
2026-10-08 02:22:19 +02:00
Federico 83bd7928c1 Merge pull request #1426 from feder-cr/openrouter-referer
engine: the OpenRouter referer is the repository's own address, github.com/feder-cr/invisible_dots
2026-10-08 02:16:49 +02:00
feder-cr 9f367dc8a7 README: the animation reads on a phone: a version of its own for narrow screens, square, few words, nothing under 28 px; the wide one's small text is larger 2026-10-08 02:13:41 +02:00
feder-cr f4427daab7 engine: the OpenRouter referer is the repository's own address, github.com/feder-cr/invisible_dots
OpenRouter names an app by its HTTP-Referer, and the Dot still sent github.com/feder-cr/dots, the old name that GitHub only redirects. The engine's constant, its two tests and the two pages that quote the header now say feder-cr/invisible_dots. The three OpenRouter constants in packages/shared, which nothing imports and which repeated the engine's values, are gone, so the engine stays the one place they live.
2026-10-08 02:06:55 +02:00
Federico 7f2c17e8ed Merge pull request #1425 from feder-cr/readme-no-nav
README: no row of links under the animation
2026-10-08 01:49:11 +02:00
feder-cr 83d97af7f6 README: the row of links under the animation goes; the page goes straight from the animation to what a Dot is 2026-10-08 01:42:15 +02:00
Federico a4c0ecde64 Merge pull request #1424 from feder-cr/readme-logo-gif
README: it opens on the animation, which is also the logo
2026-10-08 01:11:55 +02:00
feder-cr c4e1d97ac9 README: it opens on the animation, which is also the logo: the name, the alternative it is and the four, on the page's own colour in light and dark, then what a Dot does; the banner, the line and the old animation go 2026-10-08 01:04:36 +02:00
Federico 147135e90a Merge pull request #1423 from feder-cr/readme-badges-bottom
README: badges at the end, no comparison table or neighbours, the new render of the four
2026-10-08 00:37:44 +02:00
feder-cr dd7495871a README: the banner and the animation's card use the owner's new render of the four, with softer reflections 2026-10-08 00:31:41 +02:00
feder-cr 5babbf4580 README: the comparison table and the neighbours paragraph go; the related projects keep the pieces this one is built on 2026-10-08 00:25:33 +02:00
feder-cr 713398d806 README: the badges move to the end, beside who built it, so the page opens on the banner, the line and the animation 2026-10-08 00:22:31 +02:00
Federico e0f0fa4382 Merge pull request #1422 from feder-cr/readme-four
README: the title is a banner with the four angry characters
2026-10-08 00:16:19 +02:00
feder-cr 5407240a43 README: the paragraph under the title goes, and the animation opens on its end card, the name and the four, so its loop is seamless 2026-10-08 00:08:51 +02:00
feder-cr c17395dce5 README: the title is a banner in invisible_playwright's format, the name beside the four angry characters, light and dark 2026-10-08 00:00:24 +02:00
Federico 57f32de671 Merge pull request #1420 from feder-cr/readme-gif-dark
README: the picture is an animation of what a Dot does
2026-10-07 23:56:58 +02:00
feder-cr 2fced2beac README: the animation ends on the four angry plush characters on black, from the owner's render 2026-10-07 23:50:23 +02:00
feder-cr 21749e2b1f README: the picture is an animation of what a Dot does: a task asked once in its chat, done on its own computer, an approval before the daily automation, and the list on Telegram every morning 2026-10-07 23:26:04 +02:00
feder-cr 12bd7283f4 Merge main into readme-gif-dark 2026-10-07 23:26:03 +02:00
Federico 6c3d0be0d9 Merge pull request #1421 from feder-cr/approvals-order
engine store: approvals of one millisecond keep the order they were asked in
2026-10-07 23:04:57 +02:00
feder-cr 641ec29deb engine store: approvals of one millisecond keep the order they were asked in, not their random ids' order 2026-10-07 22:47:36 +02:00
feder-cr ccf0ec4f00 README: the GIF is recorded with the web UI dark and the Dot's browser light, so the page it reads stands out 2026-10-07 22:33:33 +02:00
Federico 9db8182df4 Merge pull request #1419 from feder-cr/community-files
Community files: contributing, code of conduct, security policy, issue and PR templates
2026-10-07 21:58:10 +02:00
feder-cr bcf03a0eb2 Community files: how to contribute, the code of conduct and the security policy, and issue and PR templates that ask for what a report of this project needs (doctor, logs, commit, host) 2026-10-07 21:33:52 +02:00
Federico a0bc3a640a Merge pull request #1418 from feder-cr/readme-hero-gif
README: the picture is a GIF of a real Dot at work on GitHub Trending
2026-10-07 19:06:59 +02:00
feder-cr 6abd91e947 README: the picture is a GIF of a real Dot at work: asked in its chat for today's top repository on GitHub Trending, it opens Firefox on its computer, the Computer tab shows it browse, and the chat gives the answer 2026-10-07 18:54:23 +02:00
Federico 4f9819f783 Merge pull request #1417 from feder-cr/browser-stop-closed
engine: an identity still closing when the stop's budget ends is recorded closed, not lost
2026-10-07 18:37:07 +02:00
feder-cr 0b58221579 engine: an identity still closing when the stop's budget ends is recorded closed before the store closes, not lost 2026-10-07 17:35:14 +02:00
Federico 4aa45c5d16 Merge pull request #1416 from feder-cr/readme-hero-alternative
README: the title line says what the About says, two sections move to the guide, and the repository is invisible_dots
2026-10-07 17:23:00 +02:00
feder-cr 5c636cd029 README: the line under the title is the owner's: a virtual machine on your PC with a desktop, a shell, files, memory and skills that stay, invisible to anti-bots 2026-10-07 16:57:54 +02:00
feder-cr 39963e5277 The repository is feder-cr/invisible_dots: the clone commands, the tests badge and the prebuilt releases name it; the OpenRouter referer stays the old URL, so the app's history there is not split 2026-10-07 16:57:16 +02:00
feder-cr 1415d12f3a README: How it works and Security and privacy leave the page; the guide and docs/architecture.md keep them 2026-10-07 16:57:16 +02:00
Federico 41eb57ea58 Merge pull request #1415 from feder-cr/soak-flakes-2
Fixes from the soak: engine stop, task claim, Inbox after a delete, Home card on a phone
2026-10-07 15:51:00 +02:00
feder-cr cba1ccaee5 channels test: a deleted Dot's token is waited for until the background delete ends, not read when polling stops 2026-10-07 14:43:40 +02:00
feder-cr 24170f9547 README: the hero leads with what the repo's About says: an open-source alternative to OpenAI Dots, Meta Muse, Grok Bot, Manus Cue and Claude Cowork, then the browser anti-bots do not detect 2026-10-07 14:27:03 +02:00
Federico bff0eef8a5 Merge pull request #1414 from feder-cr/readme-medium
README: a one-minute page, not a stub
2026-10-07 13:34:17 +02:00
feder-cr 6402a753fb README: a one-minute page like the projects people know, not a stub: what it is, quickstart, what a Dot has, which one fits, how it works, security 2026-10-07 13:26:37 +02:00
feder-cr 95415aad0e scheduler: a task the claim skipped because its Dot's row was held a moment is claimed when the row is free, not at the next timer 2026-10-07 13:09:48 +02:00
feder-cr 4ec820739b engine: a stream asked on a kept connection once the stop began is refused, so SIGTERM ends within systemd's 30 s 2026-10-07 12:35:54 +02:00
feder-cr 4693d47fd9 web: a long Dot name is cut inside its Home card on a phone, not past its edge 2026-10-07 12:27:12 +02:00
feder-cr 118985398e web: a deleted Dot's approvals leave the Inbox, its count and its History when the delete finishes 2026-10-07 12:27:11 +02:00
feder-cr d6cf24e1a6 engine test: a session's first output is read from the session when the interpreter starts after the first yield 2026-10-07 11:59:18 +02:00
Federico 5ff4ef0211 Merge pull request #1413 from feder-cr/readme-short
README: short, the long form in docs/guide.md
2026-10-07 11:28:30 +02:00
feder-cr 28d925f696 README: short, with the long form moved to docs/guide.md and its facts still checked there 2026-10-07 11:09:11 +02:00
Federico 3ce017171c Merge pull request #3 from feder-cr/dots-ui-browser-channels
invisible_dots: a leaner first version, run end to end on real VMs
2026-10-07 10:41:08 +02:00
feder-cr a9a00de2bf scheduler testing: a Dot waited READY gets 15 s, a creation against Postgres on a loaded host took over 5 2026-10-07 10:15:05 +02:00
feder-cr 42ee9b4fab channels test: a prompt's record is waited for, not read 80 ms after its chat took it 2026-10-07 10:07:57 +02:00
feder-cr 48299b2772 web: a Dot's page open while its delete finishes says the Dot does not exist
The header reloaded the Dot on dot.updated, computer and agent events, not on dot.deleted: a delete finishes after its request (the computer stops first, longer with a task running), and the page open on the Dot stayed on "Computer: Deleting" with its tabs until reloaded by hand (seen through the real product). dot.deleted reloads it, and the 404 draws "This Dot does not exist". vitest floors +1.
2026-10-07 09:43:57 +02:00
feder-cr d0beb90403 scheduler test: the interrupted idle stop's Dots are waited READY, not caught while the automation runs
settle() waits for the scheduler's own work; the automation the woken guest runs can still hold the Dot RUNNING when the test reads it (soak cycle 23, PostgreSQL under load: expected READY, got RUNNING). The test waits for READY, which is what it proves: the stop was finished and the computer works again.
2026-10-07 09:34:32 +02:00
feder-cr f3c0b29c17 smoke: the dotengine group check has its two arguments again
Inserting the VM proxy check (8c8ad881) dropped the space between the label and the command of the next check, so check got one argument and the smoke stopped at set -u (lib.sh line 15: $2 unbound). Seen in the soak's smoke lane.
2026-10-07 09:23:04 +02:00
feder-cr 6d557efae6 smoke: the VM proxy check works in a fresh mktemp directory, with no recursive rm
tests/repo/engine-smoke.test.ts keeps smoke.sh from removing directories (it must point nowhere outside its run); the check made in 8c8ad881 cleared its output directory with rm -rf.
2026-10-07 09:16:16 +02:00
feder-cr a10343ff70 vm-manager: a seed that changes gets an instance-id cloud-init has never seen, so a cleared VM proxy is cleared
The instance-id was the digest of the seed's content, and cloud-init runs write_files once per id it has seen. A VM proxy set and then cleared gave the first boot's content, so the first boot's id: cloud-init skipped write_files, config.json kept the proxy, and the Dot stayed behind a proxy it no longer had (seen through the real product: after the proxy was removed and the computer stopped and started, every message answered "Connection error"). A seed equal to the last one written keeps its id, as before; any other gets the content's digest and a digest of the id before it. The last one is recorded in seed-instance.json beside seed.iso. vitest floors +1.
2026-10-07 09:16:05 +02:00
feder-cr 8c8ad8815e guest: the VM proxy's firewall is a ruleset nft takes, so a Dot with a proxy goes out through it
install.sh wrote "ip daddr <proxy> udp accept", which nft refuses (udp wants a field after it; the protocol is meta l4proto udp). nft -f failed, and set -e stopped install.sh there: no table, no tunnel, and the rest of the install skipped. A Dot with a VM proxy went out directly, even through a proxy that answered nothing (seen through the real product: the Dot kept answering with socks5://127.0.0.1:9 set and restarted; inside it, no tun0, no dot-vmproxy unit, the direct default route, and the guest's journal held the nft error).

The engine smoke now runs install.sh's own generator on a config with a proxy and checks the ruleset with nft -c (its container gets NET_ADMIN, and nftables and iproute2): it fails on the old rule and passes on this one. No test had run the VM proxy's setup before.
2026-10-07 09:02:52 +02:00
feder-cr ca781d96b2 engine: a key the provider refuses is said to be the key
With a wrong OpenRouter key set in the host's Settings the chat said "I could not answer: Error: User not found.", OpenRouter's words for it, which do not say that the key is what is wrong. A 401 now reads "Error: the API key was refused (401): User not found." pytest floor 2222 to 2223.
2026-10-07 08:51:09 +02:00
feder-cr 15488a65c1 tests: the 429 test reads the status changes from the log, and two whole tasks get the time PostgreSQL needs
Both failed twice in fifteen soak cycles, at its busiest. hub-telegram's 429 test polled the channel's status for an error that lasts the second Telegram asked for: one read of a loaded pglite took longer and the error was never seen. It now reads the channel.status events, where every change is recorded, and measures the wait between them. api.test's two tasks in a row take 0.6 s on PGlite and 2.2 s on a PostgreSQL reached across WSL, and passed the default 5 s under load: that liveness wait is 15 s, as the hub's comparable waits are.
2026-10-07 08:46:19 +02:00
feder-cr 880006fdda cli: bundle-options.mjs has its type, for the test that imports it 2026-10-07 08:38:13 +02:00
feder-cr c75bca3ef2 cli: the bundle keeps class names, so Telegram works from the installed command
In the bundle esbuild renamed AbortSignal to AbortSignal2, and node-fetch, which grammY uses on Node, recognizes a signal by its constructor's name: every Telegram call failed there as "Network request for 'getMe' failed!". Connecting a bot answered "could not reach Telegram (getMe)" for any token, and a connected one could neither poll nor send. The suites import the sources, so none saw it; it was found through the real product (a wrong token said Telegram was unreachable while Telegram answered 401). keepNames is set, the options live in scripts/bundle-options.mjs, and tests/repo/cli-bundle.test.ts bundles a real grammY call with them against the fake Bot API (without keepNames it fails as the product did). vitest floors +1.
2026-10-07 08:37:54 +02:00
feder-cr b1a05987ad web: the page of a Dot that does not exist says so, with the way Home, and draws nothing of a Dot
Opened after its delete (seen through the real product, a Dot deleted while a task ran), a Dot's address drew the whole page around nothing: an empty header, the tabs, a live message box for a Dot that is gone, a computer panel saying to start it from the header, and two red errors naming its id. A 404 for the Dot now draws one page that says it does not exist and links to Home; any other failure to load it keeps the error it had.
2026-10-07 08:35:02 +02:00
feder-cr 1194e0a576 web: an address with nothing behind it has a page in the app's look, with the way back to Home
Next's default 404 drew its own black or white page in the system font, with no link: a person who followed an old link, mistyped, or opened a Dot that was deleted was left there. app/not-found.tsx says there is nothing at this address, why that can be, and links to Home. Seen through the real product in every theme and width; shell.spec.ts checks the 404, the font and the link. Playwright floor 86 to 87.
2026-10-07 08:21:15 +02:00
feder-cr d8bb8901f6 engine: a context window too small for the request is said in words, with what to change
With limits.context_tokens at 4000, the schema's floor, a Dot of the Balanced preset could answer nothing: its prompt and tools alone are 6371 tokens and the window less the answer's room is nothing. Each message got "I could not answer: Model input exceeds the local context budget for chat: 6371/0 via tiktoken" (seen through the web client). The turn now fails with the request's size, the window and what it leaves, and says to raise Context tokens in the Dot's settings. Whether the floor itself should rise is the owner's decision; the smoke's summary check relies on a small window. pytest floor 2221 to 2222.
2026-10-07 08:12:44 +02:00
feder-cr ac5e93ca93 engine: a provider's error reaches the chat as its message, not as a Python dict
The OpenAI client hands failure_text the body it already parsed, a dict, and str() of it reached the person as "I could not answer: Error: {'message': 'no-such-vendor/no-such-model-9 is not a valid model ID', 'code': 400}" (seen through the web client with a model id that does not exist). A parsed body is now read for its message, {message} or {error: {message}}, and shown as JSON when it has none. pytest floor 2217 to 2221 for the four tests added.
2026-10-07 07:59:44 +02:00
feder-cr 7195380198 cli test: serve's tests wait for what they check instead of a fixed 20 ms
serve starts the control plane, then listens for the stop signal, then starts the web client, each asynchronously. The tests slept 20 ms and then asserted, which a loaded machine loses: the soak run read webOptions[0] before the web start had happened. Each now waits (vi.waitFor) for its own condition: the start it checks, the SIGINT listener, or the web start having begun before Ctrl+C.
2026-10-07 07:58:27 +02:00
feder-cr f3096aee05 api test: the key every READY needs is stored in the setup, not by the health test
A Dot reaches READY only with a key to push. In api.test.ts the global key was stored by the health test, so the 30 tests that ready a Dot passed only after it: run alone (vitest -t), any of them timed out waiting for READY, on both adapters. It is stored in beforeAll now, as the other suites do.
2026-10-07 07:49:00 +02:00
feder-cr d4a3870e3c tests: the forced-colors outline is read once settled, and the Linux host's copy leaves the web client's test output out
shell.spec.ts read the focused button's outline right after the Tab, and its transition-all eases the outline in from the default medium width (3px) to 2px over 150 ms: on a loaded machine it read 3px every time (measured: 3px at once, 2px 300 ms later). It now polls until 2px. prepare.sh copied apps/web/test-results and playwright-report.xml, which no clone has, and tar failed when a browser test run wrote there during the copy.
2026-10-07 07:30:44 +02:00
feder-cr 882ea72e75 web: on a phone the Inbox fits the screen again, and a long approval's answers are on it
Since the main became a flex column (6e6cd785, every page to the bottom), the Inbox's mx-auto box no longer stretched: auto margins stop it, so its width was its content's, up to max-w-3xl. A long file in an approval made the page 768 px wide on a 390 px phone, the main scrolled sideways inside itself (the document did not, so the overflow check passed) and Allow once and Deny sat at 654 to 751 px, off the screen. w-full gives it the width of the main, as the chat's centered boxes have. Found by e2e/inbox.spec.ts in the soak run.
2026-10-07 07:30:44 +02:00
feder-cr 477be7cf47 engine tests: the fake relay gives the command the account's HOME, USER, LOGNAME and SHELL, as dot-agentd does
dot-agentd runs what the model asks with the account's own HOME, USER, LOGNAME and SHELL (exec.go execEnv). The fake relay left HOME out, so the login shell of a tty call read the profile of whoever ran the tests: on a machine whose ~/.profile sources $HOME/.cargo/env, its errors came first in the output and test_an_escape_sequence_split_across_two_polls_is_never_shown_in_halves failed. The relay's home is the LocalComputer's /home/dot.
2026-10-07 07:22:43 +02:00
feder-cr b69184e1e2 tests: the vitest floors are what the pre-push gate measured, 2099 on Windows and 2096 on Linux
The floors lowered with the Memory tab, the browser's view and the goal were worked out from the lines of vitest list, which shows an it.each once: the deleted files (memory, memory-view, notes, automations, identity, browser-activity, login, session) took more runs than that. The gate's run on Windows: 2099 passed, 0 failed, 0 skipped, in 149 files. Linux stays three below, the generated PowerShell tests it skips.
2026-10-07 04:03:24 +02:00
feder-cr a818208f03 e2e: the run follows the product as it is, and passes 15 of 15 on real VMs
Step c checks the web client as it is since it has no login: its pages on 127.0.0.2:3000 and its proxy reaching the API, with no session (cookieOf goes, nothing else used it). Step e reads example.com's title with browser_snapshot instead of a heading: the site's body has changed again and its first line is now the paragraph, while its <title> stayed "Example Domain"; the files are title.txt and example-title.md. The Linux host's user takes the uid and gid of whoever builds it (--build-arg), so the key mounted from their home, mode 0600, reads as it does for them; it was EACCES.

Run in the linux-host container on WSL with KVM: 15/15 steps passed, among them h with the engine's max_open of 3, j to l across restarts and killed VMs, m and o finding the key nowhere.
2026-10-07 04:00:05 +02:00
feder-cr 580aa62566 engine: browser_navigate's url has no schema pattern, so a provider that decodes under it is not made to send "https://"
Together, one of the providers OpenRouter routes z-ai/glm-5.3-flash to, decodes the call under the tool's schema and takes `pattern` as the whole value: with "^https?://" the only URL it let the model write was "https://" (measured on every provider of the model, the same forced call with and without the pattern: Together alone changed, and only with it). The refusal of anything but http and https stays in the engine, where it was already enforced.
2026-10-07 03:36:13 +02:00
feder-cr bb37a3a105 settings: a Dot has no goal and no browser settings; it is asked for what it does, and it manages its identities itself
The goal is gone from the schema, the engine's protocol and prompt, the header, the cards, the search, the
forms and the CLI sample; the first messages offered no longer draw on it. Migration 0010 takes it out of
stored configs and moves their version.

The Browser panel is gone with what it set: managed_by_dot, max_identities and max_open. The engine keeps
its own limits (3 open, 20 in all, DEFAULT_MAX_OPEN and DEFAULT_MAX_IDENTITIES in main.py), fixed when the
manager is made, and offers every identity tool its permission allows. Migration 0011 takes the section out
of stored configs. The limit message says "the most it may keep" instead of "the most its configuration
allows". The real e2e's eviction step launches a fourth identity instead of lowering max_open to 2.

Test floors are lowered by what went with the features: pytest 2231 to 2217 (the set_limits, managed_by_dot
and goal tests), vitest 2125 to 2124 on Windows and 2122 to 2121 on Linux (three first-message tests about the goal
became one and the max_open rule's test went; the two migration tests came).
2026-10-07 03:21:44 +02:00
feder-cr 23c130b10a readme: the shape of the projects people know, a real picture of a Dot, and what changed today
Measured against fifteen READMEs (browser-use, Stagehand, Skyvern, OpenHands, Open Interpreter, nanobot, Claude
Code, uv, Ollama, n8n, Supabase, E2B, Dify, AutoGPT, Playwright MCP), in their order: a centred title, a one-line
tagline, badges and a short row of links; a picture of the product at once, light and dark; what it is in a few
lines and its highlights; the quickstart; what to ask it, with real prompts; then what it does, how it works, the
security model, privacy, configuration, troubleshooting, status, development, acknowledgements and the license.

The picture is a real Dot (fare-watch) answering a real request: it opened a browser identity, read example.com and
answered, and the desktop beside the chat shows Firefox on that page. The text says what today changed: memory and
skills the Dot keeps itself, no Browser view (an open browser is a window of the desktop), no Memory page. The
facts the repository's tests hold the README to are kept word for word, and both prose gates pass.
2026-10-07 02:39:49 +02:00
feder-cr fefd892aef skills: the Dot learns how to do things as Claude Code does, and the browser has no view of its own
A skill is a folder with a SKILL.md, the Agent Skills layout Claude Code and upstream nanobot read: a frontmatter
with its name and a one-line description of when it applies, then the steps. Built-in skills ship beside the
engine's package (invisible_engine_dots/skills/, on the runtime disk at /opt/invisible-dots/engine/skills/); the Dot
writes its own under /home/dot/skills with its file tools, and one of its own replaces a built-in one of the same
name. Each turn the prompt names every skill with its description and the path of its file, says to read the file
before a task it covers and how to write one; a file that does not parse is left out and logged
(nanobot/dots/skills.py). The first built-in skill, invisible-playwright, is invisible-playwright-mcp's own
instructions to agents brought to the Dot's tools: identities, a selector before coordinates before a screenshot,
the real pointer and keyboard only, a second identity for a throwaway mailbox, and saying a task is impossible
rather than getting blocked.

The engine serves the list (GET /skills), the host passes it through as it does the tools (GET
/api/dots/:id/skills, the SDK's listSkills), the engine pins its answer in the wire shapes the host parses with
skillSchema, and the web client has a read-only Skills tab: the list, whether each is built in or the Dot's own, and
the open one as Markdown.

The Computer page's Browser view, its identity cards and the chat panel's browser chips are gone: an open browser
is a window of the desktop the Screen and the panel show, and the Dot makes, opens and closes its identities with its
tools. The engine, its tools and the host's identity routes are unchanged. The old /identities address goes.

The summary tests' estimator counts the thread they size, not the engine's own prompt, which grows with what it
teaches the model (a skills section had broken them a second time); the test that no engine key reaches the model
looks for the keys as keys, since a prompt's path may hold the same letters. The vitest floors go down by the 29
tests of the Browser view, net of the new ones; the pytest floor goes up to the 2231 it now has.
2026-10-07 02:33:07 +02:00
feder-cr 576ed74c3c engine: the tokenizer loads when the engine starts, so the first turn after a wake is not counted in bytes
"Model input exceeds the local context budget for chat: 29289/26880 via heuristic" came on the first message after
every start of the engine (measured on a Dot: agent.started at 22:21:10 and 22:36:09, the failures at 22:26:22 and
22:36:35, a message in between answered). Nothing loaded the tokenizer at start: the first count of a process began
the load and was answered with the fallback, one token per byte, about four times the truth, so a long thread was
"over" its budget even after its summary. Upstream nanobot warmed the tokenizer when its agent loop started, a call
the port removed with the loop; the engine's start makes it again, from the image's cache, while the host pushes the
config and the key.
2026-10-07 02:05:27 +02:00
feder-cr 5289e0b8d3 no telemetry: Next's is off in the web client's own config, and the Dots' Ubuntu loses pollinate
Next.js posts anonymous telemetry from every `next build` and `next dev` unless NEXT_TELEMETRY_DISABLED is set. It
was set by `setup --all` and by CI only, so a build by hand sent it; now next.config.ts sets it, which Next reads
before it creates its telemetry, so no way of building the web client sends any, whatever the person's own Next
settings say (measured: a build started without the variable finds it set and its telemetry disabled, and posts
nothing to telemetry.nextjs.org). The copies in setup and CI go.

The minimal cloud image ships pollinate, which at the first boot of every machine, so of every new Dot, tells
Canonical's entropy server the system's version, its cloud and its processor; it is purged with the other services
a Dot never uses. The README's privacy section says what is left, and that the browser layer's launch counter is
that library's.
2026-10-07 02:01:09 +02:00
feder-cr 07cb456028 memory: the Dot keeps it itself, as Claude Code does; the Memory tab and everything it set are gone
The notes stay what they were, files in /home/dot/memory, but nothing about them is a setting any more. The prompt
always says where they are, that the Dot saves there what a later conversation will need and changes a note that is
no longer true, and names the newest; the Dot reads and writes them with its file tools (grep, find_files,
read_file, write_file, edit_file). Gone: the memory_search and memory_get tools and their memory.read permission,
the memory.enabled switch, the memory.written event and the chat's "Remembered" chip, the Memory page with its notes
and its automations views, and the routes that listed, paused and deleted automations (engine, guest client,
scheduler, API, SDK). The automations themselves stay the Dot's: it makes and removes them with its cron tool, and
the host still hears when the next one is due.

Migration 0009 takes memory and memory.read out of the configs already saved (moving their version on) and the
memory.written rows out of the log; an engine file made before keeps its old intents column, which its default fills.

Two defects found on the way: dot-agentd not answering reached the engine as an httpx error instead of a
ComputerError, so a turn crashed over the list of notes for its prompt (now status 0, and the turn goes on); and the
sample `invisible-dots init` writes would have been refused by the schema (now tested to parse).

The test floors go down by exactly the tests removed with the feature: vitest 73, postgres 2, pytest 78
(2299 to 2221), playwright 10 (memory.spec.ts).
2026-10-07 01:49:38 +02:00
feder-cr 6e6cd78580 web: the computer is always beside the chat, and every page fills the window to the bottom
The "Watch the computer" button and its sheet are gone: from 1024 px the chat and the computer share the width, below
that the computer is a strip above the thread as tall as its picture needs. The page column is as tall as the window
and a Dot's tab body fills what its header leaves, so the thread and the panel end at the bottom of the window; the
1200 px cap is gone. Scroll containers are positioned, so a screen-reader-only span or a hidden select no longer
makes the whole document scroll (it did by 2510 px on a Dot's settings).

The screen picture is laid in a viewer box that has its own size and never sizes it: in an auto-height grid row its
max-height had nothing to resolve against, so a 1280 x 800 frame spilled over the caption and the buttons. LIVE and
the controls share one line, and the Desktop/Browser choice sits on the heading's. The fake guest's screenshot is a
real 1280 x 800 PNG, and the chat e2e checks the picture stays inside the panel and above its controls.
2026-10-07 00:57:51 +02:00
feder-cr 111b664884 image: the seed and the runtime ISO are virtio drives, and the golden is built on Ubuntu's minimal cloud image
Measured: the minimal image's kernel has no SATA CD-ROM driver, so on the q35 machine the builder VM never saw the
seed and cloud-init never ran. A read-only virtio drive is seen by every cloud image (vdb, vdc), and cloud-init and
the runtime's mount find the ISOs by their labels as before. The golden built on it is 1.96 GB instead of 3.0-3.4,
and a Dot from it boots in about 12 s.
2026-10-07 00:57:43 +02:00
feder-cr 8033cf48d0 web: Vercel's look, states in words, the rail's foot as icons with tooltips
Geist Sans and Mono (bundled), black on white and white on black, Vercel's grays
and 8px corners; a state is a colored dot and a word everywhere (the computer's
says Running, not RUNNING: one label map for every VM state), and the cost is
plain text. The rail's foot is one line of icons whose words are in a tooltip
and for screen readers; Tooltip brings its own provider, and the phone menu
opens on the current page instead of focusing (and opening) a status tooltip.

Tests: the e2e harness has no sign-in any more, the rail test reads the favicon
color from its module, and the Tasks paging test counts the queue's items from
the DOM instead of a role query over 200 cards repeated by waitFor (5s -> 2s).
Playwright floor 99 -> 96: the three login tests went with the login.
2026-10-07 00:20:39 +02:00
feder-cr 7cd33b3a33 web: an instrument, not a template: ink and graphite, IBM Plex, square corners, a ledger of Dots
The primary action is ink and color is kept for status, with amber for what
needs the person (no indigo). IBM Plex Sans for words and Plex Mono for what the
machine reports, bundled with the client. Pills and circles become square marks,
the soft focus glow a crisp ink line; Home lists the Dots as rows, the chat is
the page instead of a card in it, the create form numbers its steps in mono,
and the rail has a wordmark instead of a colored New Dot button.
2026-10-06 23:27:42 +02:00
feder-cr 0b746819b9 web: no login; the web client listens on 127.0.0.2, which no Dot's VM reaches
QEMU's user network maps 10.0.2.2 to the host's 127.0.0.1 only, so a Dot
reaches the API on 127.0.0.1:8787 (which needs the token) and never the web
client. Measured from a Dot: 10.0.2.2:3000 times out, 10.0.2.2:8787 answers
401. The login page, the session route, the sign-out button and their tests
go; the web server still adds the API token to each call itself.
2026-10-06 23:12:09 +02:00
feder-cr 1a16d015f6 image: back to Ubuntu's server cloud image: measured, the minimal one does not see the seed's CD-ROM on the Dots' q35 machine, so cloud-init never runs 2026-10-06 22:54:04 +02:00
feder-cr ef2eaf4c79 image tests: dot-install is a guest script with dot's one sudo rule, and the seed has no Node tarball 2026-10-06 22:43:25 +02:00
feder-cr e8e39443bc image: apt's daily timers are masked; a Dot's packages change with its golden image, and dot-install refreshes the lists itself 2026-10-06 22:40:46 +02:00
feder-cr f511191e66 guest: the model installs a missing Ubuntu package with sudo dot-install, and stays without root
The sudo rule names a script that takes package names only, so no option
reaches apt (a rule for apt itself is a root shell through -o); install.sh
puts it in /usr/local/sbin and checks the rule with visudo. The engine's
prompt tells the model, with uv for Python tools.
2026-10-06 22:35:29 +02:00
feder-cr eeafebf7a5 image: curl, git and the desktop's terminal are back, as before; the rest stays out 2026-10-06 22:31:37 +02:00
feder-cr 7c34401a52 image: the golden image carries only what the Dot's own software uses
No Node (nothing in the guest runs it since the engine is Python), no git,
curl, xz-utils, xfce4-terminal or the model's convenience tools; the cloud
image's services a Dot never uses (snapd, unattended-upgrades, apport, the LXD
stubs, Ubuntu Pro, the release upgrader, the SSH server) are purged in the
build.
2026-10-06 22:24:09 +02:00
feder-cr 0c967dcf3c image test: the golden build tests build, without looking for a published image 2026-10-06 22:18:11 +02:00
feder-cr 51385e45a5 image: the golden image is built once by CI and downloaded, built here only when none is published for these inputs
CI (golden-image.yml) builds it compressed and publishes the release
golden-<inputs digest> with its manifest, the image in parts under 2 GiB and
release.json. image build looks for the release of the checkout's own digest,
checks each part and the whole image against their SHA-256, and builds the
image itself when there is none (--no-download always builds).
2026-10-06 22:17:56 +02:00
feder-cr 17030cc50c image: the golden image starts from Ubuntu's minimal cloud image, and dpkg skips its fsyncs in the builder VM
The minimal image has the same generic kernel and cloud-init as the server one
at about 250 MB instead of 600; the few tools a model's commands use that only
the server image had (jq, file, less, nano, rsync, zstd, lsof, ping) are now
apt packages of pins.json.
2026-10-06 22:14:43 +02:00
feder-cr 1cb4a2194e tests: floors set to what the suites measure after the cuts
Lowered on purpose: the tests of the removed layers went with them (GeoIP pin,
command masking, proxy scrub and stderr filter, engine start-up guards) and the
web sessions/CSP revert took two browser tests. Measured: vitest 2227 on
Windows (2224 on Linux, three PowerShell tests skip there), pytest 2299,
postgres 701, playwright 99.
2026-10-06 21:59:22 +02:00
feder-cr 17bf7bfba5 smoke: drop the two checks of the masking and the scrub that were cut 2026-10-06 21:56:43 +02:00
feder-cr ef4c786f6b web test: the settings of a Dot have the VM proxy card before the danger zone 2026-10-06 21:46:10 +02:00
feder-cr 9426cdedf4 docs: the README and the architecture say what is true after the cuts
WHPX boots with host,-vmx,-svm (measured), the host shows no identity proxy but
the browser server's own messages are not filtered, and a navigation's target
keeps its query.
2026-10-06 21:35:26 +02:00
feder-cr 36d5e451ed engine: drop the start-up guards nanobot does not have (owner: not needed)
Gone: the credentials-on-disk assertion (credentials.py), the requirements-lock
tie between the runtime disk and the golden venv (and both lock copies), the
store's schema-version guard, and the key replacement in provider error text
(failure_text stays the one place an error becomes text). Their tests and
smoke checks go with them.
2026-10-06 21:34:12 +02:00
feder-cr 23f235bdf0 engine: drop the proxy-password scrub of browser output and the MCP stderr filter (owner: not needed)
The browser server's answers and stderr pass as they are; mcp_stderr.py, the
scrubbers, their tests, the fake server's echo controls and the smoke checks
that relied on the scrub go.
2026-10-06 21:31:40 +02:00
feder-cr 7fa07adaa7 engine: tool.called shows a call's target as it is, without the command-masking grammar (owner: not needed)
targets.py keeps one simple line per tool (the command's first line, the path,
the URL without user and password); the approval still masks an identity's
proxy. The masking tests and the smoke check of it go.
2026-10-06 21:29:48 +02:00
feder-cr 1102fd058d image: drop the pinned GeoIP database; the browser library keeps its own (owner: not needed)
The pin, its weekly CI check, the cache pruning, the notices file and the
engine's start check go; the engine no longer sets STEALTHFOX_GEOIP_MMDB.
Tests and smoke checks of that layer are removed with it.
2026-10-06 21:28:01 +02:00
feder-cr cfbe7a0b01 web test: the tab-title test signs in with the single token's session again 2026-10-06 21:22:21 +02:00
feder-cr 6120357eee web: drop the per-sign-in sessions and the CSP layer (owner: not needed)
Reverts 9c86a3f4. The web client keeps its single local token as before.
2026-10-06 21:22:02 +02:00
feder-cr a3ec9a2e4c merge lane-whpx: the Dots' CPU model runs under WHPX
No conflicts; typecheck clean.
2026-10-06 21:15:10 +02:00
feder-cr eea76ad20e vm-manager: the Dots' CPU is the host's without the virtualization extensions, so WHPX runs them
Measured on this Windows 11 host (QEMU 11.1, Intel Core Ultra 7 255H, hypervisor
running): -cpu host and -cpu max pause the VM at its first firmware instructions
with "WHPX: Unexpected VP exit code 4"; -cpu host,-vmx boots the Ubuntu cloud
image to its login prompt, and no other host CPU feature mattered (bisected).
A Dot never runs a hypervisor, so CPU_MODEL = "host,-vmx,-svm" (svm is the AMD
name of the same extension) is the one CPU model on every accelerator: the
Dots' QEMU command line, the image builder's VM and the doctor's accelerator
probe use it. Architecture 3.4 goes from OPEN to measured.
2026-10-06 21:14:59 +02:00
feder-cr a831c0a5e8 README: the VM proxy paragraph in the browser section, not inside the feature list 2026-10-06 21:14:27 +02:00
feder-cr cf2931c68f merge lane-quick: setup --all, the first run in one command
No conflicts; typecheck clean.
2026-10-06 21:14:12 +02:00
feder-cr 108a55a4f6 cli: setup --all runs the whole first run in one command that can be run again
After the clone, "invisible-dots setup --all" does in order what the quick start
did by hand: builds the guest daemon (go build, static linux/amd64, trimmed
paths), gets QEMU and its accelerator ready (the one administrator step stays
one step), builds the web client and the images. It stops with the next action
where a restart or a re-login is needed and carries on from there when run
again; it ends saying how to open the web UI. Each step is the existing code
called as a function (prepareHost is setup's own work, imageBuild the image
command), not a copy. "setup" alone keeps its behavior. README quick start and
architecture follow.
2026-10-06 21:14:01 +02:00
feder-cr b75e5e7b99 vm proxy: a Dot's whole VM can go out through a SOCKS5 proxy
The proxy is a per-Dot secret (vm_proxy in secrets, no install-wide default),
set by PUT/DELETE /api/dots/:id/proxy, the SDK, "invisible-dots secret proxy
--dot <dot> [--clear]" or the VM proxy card of the Dot's settings. Answers say
only whether one is set. It travels in the seed's root-only config.json, which
is written at every start, so a change is used from the next start; nothing
tracks or compares the exit.

In the guest, the runtime's install.sh routes the whole VM through
hev-socks5-tunnel (MIT, pinned by URL and SHA-256 in pins.json, installed by
provision.sh): TCP, UDP and DNS over tun0, IPv6 off, and an nftables table that
lets out only the tunnel's connection to the proxy and the host's network, so
nothing leaves when the proxy is down. A value that is not socks5:// keeps the
VM closed and says so in the log. The browser gets no proxy: it inherits the
egress, as before.

PinnedDownload's checksum list is optional now: hev publishes none, so its
pinned sha256 is the only record. nftables joins the image's packages.
2026-10-06 21:13:35 +02:00
feder-cr 6da60a7fa4 merge lane-vp-id: a proxy per identity is an optional pass-through to invisible-playwright-mcp
Brings ede1d648 (no proxy per identity by default) and 1393b908 (the proxy is one
optional secret string handed to the library as written; our copies of its parsing,
scheme list, port rule and display redaction are gone).

Conflicts:
- apps/web/src/components/IdentitiesTab.tsx: deleted on this branch by the new web
  UI; kept deleted.
- apps/web/src/lib/format.ts, apps/web/test/format.test.ts: this branch's version
  (formatUsd, startOfToday and their tests); lane-vp-id only removed maskProxy, which
  this branch had already removed.
- README.md: the identity-proxy limit takes lane-vp-id's wording, the rest of the
  security list and the channels text are this branch's.
- .github/test-floors.json: this branch's; the floors are set to measured counts
  after the next batch of work.

The new web UI followed the old shared redactProxy, which lane-vp-id deleted: an
identity card now says only whether it has a proxy (hasProxy), an approval shows an
identity's proxy as ***, and the form no longer refuses a proxy (the library judges
it at launch). Web tests and the computer e2e follow; removed: the web checks of a
redacted proxy URL and of a refused scheme (the library owns both).
2026-10-06 21:06:57 +02:00
feder-cr 1393b90846 browser: a proxy per identity is one optional secret string that invisible-playwright-mcp reads, and our copies of what it does are gone (VP1S)
The browser library already owns what a proxy is: invisible-playwright-mcp reads
STEALTHFOX_PROXY (or a browser_open argument) with its own parser, refuses a URL with no
scheme, host or port in its own words, and invisible_core.parse_proxy owns the scheme list
and the port range. Our identity rules re-implemented that (a WHATWG URL parser in two
languages, a scheme list, a port rule, redact_proxy for display), so every change of the
library would have been a second change here. An identity now has an optional proxy string:
blank, null or absent is none (the default), a string is stored as given, as a secret, and
handed to the library unchanged in STEALTHFOX_PROXY at the launch, which is where the
library judges it and answers a bad one with its own error. Only a value that is not a string
is refused, as the wire contract says.

What stays ours is that the secret is shown nowhere:
- an identity says only that it has a proxy: `hasProxy` on the wire (BrowserIdentity in
  protocol.ts, the engine route, the model's browser_identity_list `has_proxy`), the CLI
  column says yes or -, the web table says yes or none; no host, no user. The library has no
  safe display form of a stored proxy to borrow, so none is shown.
- an approval of browser_identity_create shows the proxy as `***`.
- the scrub of what the server says is kept, because the library has nothing for it (it quotes
  the refused URL, password included, in its error: `proxy URL '...' has no port`, and nothing in
  it or in invisible_core hides a secret). It now hides a proxy that carries credentials whole as
  `[proxy]` and a piece of its credentials as `***` (the password in every form a traceback, a
  JSON line or a Proxy-Authorization header repeats it), and leaves alone what the library
  prints of a proxy with no secret (its scheme, host and port). It cuts the credentials out of
  the URL with the standard library's urlsplit, as the library does, and judges nothing.
- the web form's proxy field is a password input.

Deleted (the library owns it): nanobot/dots/identity_rules.py's URL parser, PROXY_SCHEMES,
the port rule, redact_proxy; packages/shared identity-rules.ts redactProxy, PROXY_SCHEMES,
namesAPort and the scheme, host and port checks; apps/web maskProxy; the smoke's port-less
refusal at create.

Removed tests, each because the library owns the behavior or the display form is gone:
- fixture identity-rules.json `redact_proxy` (15 cases) and its two runners, test_redact_proxy
  (engine) and the redactProxy block (TypeScript): redact_proxy no longer exists.
- fixture check_request, 19 cases removed of 50: an http, an https, a socks4 proxy and an IPv6
  host accepted (the scheme list is the library's), a proxy that is not a URL, an http proxy
  with no host, a port that is not a number, a port above 65535, a scheme that is not a proxy
  scheme, host and port with no scheme, four proxies without a port (with and without
  credentials, IPv6, a path), an empty port, the written default ports of http and https,
  a port after a password holding a colon, and the proxy-checked-before-the-limit case. The
  trimmed-proxy case is replaced by one that says the proxy is kept as written; two cases added
  (a string that is not a URL is kept, a non-string proxy is refused before the limit).
- test_browser_manager: the bad-proxy refusals of test_refuses_an_empty_name_a_bad_proxy_and_more_than_max_identities
  (ftp scheme, not a URL) are removed from it, now test_refuses_an_empty_name_and_more_than_max_identities.
- test_browser_tools: the ftp refusal of test_a_refused_request_is_an_error_result_that_says_why.
- test_server: the ftp refusal in test_refuses_what_the_rules_refuse_with_the_status_the_host_expects.
- apps/web format.test: "hides proxy credentials" (maskProxy removed).
- apps/api vm-driver.test: the ftp refusal now checks a number, the one thing still refused.
Rewritten, same intent: the tests that asserted the redacted URL now assert `hasProxy` and
that no user, password or host is in any answer, event or log (test_server, test_browser_tools,
test_browser_manager, api.test, scheduler.test, cli.test, tests/repo/e2e.test proxyIsMasked,
tests/e2e). Added: test_a_proxy_is_not_judged_by_the_manager_and_reaches_the_server_as_written
(4 cases, including one the library refuses), the scrub of a proxy with a user alone, one
that cannot be read, one without credentials and a non-URL (4 cases), the CLI row of an
identity with a proxy.

Smoke (browser, run against the real library, 77 passed; engine, 252 passed): a port-less
proxy is now accepted at create (hasProxy, no password), the model's launch of it fails with
the library's own "has no port" refusal and nothing the model is sent holds the password, and
the identity is deleted; the event counts become 3 created and 2 deleted; the listing checks
say hasProxy. docs/architecture.md, README.md and the smoke and e2e READMEs say the same.

Floors in .github/test-floors.json are lowered by the removed tests net of the added ones:
vitest linux 1192 -> 1157 and win32 1195 -> 1160 (35 fewer, as run: 1160 passed on
win32), pytest linux 2789 -> 2763 (as run: 2763 passed). Go is unchanged.
2026-10-06 20:37:11 +02:00
feder-cr d9194a3149 tests: floors set to what the merged suites measure (M7)
Every floor is the measured count of the merged tree (HEAD 07812b36 for
the guest and container suites, run from a git archive; the Windows host
suites on this checkout):

  vitest      linux 2269, win32 2272   (was 2255 / 2258)
  postgres    linux 697, pg runs 272   (was 711 / 281, lowered: see below)
  whatsapp    linux 25 (9 pg), win32 16 (was 24 / 15)
  go          linux 120, win32 70      (unchanged since lane-sec)
  pytest      linux 2799               (was 2791)
  playwright  linux 101, win32 101     (unchanged)

The postgres floor is lowered, from 711 to 697 tests and from 281 to 272
runs on pg, and the reason is a move, not a loss. lane-lic took the tests
that need the real WhatsApp client out of the default suites because the
client is an opt-in install: the auth-state tests (18 tests, 9 of them on
pg) and the reading and connector tests of baileys.test.ts, plus the
final review's test that Baileys fetches no link of a text, now
packages/channels/test-optin/ (npm run test:whatsapp, the whatsapp floor,
which the whatsapp-client job counts with PostgreSQL: 25 passed, 9 of
them pg). The default suites gained the tests of the client loader
(whatsapp-client.test.ts) and of the API start in their place. The
whatsapp floor is raised by the one test that moved there in this merge.

Suites run on the merged tree, all green: typecheck; vitest 2272 on
win32 and 2269 (3 skipped, the real-PowerShell tests) on linux; postgres
697; test:whatsapp 16 on win32 and 25 on linux with pg, typecheck:whatsapp;
go 70 on win32 and 120 on linux, gofmt, vet, vet GOOS=windows, the static
build, and 131 with -tags privileged as root; pytest 2799; next build;
Playwright with CI=1, 101; engine smoke 252 passed, 0 failed, 0 skipped;
browser smoke 75 passed, 0 failed, 0 skipped; english and claims gates.

Not run: the iso kernel-check job (needs a loop mount, nothing here
touches packages/iso) and tests/e2e (a real golden image under QEMU).

No test removed.
2026-10-06 20:22:34 +02:00
feder-cr 07812b3651 docs: the user split as it is, and the GPL statement only where it is true
Both merged branches left statements that no longer matched the product:

- README.md: the architecture diagram names dotagentd as the computer
  daemon's user. The list of what is not protected now says what is still
  true after the split: it rests on the guest operating system (a kernel
  flaw or an unknown way to root ends it), the daemon holds CAP_SETUID,
  CAP_SETGID and CAP_KILL and one root command (its one door, the token
  port, is closed to dot), and nothing limits the disk, memory or
  processor time of the model's commands inside their VM. The statement
  that dot could replace the computer daemon on its port was already
  removed by the split and stays removed.
- docs/architecture.md 4.1: after the merge the section describes three
  users; its closing paragraph "What stays" is now the list above with the
  reasons and the section that covers each. The older statements that dot
  can read the Dot token and stop dot-agentd are gone with the old unit
  text (the security split wins).
- "Nothing under the GPL is installed by default" was written for the npm
  install and read as a statement about the product. It is now stated for
  the default npm install (README license section, architecture 9.8 and
  11.3, THIRD_PARTY_NOTICES.md, the tests.yml comment and two test
  headers), and architecture 11.3 and the README say the VM image carries
  the Ubuntu guest operating system and the apt packages of the golden
  image, mostly GPL and LGPL software under its own licenses, downloaded on
  the machine that builds the image and not published here.

Comment and prose changes only; no test removed, no floor changed.
2026-10-06 20:06:25 +02:00
feder-cr dab32a4a47 merge lane-lic: Baileys and libsignal opt in, true GeoIP licences
Brings in S3 (lane-lic, tip a39316c2): the WhatsApp client (Baileys, which
depends on libsignal, GPL-3.0) is no longer in the default npm install; it
lives under optional/whatsapp with its own lock file and is installed by
npm run whatsapp:install. A test asserts that a default install holds no
GPL package; the GeoIP data is described with its true licences.

Conflicts and resolutions:
- .github/test-floors.json: kept the final review's playwright floor and
  its description, added this lane's whatsapp floor and its description,
  and the counts of the previous merge. Measured values replace them in
  the floors commit that follows.
- .github/scripts/test-guard.mjs, tests/repo/test-guard.test.ts: both
  sides added a suite name to the usage text, the comment and the list
  CI must run; the result accepts whatsapp and playwright both.
- package-lock.json: regenerated from the final review's lock file with
  npm install --package-lock-only after packages/channels/package.json
  dropped baileys, so the lock holds neither baileys nor libsignal (the
  opt-in client has its own lock under optional/whatsapp).
- packages/channels/src/whatsapp-baileys/baileys.ts: the final review
  added textContent (linkPreview: null, so Baileys fetches no link of a
  text from the control plane) and exported BaileysConnection; this lane
  moved the library behind client.ts and added socketConfig. Both stay;
  WhatsAppSocket.sendMessage in client.ts now types linkPreview, so the
  typecheck made with the client installed holds textContent against the
  adapter's view of the socket.
- packages/channels/test/baileys.test.ts: the final review's two tests of
  what is sent. The one that runs Baileys' own generateWAMessageContent
  needs the library, so it moved to packages/channels/test-optin/
  sending.test.ts (npm run test:whatsapp); the one with a fake socket
  needs no library and stays in the default suite.

No test removed. Verified on this merge: typecheck, vitest 2272 passed,
typecheck:whatsapp, test:whatsapp 16 passed on a Windows host.
2026-10-06 20:04:28 +02:00
feder-cr 17f2106daa merge lane-sec: dot-agentd runs as its own user, the model's work as dot
Brings in the security split (lane-sec, tip d7dc34b1): dot-agentd runs as
dotagentd and holds the Dot's token; the model's commands, files and
browser servers run as dot through credentials; the engine socket refuses
dot (SO_PEERCRED); programs the daemon runs as itself are named by
absolute path; its ambient capabilities are emptied before it starts
anything.

Conflicts and resolutions:
- .github/test-floors.json: both sides raised the vitest and pytest
  floors from the same base, and the final review added the playwright
  floor. Kept the playwright floor and the final review's counts and
  added this lane's increments (vitest +3, pytest +8, go 120/70 which
  only this lane changed). These are provisional sums; the measured
  numbers replace them in the floors commit that follows the second merge.
- No other textual conflict. README.md, docs/architecture.md and
  apps/vm-manager/src/guest-client.ts merged without a marker: the
  security text of this lane replaces the older unit and install text
  (one owner per fact), and the statements the final review flagged as
  stale are rewritten in the docs commit at the end of M7.

No test removed.
2026-10-06 20:00:13 +02:00
feder-cr 9b1fd2fe6b tests: floors raised to what the suites reach now (final review)
Measured on this Windows host on the tree of d27601ef, after the work of the final
review; nothing is lowered.

- vitest: win32 2160 -> 2258 passed, 0 skipped; linux 2157 -> 2255 (the same three
  PowerShell tests skip there).
- postgres (the pg adapter, against postgres 18.4): 687 -> 711 passed, the "pg"
  tests 279 -> 281.
- pytest (engine, docker, python 3.12): 2778 -> 2791 passed, nothing skipped.
- playwright: 95 -> 101 passed.
- go (dot-agentd): unchanged, 64 passed on win32 (110 on linux).
- the engine smoke 238/238 and the browser smoke 75/75 passed on the git archive
  of d27601ef.
2026-10-06 19:39:35 +02:00
feder-cr 65b5360159 web e2e: the focus test finds the first button however many Dots the rail lists (found by the full browser run)
The forced-colors focus test pressed Tab at most 20 times to reach a button or a
field. Alone, on a host with no Dot, the first is the theme toggle; in the full run
the worker's host has the Dots of every earlier spec, the rail lists them as links
before it, and 20 presses ended on a rail link, which has the browser's own focus
outline and no shadow: the "ring is a shadow" assertion then failed. The product
was right and the test was not: it now presses up to 300 times, and says which
element it stopped on in every failure message. Nothing it asserts is loosened.
2026-10-06 19:32:58 +02:00
feder-cr ede1d6486e browser: no proxy per identity is the default, a proxy is an explicit option (VP1R)
The VM proxy design gives a Dot one egress for the whole VM, so a browser identity
no longer needs a proxy of its own to have a place on the internet: by default it
has none and inherits the VM's egress (the VM proxy when the Dot has one, direct
otherwise), and its time zone, language and location follow the exit it uses. The
per-identity proxy stays, as an explicit option for one identity, with every
protection it had (password replaced in all that is shown, the port rule, the scrub
of what the server says, the smoke's checks). When both a VM proxy and an identity
proxy are set, the identity's proxy is reached through the VM's tunnel.

What changed:
- A null proxy now means none everywhere, as an absent or blank one already did:
  the shared rule (checkIdentityRequest), its engine twin (check_identity_request,
  same fixture case), the scheduler's createIdentity. The model's create tool
  already offered null and the engine's route already took it, so the host
  answered 400 to what the guest accepts. BrowserManager.create passes the
  option to the rule as it is instead of building the body around it.
- Wording: the model-facing browser_identity_create tool says to give a name only
  and to use a proxy only when the person gave one; the permission label no longer
  says the profile is created behind a proxy; protocol.ts and protocol.py say
  STEALTHFOX_PROXY is set only for an identity with its own proxy.
- docs/architecture.md (section 6 opens with where a browser exits, the routes,
  the tool table, the launch environment, the echo services, the session-file
  paragraph), README.md (what the browser is, the privacy list, the security
  limits, the diagram) and THIRD_PARTY_NOTICES.md (the GeoIP section) no longer
  present an identity's proxy as how a browser gets its geography. They say the
  VM proxy is designed and not built yet, so today the VM's egress is the host's
  own address.
- The browser smoke checks that the first identity's server runs with no
  STEALTHFOX_PROXY.

Tests added: apps/api (an identity with a name alone, null, blank, and an explicit
proxy), apps/scheduler (the request the guest receives), the CLI listing (the PROXY
column for an identity with none), the engine's tool (no proxy variable in the MCP
environment for a name alone, null and blank, even when the engine's own
environment has one; the schema never requires it) and route (null and blank).
Changed: identity-rules.json, the case 'a proxy that is null' now expects none
(was an error) in both languages.

Removed tests: none.
Floors raised: vitest linux 1190 to 1192, win32 1193 to 1195, pytest 2786 to 2789.
Nothing lowered.
2026-10-06 19:19:53 +02:00
feder-cr d27601ef29 engine: the browser-installed check uses the standard library's lookup of an executable (final review: reuse, checks.py)
Finding: checks.py wrote its own lookup of the browser command (absolute or
relative path, else each directory of PATH, isfile and X_OK), which is what
shutil.which does. REUSED: _is_installed is shutil.which(command, path=path), the
guest's PATH still passed in (the engine's own, not the process's). The behavior is
the same for every case the tests cover (a name found through the path, an absolute
path with an empty path, a file that is not executable, no path at all), and those
tests are unchanged and pass; no new test is added because there is no new behavior
to fail before. 9 lines fewer, no second copy of the rule.
2026-10-06 19:09:48 +02:00
feder-cr 53edfb157b web e2e: the Computer spec seeds the automation that a stop pauses (a test of fdcd2071 that the browser run found stale)
fdcd2071 made the Automations view say "Paused: you stopped this computer" only
for a Dot whose engine reported a due run (next_automation_at is set): a Dot with
no automation has nothing to pause. The e2e still stopped a Dot with none and
expected the paused text, so it failed once the browser suite was run (the unit
tests of the view passed, they seed a due run). The spec now has the engine report
a run (automation.next_run), checks that the page says "Next automation:", then
stops the computer and checks the paused text. The assertion of the paused text
is kept, not weakened: its precondition is now stated.
2026-10-06 19:09:47 +02:00
feder-cr f9f263fdec web: wording that said less than the product does, the order of the tabs, the Dot named in the tab title, a field border that can be seen, and the last activity on the Home card (final review LOW: web wording/a11y)
Findings -> what was done

- Model spend said nothing about its unit or that tokens are not kept. FIXED
  (usage-tab.tsx): "in US dollars as OpenRouter priced them. Tokens are not
  reported, only the cost." Test: computer-page.test.tsx.
- Delete Dot said what the computer loses and not that the channels go with it
  (the Telegram or WhatsApp link, its token or login, the people paired, a key
  stored for this Dot alone). FIXED, in the paragraph and in the dialog.
- The permissions panel said a tool is offered unless its permission is denied;
  the engine also leaves out a memory tool while memory is off and the tools that
  create or delete browser identities while the Dot does not manage its own. FIXED:
  the sentence says it (engine/permissions.py is the owner of the rule; the text
  now agrees with it).
- The Dot's tabs were ordered Channels before Activity; the design (1.4) orders
  them by what the Dot says and does, how it is reached, how it is set up. FIXED in
  DOT_TABS (the one owner of the order); dot-shell.test.tsx asserts it.
- The tab of a Dot's page said only "Chat", so several Dots in several tabs were
  not told apart. FIXED: lib/dot-title.ts asks the control plane for the Dot's name
  on the server, with the web server's own credential and only for a request that
  carries a valid session (a title must not hand a Dot's name to anyone who is not
  signed in), settles for the tab alone on any failure within 2 s, and every page of
  a Dot uses it ("Chat - fares - invisible_dots"). Test: dot-title.test.ts (signed
  in, not signed in, an unknown Dot, a slow or failing control plane).
- The border of a form field (--input) was drawn at 1.5:1 (light) and 1.7:1 (dark) against its background,
  below WCAG 1.4.11's 3:1 for a control's boundary. FIXED in tokens.css for both
  themes, and tokens.test.ts now checks input against background and card (it
  failed with the old values).
- The Home card did not say when a Dot last did anything. FIXED: a "Last activity"
  line from the newest event of the Dot (components/home/last-activity.tsx);
  home.test.tsx.
- The Memory page said "There is no note called X (any more)" for a note that may
  be in a folder the listing cut off. FIXED: when the listing is cut it says the note
  is not among those listed and sends the person to the Files view; memory.test.tsx.
- The Activity feed named channels by their raw kind ("telegram"). FIXED in
  events/view.ts with CHANNEL_LABELS, the one owner of the names (an unknown kind is
  shown as the host wrote it); event-view.test.ts asserts "Telegram" and "WhatsApp".
  The assertions changed because the wording did: the facts they check are the same.
2026-10-06 19:09:39 +02:00
feder-cr 6b74cf53e0 api, web: a Dot's approvals are chosen in the database, its tasks are read past the newest 200, and the Inbox says what the keys select and shows each Dot's face (final review LOW: durability/contract and web-owned facts)
Findings -> what was done

- The Inbox's History for one Dot read the pages of every Dot's answers and
  filtered them in the browser, so a Dot whose answers were all older than fifty
  other Dots' answers saw an empty table (with "show older" as the only way on).
  FIXED at the origin: GET /api/approvals takes dot_id (an id or a name, validated
  like the other parameters), scheduler.listApprovals resolves it as the event log's
  history does (a deleted Dot is read by its id), and the SDK's listApprovals takes
  { dot }. useHistory(dotId) asks for it and holds nothing of another Dot's rows
  when the Dot changes. The permission filter stays in the browser: the route has no
  such filter and a second one was not invented for it. Tests: api.test.ts (the
  route, its refusals, the Dot by name), the inbox test "asks the control plane for
  the Dot's own answers" (one answer under sixty newer ones of another Dot is on the
  first page), and the "none of the newest answers are under these filters" test
  now uses the permission filter, the one left in the browser, which keeps what it
  asserted (it is not weakened: the filter that can come up empty changed).

- GET /api/dots/:id/tasks answered the newest 200 and the older ones were not
  reachable at all; the Tasks page only said so. FIXED: listByDot takes before (the
  id of the last task of the previous page; a keyset on (created_at, id), an id that
  is not the Dot's lists nothing), the route takes limit and before, the SDK's
  listTasks takes { limit, before }, and the Tasks page has a "Show older tasks"
  button that reads one page at a time. The page keeps every task it has seen (a
  task is never deleted): the newest page is read again on every change and a task
  that a newer one pushed off it is not lost; the first version of this lost it and
  a test found it. TASK_LIST_LIMIT's comment and docs/architecture.md say what the
  route does now. Tests: repositories.test.ts (the cursor, a foreign id), api.test.ts,
  tasks.test.tsx (the queue is complete after the older page, the notice goes away,
  a task pushed off the newest page stays) and the e2e that counts 200 then 204.

- The Inbox's keys moved a drawn selection that a screen reader cannot hear, and the
  cards of several Dots looked alike. FIXED: j and k announce the card now selected
  ("fares: Wants to run a command. 2 of 3."), and a card shows its Dot's face
  (DotAvatar gets an xs size), decorative because the name is said beside it.
  Tests: inbox.test.tsx for both.

- useResource kept the error of the previous key while a new key loaded. FIXED in
  ui.tsx (one line), with the use-resource test.

Not split: apps/web/test/inbox.test.tsx holds the tests of all three Inbox items and
test/support/control-plane.ts the fake routes of the first two; they are in this
commit together.
2026-10-06 19:09:08 +02:00
feder-cr 9c86a3f470 web: each sign-in is a session of its own that the server can end, and every page is served under a Content Security Policy with a nonce (final review LOW: session + CSP)
Findings -> what was done

- The session cookie held a value derived from the API token alone: every sign-in
  produced the same cookie, signing out only cleared it in the browser, and a copy
  of it (a server on another port of the same host can read it) stayed good for as
  long as the token did. FIXED at the origin, lib/proxy.ts: a sign-in makes a random
  id (256 bits), kept by the server with the time it ends (a week) and a fingerprint
  of the token it was made with; hasSession looks the id up and compares the
  fingerprint (so a changed token ends every session); DELETE /session drops the
  session in the server; the cookie gets Max-Age. The store lives in the process's
  global because the sign-in route and the proxy are separate bundles of the Next
  build. A restart ends the sessions: written in the doc comment. sessionValue is
  deleted, not kept beside startSession. Tests: proxy.test.ts (two sign-ins are two
  sessions, an ended or expired one and one of another token are refused, sign-out
  ends it in the server) and the e2e "signing out ends the session in the server".

- No Content Security Policy, no nosniff, no referrer policy, no frame protection.
  FIXED: src/proxy.ts (Next 16's proxy convention) makes a nonce of 128 random bits
  per request and puts the policy on the request (for Next to mark its scripts with)
  and on the response; the policy (lib/csp.ts, one owner) is script-src 'self'
  'nonce-..' 'strict-dynamic', default-src 'self', connect-src 'self', img-src with
  blob: and data: (the pictures of the Dot's computer), frame-ancestors, object-src
  and base-uri 'none', and 'unsafe-eval' only in development, where React reads
  server stacks back with it. The root layout gives the theme script its nonce.
  next.config.ts adds X-Content-Type-Options, Referrer-Policy and X-Frame-Options to
  every response. Tests: csp.test.ts (the policy, the nonce, the matcher that leaves
  out the API proxy, the session route, built files and prefetches) and the e2e
  "every page is served under a Content Security Policy...", which visits every page
  of a Dot in a real browser and fails on any securitypolicyviolation or console
  report, and on markup with a script of its own that runs.

Found while writing the CSP e2e, both written down here

- zod probes new Function("") once, inside try/catch, and the strict policy refuses
  it: the browser reports that one eval violation. It is harmless (the library falls
  back to its interpreted parser) and comes from a dependency, so the e2e ignores
  exactly that report (blockedURI eval, from a built chunk) and every other
  violation still fails it. Not fixed in code: zod's jitless switch has to be set
  before the schemas of packages/shared are built, which no web module can promise.
- Chromium reports a connect-src violation for the blob: URL of an image it cannot
  decode. The harness's FakeGuest answered a screenshot with the 8 bytes of the PNG
  signature, so the Computer page tripped it. FakeGuest.screenshot now answers a
  real 1x1 PNG (it still starts with the same signature, so the tests that check
  it are unchanged). The product has no such violation: a real picture decodes.
- The first version of the injection check appended a script element from the
  browser's own evaluate, which a strict-dynamic page allows (a script created by a
  trusted one). It proved nothing, so it now writes markup with a script into the
  page, as an injection does, and that one must not run.
2026-10-06 19:08:41 +02:00
feder-cr fdcd2071f1 host, web, engine: the cheap LOWs of the final review, first batch (durability, contract, one owner of a fact)
Final review, LOW "durability and contract LOWs of earlier reviews that still
hold", item by item:

(1) A wake for an automation used a plain ensureReady outside the lock and did
not re-read stop_reason -> fixed. Lifecycle.wakeForAutomation takes the per-Dot
lock and asks again, from the same place the pass asked (stoppedDotsWithAutomationDue),
whether the Dot is still one to wake; a person's stop that came in front of the
wake keeps the computer off. Test: two idle-asleep Dots with a due automation, the
person stops one before the wakes run; only the other starts.
(2) The person's stop of a sleeping computer wrote no event -> fixed in the commit
of the commit points (computer.stopped {reason: "user"} in the same write).
(3) automationsNote (web) and `status` (CLI) said "Paused" for a Dot with no
automation -> fixed: paused is said when the engine had reported a run that is due.
(4) A failed setCredentials restarted the runner with the binding read before the
stop, so messages delivered meanwhile were sent again -> fixed: the binding is read
again after the stop (its cursor is the stop's). Test: the Dot answers during the
credential check, the write is refused, and the answer arrives once (it arrived
twice before the fix: the test was run against the old code).
(5) A fresh engine.sqlite restarts the outbox seq while computers.event_cursor
stays high -> REJECTED, not cheap or safe: the new store's events would also collide
on events (dot_id, guest_seq) unique, which the host reads as "already stored", so
a cursor reset is not enough; a correct fix is a store id in the agent health, an
epoch in the events key and the cursor, and a migration, across the engine,
dot-agentd's health passthrough, shared, the database and the lifecycle. No code
path or documented operation replaces engine.sqlite in an existing Dot (the disk
is the VM's, and a destroy deletes the Dot), so it stays open for the owner.
(6) migrate knew migrations by name only -> fixed: a database that records a
migration this build does not ship is refused with a message naming it, before
anything runs (tests on both adapters, and the fresh database).
(7) dot.updated had two meanings, shown alike -> fixed with the discriminating
field the Dot's ERROR already carries: Activity says "The Dot failed" with the
error, and "Configuration updated" for a save (architecture 9.2 says both).

Final review, LOW "web layer still owns or copies facts":
- approval-view.ts takes WORKSPACE and HOME from GUEST_PATHS (the one owner of
  guest paths); its resolution stays its own, because the rule differs (the
  engine's file tools read a relative path against the workspace and resolve "..",
  the host's file routes read it against home and refuse "..").
- FRAME_ERROR_CODES `satisfies Record<string, IdentityErrorCode>`.
- The JPEG rule is the engine's (BrowserManager.frame answers frame_failed for any
  other media type, with a fake-server test) and the host's copy of it is deleted
  with its test (the vm-manager test that checked it: one test fewer, its rule now
  tested at the owner).
- The note of an approval: APPROVAL_NOTE_MAX (500) is shared, the web input uses it
  and the host refuses a longer one with a 400 (tests on both).
- APPROVAL_PROMPT_TEXT_MAX (300, moved from channels' ARGUMENTS_MAX) is shared and
  the channel setting's description renders it.
- One relink type (ChannelRelink; DotRelink is it without the Dot).
- A file above MAX_HOST_FILE_BYTES is no longer offered as a download (the host
  answers 413); the preview says why (tests).

Final review, LOW "stale comments": approval-text.ts and spend.py reworded to the
documented rules (the query of an approval URL stays; a failed request is not counted,
though a cut stream may have been billed).

Test floors: one vitest test fewer (the host's JPEG check), the rest are additions;
the floors are measured at the end.
2026-10-06 18:30:40 +02:00
feder-cr cbc28853c5 web: an offline banner, the terminal session named, and a focus that forced colors keep (final review)
Final review, MEDIUM: design gaps of the web at HEAD: no offline banner,
tool.called tty not rendered, focus invisible under forced colors -> fixed.

- OfflineBanner (components/shell/offline-banner.tsx), an alert at the top of
  every signed-in page, from the live stream (reconnecting or ended) or the 30
  s health check failing (lib/offline.ts words each: the control plane does not
  answer and the page connects again by itself; or live updates stopped and a
  reload is needed). Tests: the words, the shell (the stream dropped and refused,
  the alert, the Reconnecting in the rail, and gone when it answers; health alone),
  and a Playwright spec that stops a control plane of its own and sees the alert
  under 768 px too, near the top and without a horizontal scroll.
- tty: toolLabel(tool, tty) says "Started a terminal session" for exec with tty
  (design 1.9), used by the chat's steps, the task's story and the Activity
  rows, with the unit tests and a Playwright spec across the chat and Activity.
- Focus: `outline-none` is gone from all 14 controls and the shared button,
  input, textarea, switch and badge classes; each says `focus-visible:outline-hidden`
  (Tailwind 4: no outline normally, a 2px one under forced colors, which draw no
  box-shadow), and the radio cards and the composer say it for their own triggers.
  The ring is drawn at 80 percent, not 50 (2.1 to 1 in light, so a ring that did
  not meet WCAG 1.4.11): tokens.test.ts computes every ring opacity the sources
  use over background, card and popover in both themes at 3:1, fails on any
  `outline-none`, any focus ring without its outline-hidden, and a selected Inbox
  card drawn with a ring (it is an outline now). A Playwright check emulates
  forced colors and reads the focused control's computed outline (solid, 2px, a
  system color) against the shadow it has otherwise.
2026-10-06 18:14:36 +02:00
feder-cr a13319297e web: the browser tests run the standalone server the product ships, with its reduced environment (final review)
Final review, MEDIUM: the Playwright suite runs `next start` with the whole
environment, not the standalone server.js the product ships -> fixed. The 95
tests never exercised the shipped entry, its copied static tree or its reduced
environment.

harness.ts starts the web client with the CLI's own locateWebBuild and
startWebServer (apps/cli/src/web.ts): .next/standalone/apps/web/server.js with
its copied static files, in the environment webEnvironment() allowlists out of
the harness's own (home, token, the API's address), the web server's exit with
the harness's pid, and the CLI's wait and error text when it does not start. The
wording of the harness and the README is the product's ("the built web client
as the product runs it"); the web config test fails if the harness goes back to
`next start` with the whole environment. The harness also gains stopApi(), the
control plane that stops while the web client goes on, for the offline banner's
spec. The suite needs `npm run build --workspace @invisible-dots/web` (which
also copies the static files), as the README says.
2026-10-06 18:14:27 +02:00
feder-cr 506f19bff6 api: stopping the server does not wait for a page to close its live stream (found by the final review's harness change)
The browser tests' harness now runs the web client as the product does, and
stopping the control plane while a page was open hung for good: Fastify closes
its server and waits for the connections before it runs the onClose hooks, and
the hook that ends the live streams (`/api/stream`, the WhatsApp link) was an
onClose hook, so a stream waited for the close that waited for the stream.
`invisible-dots server` with a tab on the web UI would not stop on Ctrl+C
until the tab was closed.

The hook is a preClose hook now (it runs before the server stops listening and
waits). Test: a stream is held open, the server closes within the wait and the
stream ends; it fails with the onClose hook (the close is still waiting after 15
seconds).
2026-10-06 18:14:26 +02:00
feder-cr ffae426a18 web: the agent's state ends with its computer, and a page opened mid-turn reads it (final review)
Final review, MEDIUM: the agent state in the web is live-only and never
cleared: a computer stopped mid-turn keeps saying Thinking, and a page opened
mid-turn says Idle -> fixed.

- dot-live: computer.stopped, and computer.state other than RUNNING, end the
  agent's state and what its task last reported (only a computer that runs has an
  agent). Each agent event is applied only if its id is newer than the newest
  one that set or ended the state (agentAt), so a replay or a late read of the
  log never undoes what is known.
- The shell reads, once, the newest agent.state or agent.started of the Dot whose
  page is open and whose computer runs (one request, limit 1, newest first, the
  two types in the database) and applies it like a live event; a stopped computer
  is not asked about, a failed read leaves the stored status to say what it can.
- The fake control plane keeps that request apart (agentQueries) from the
  requests of a view, so the tests of what a view reads count only its own; the
  few assertions that counted raw requests to /events now count eventQueries.

Tests: dot-live (every way a computer goes off, a running state kept, another
Dot's untouched, the agent that comes up again, an older event not undoing a
newer, the log read), chat (a page opened mid-turn shows the working row and
its last step with the one request; a stopped computer is not asked; a stop in
the middle of a turn takes the row away).
2026-10-06 17:46:21 +02:00
feder-cr d7dc34b1d9 guest: nothing dot-agentd runs as itself is found through a directory the model writes, and its ambient capabilities are emptied whatever --run-as says (sec review)
Finding 1 (high), confirmed and fixed. The unit gave the daemon
PATH=/home/dot/.local/bin:..., DefaultPowerOff started "sudo" through
os/exec.LookPath in that PATH as dotagentd, and dot owns /home/dot (it can
chmod o+x ~ ~/.local ~/.local/bin and plant ~/.local/bin/sudo), so the next
stop or sleep of the VM ran the model's program as dotagentd: it reads the
token and, being in group dotagentd, enters the engine's socket.
- dot-agentd.service: the daemon's PATH is system directories only.
- The PATH of the model's commands is built by the one owner of the model's
  environment, Server.execEnv (modelPath: the Dot's ~/.local/bin, then
  SystemPath), and no longer inherited from the daemon. A PATH a request
  names (POST /v1/proc env) still wins, as before.
- DefaultPowerOff is /usr/bin/sudo -n /usr/bin/systemctl poweroff (still
  exactly what the sudo rule names); Bash defaults to /bin/bash and
  ImportBin (and --import-bin) to /usr/bin/import, so no program the daemon
  starts is looked up in any PATH.
- The engine's own unit keeps /home/dot/.local/bin: it only checks that
  invisible-playwright-mcp exists there, and starts every command through
  `dot-agentd relay` by absolute path with a system-only PATH, so nothing
  the engine runs is found there.
Tests: guest-files (the dot-agentd unit's PATH has no entry outside the
system directories), Go (TestTheProgramsTheDaemonStartsAreNamedByPath,
TestExecEnvPathDoesNotDependOnTheDaemonsPath, the poweroff contract test
and TestExecEnvIsTheAccountsNotTheDaemons updated to the new argv and PATH),
and a smoke check that opens dot's home, plants ~/.local/bin/sudo, asks for
the poweroff and asserts it never ran. The smoke now starts the daemon with
the PATH read from the unit itself.

Finding 2 (medium), confirmed and fixed. ResolveRunAs returned before
emptying the ambient set when the daemon already was the user to run as, and
main skipped ResolveRunAs for an empty --run-as, so the three ambient
capabilities of the unit reached every command of the model (CapAmb e0:
setuid(0) works). serve now calls ForgetAmbientCapabilities first, before
anything starts, whatever --run-as says; ResolveRunAs keeps only the check
that the daemon holds the capabilities. An ambient set that is empty already
is left alone, so a cgo build (the unit tests on CI) runs where nothing is
granted. The empty/self mode stays: tests and development use it, and with
the set emptied it no longer carries the capability, only the uid (which the
daemon says in its log). Test: main_privileged_test.go starts the daemon
under setpriv with the unit's three ambient capabilities and an empty
--run-as and asserts CapAmb is empty (it failed before the fix with e0);
run.sh now runs the privileged tests of every package, not only agentd's.

Floors: go linux 118 to 120 and win32 68 to 70 (two tests added that run on
both). No test of kept behaviour was weakened; the poweroff contract test
and the unit's PATH test changed because the contract they pin changed.
2026-10-06 17:37:01 +02:00
feder-cr 2efb7e6b84 engine: a browser_type text is bounded by what the call's timeout allows (final review)
Final review, MEDIUM: a long browser_type can outlive the 120 s tool timeout
while the MCP server keeps typing (reuse item 7 still open) -> fixed with the
interim the review names, until the owner decides the pin.

The pinned MCP (0.70.2) fills a field by typing it key by key at the pace of
the library's typing persona, 120 to 280 ms a key. A call past 120 s is
cancelled on the client, the identity's lock is released, and the next call
runs on a page still being typed into. browser_type's text now has a maxLength
derived from the two facts that decide it: TYPE_TEXT_MAX = half of
REQUEST_TIMEOUT_S at the slowest pace (214 characters), the other half being the
page's and the pauses' share. REQUEST_TIMEOUT_S is browser.py's one number (the
manager's default), the description tells the model the limit, and the schema
refuses a longer text before it reaches the server.

Tests (fake MCP server, as the others): the longest text is sent and a text one
character longer is refused with "at most 214" and the server is asked once;
the arithmetic is pinned (the longest is typed in half the time, one more key is
not), and the manager's default timeout is that constant. The move to 0.70.10,
whose library types in the background under an input lock, stays the owner's
pin decision (decisions.md); this limit would then be lifted.
2026-10-06 17:35:57 +02:00
feder-cr a39316c217 channels: the typecheck with the WhatsApp client installed checks the socket options and the auth state, and the loader refuses a client that is not the pinned release (lic review)
What and why

Finding 1 (typecheck no longer checked the socket config or the auth state):
confirmed. makeWASocket took `object` and the credentials were `{ me?: unknown }`,
so a renamed or mistyped option (markOnlineOnConnect, syncFullHistory,
shouldIgnoreJid) and an auth state the library would reject both compiled. The
earlier commit's sentence that the new typecheck did what the old compile-time
check did overstated it. Fixed at the origin:

- client.ts declares WhatsAppSocketConfig, exactly the options the adapter
  passes, and WhatsAppLogger; baileys.ts builds the object in one exported
  function, socketConfig, whose declared return type makes a typo in the adapter a
  compile error even without the library.
- The library's credentials and key data come in as type parameters (Creds,
  KeyData) of WhatsAppAuthState, WhatsAppClient, BaileysAuthLib and AuthStore,
  defaulting to the adapter's loose view, so the typecheck can instantiate them
  with the library's own AuthenticationCreds and SignalDataTypeMap.
- test-optin/shape.ts asserts, one way and strictly (methods are compared both
  ways, so the module-fits line alone could not): socketConfig's result is
  assignable to the parameter of makeWASocket; no key of WhatsAppSocketConfig is
  absent from the library's options; AuthStore's state is assignable to
  AuthenticationState. Checked by mutation, each making npm run typecheck:whatsapp
  fail: an option renamed, an option given the wrong type, a parameter type
  changed, keys.get returning another type, creds typed loosely.
- The sentences in architecture.md (WhatsApp, licenses) and in client.ts now say
  what is proved and what the method comparison does not.

Finding 2 (the loader did not check the installed release against the pin):
confirmed. entryOf only looked for node_modules/baileys, so after a pull that moved
the pin in optional/whatsapp/package.json the old release loaded silently.
Fixed: client.ts reads the one version package.json declares (it stays the one
owner of the pin) and the version in node_modules/baileys/package.json; any
difference, or either missing, is WhatsAppClientVersionError (names both versions
and `npm run whatsapp:install`). One function, whatsappClientProblem, replaces
whatsappClientInstalled and is what loadWhatsAppClient and the start.ts warning
both use, so the server warns at start with the exact reason. Tests that fail
without the check (verified by disabling it): whatsapp-client.test.ts (release
differs and is fixed by installing again; pin or installed version missing) and
start.test.ts (the warning names both releases). The stand-in client of the tests
now declares its pin like the real install does. tests/repo/whatsapp-optin.test.ts
follows the renamed variable in the loader's one import.

Tests: no test removed or weakened. whatsappClientInstalled's assertions became
whatsappClientProblem's (null or the error). Floors raised for 3 added vitest
tests: vitest linux 1196 to 1199, win32 1199 to 1202 (measured 1202). The
whatsapp suite is unchanged (15, shape.ts is a typecheck).

Verified: npm run typecheck, npm run typecheck:whatsapp, npm run test:whatsapp
(15), npx vitest run (1202 passed), go test ./... in guest/dot-agentd.
2026-10-06 17:22:40 +02:00
feder-cr 2d26cced13 host: a change of state and its event commit together, and recovery tells of a stop it finds (final review)
Final review, MEDIUM: host lifecycle commit points are several autocommit
writes with the event after them; a kill -9 in between is not repaired
-> fixed. Two rules coexisted (cancelTask and resolveApproval wrote their event
in the transaction, the rest after it). Now every commit point is one
transaction with its events inserted first (the event-order lock rule) and
published after COMMIT:

- Lifecycle: #commit/#logState/#logDotStatus; #recordStarted (process, images,
  state RUNNING, computer.state, computer.started; also the reboot),
  #recordStopped (state STOPPED with its reason, computer.state,
  computer.stopped, the Dot idle), #recordError (computer and Dot in ERROR with
  their events), the deletion (dot.deleted with the delete), and every single
  state change.
- Scheduler: createDot (dot.created and computer.state with the two inserts),
  createTask (task.created with the insert), updateDot (dot.updated with the
  config save) and the "always allow" of resolveApproval (dot.updated in the
  decision's transaction). dot.updated no longer carries pushed_to_guest: it was
  written after the push, so it could not be in the commit, and nothing read it
  (the Activity page shows the name only); the push stays after the commit.
- Channel hub: channel.status (statusWouldChange asks the rule setStatus
  applies, so the event can come first), channel.changed for a pause, a
  resumption and a removal (a failure to log it now fails the change instead of
  being swallowed), channel.peer.paired. The pairing's event follows its rows
  (whether the code was good is known only from the update that uses it); no
  writer of events takes a pairing's or a peer's rows, so that order cannot
  deadlock, as the comment says.
- Recovery: a computer found off that the rows still call STOPPING (or any state
  but STOPPED) is recorded now with its computer.stopped, with the reason the
  stop was asked for or "exited"; before, the kill after the driver stopped the
  VM left no stop event for good.

Tests: killedAt (database/testing) makes one write of a transaction throw, as a
process killed there; commit-points.test.ts (scheduler) kills a stop at its last
write (STOPPING, nothing told, then recovery finishes it and tells of it once),
a deletion, a creation, a task, a config save; hub-commit-points.test.ts kills a
status, a pause, a removal and a pairing; the database test checks
statusWouldChange against setStatus on every kind of change and repeat.

LOW (2) of the earlier-review list, fixed here since it is the same write: the
person's stop of a computer that is asleep only wrote stop_reason and no event,
so the automations note of another tab kept saying it would wake; it now logs
computer.stopped {reason: "user"} in the same transaction (once: asking again
says nothing).

Floors: the new tests are counted when the floors are measured.
2026-10-06 17:22:11 +02:00
feder-cr 064faa4547 engine, host: every outbound event is pinned across the languages, the host's own messages too, and a refused event leaves a host event (final review)
Final review, MEDIUM: only automation.next_run of the outbound events is pinned
across languages, and the host drops for good any event its schema refuses
-> fixed.

- Engine to host. test_wire_shapes.py no longer writes the one automation event
  by hand: a real engine (the scripted-model harness) runs a chat that opens a
  terminal and writes a note, tasks that report progress, complete, fail and are
  cut in the middle of a call, calls that ask for approval (in the chat and in a
  task), the browser identities, and the next-run report, and the outbox its
  own writers filled is pinned in wire_shapes.json: one event of each type and
  each set of keys and kinds of value, with ids and durations made stable.
  engine-shapes.test.ts requires every one to parse back unchanged (the
  schemas strip an unknown key, so a key the host does not know fails), the
  types to be exactly OUTBOUND_EVENT_TYPES, and the optional keys (tty,
  interrupted, task_id, in_reply_to, spent_usd) to be written.
- Host to engine. apps/scheduler/test/host-shapes.test.ts runs a real scheduler
  against the fake guest and writes what it sent (a message, tasks with and
  without a priority, a cancel, answers with and without a note, the config of a
  minimal and of a fully set Dot) into host_wire_shapes.json;
  test_host_wire_shapes.py parses each with the engine's own parsers and
  requires every key to survive.
- A refused event leaves a trace. GuestClient.events hands a message it
  refused to onRefused, awaited in order with the events, and resumes after its
  seq; the pump records it as the host event guest.event.refused {seq, type,
  problem} and moves the cursor past it in the same transaction. The Activity
  page words it, the fake guest can write one, architecture 9.3 says it.

Python tests need UPDATE_WIRE_SHAPES=1 to write the file again (the engine
script in .local/port does not pass the variable into the container; the
two fixtures were written with it passed).
2026-10-06 17:00:15 +02:00
feder-cr 60ac1787de guest: dot-agentd runs as a user of its own and starts the model's work as dot; the engine's socket refuses dot's processes (S1)
What
- dot-agentd runs as the new user dotagentd (builder seed, per-Dot seed, unit,
  install.sh, provision.sh). Every command (POST /v1/exec, the engine's relay),
  the browser's MCP servers, the screenshot and the pseudo-terminal of a
  command run as dot through SysProcAttr.Credential; a file route acts as dot
  for the length of the request (the serving thread's file-system user and
  groups are dot's), so files are opened, created and owned as dot's and the
  home confinement of the TCP listener is unchanged on top of it. A program is
  looked up in the PATH it is started with, as dot sees it. The environment of
  those programs has HOME, USER, LOGNAME and SHELL of dot's account.
- The unit gives it AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_KILL and
  nothing else; at start it checks it holds them and empties its ambient set
  in every thread, so no program it starts holds one (a command of the model
  has CapPrm, CapEff and CapAmb zero). --run-as (default dot) names the user;
  an empty value or the daemon's own user leaves the work with the daemon's uid
  and is logged as a warning.
- The token file is dotagentd's, 0600. /run/invisible-dots is dotagentd:dotengine
  and /run/invisible-dots-agent is dotengine:dotagentd (2750), so only the two
  daemons reach either socket and dot reaches neither. The poweroff sudo rule
  moved from dot to dotagentd (the user that runs it): the model has no sudo
  rule now.
- The engine's API socket refuses a peer whose uid is the model's user
  (SO_PEERCRED, 403 forbidden_peer, also when the kernel cannot name the peer),
  as a second wall under the directory modes; INVISIBLE_DOTS_MODEL_USER names
  the user (default dot) and the engine does not start when it does not exist.

Why
The model's commands ran with dot-agentd's uid, the engine's socket directory
admitted group dot and the engine's API has no authentication, so one command
could PUT /config with every permission allow, POST /events an approval of its
own parked call, and read the Dot token, which also opened /v1/agent/* on port
1024. Architecture 4.1 already named the fix (a user of its own for
dot-agentd); this is it, and architecture 4.1, 4.2, 4.3, 5.2 and 1.1, the
READMEs and the unit and seed comments say it.

Tests
- Go: untagged tests of the account lookup, the environment and the no-account
  path; privileged tests (build tag privileged, run as root in the smoke's Go
  container, where a skipped one fails the run) for uid, groups, environment,
  file ownership and permission, home confinement, the terminal's owner, the
  kill of another user's process group, the screenshot and the PATH lookup.
- Engine: SO_PEERCRED refusals (PUT /config, POST /events, POST /secrets,
  /health, the event stream), the model user at start.
- Smoke (engine suite, dot-agentd started through setpriv with the unit's three
  capabilities): as an exec command the model cannot enter either socket
  directory (connection refused), is refused 403 forbidden_peer by the engine
  even with directory and socket opened to everyone (PUT /config, POST /events,
  POST /secrets, GET /health), cannot read the token (its file, the daemon's
  environment, memory map, command line, a sweep of the readable files), gets
  401 on 127.0.0.1:1024 for the daemon's routes and the engine's behind it,
  cannot signal the daemon, has no sudo rule and holds no capability, also
  through the engine's relay and on a terminal it owns. Engine smoke 251
  passed, browser smoke 75 passed.
- vitest: unit users, the daemon's capabilities, tmpfiles groups, the seed's
  users and token owner, the platform-branch list.

Removed tests: none. Changed expectations of kept behavior: the token file and
the two socket directories are no longer dot's (smoke, seed test, install.sh
test); the engine's socket still reads through a link out of home, now checked
on /etc/hostname because a link to the token file is a 403 for dot (smoke); the
poweroff rule belongs to dotagentd (seed test).

Floors: vitest 1187/1190 to 1190/1193 (linux/win32), go 110/64 to 118/68,
pytest 2778 to 2786. None lowered.

Existing Dots: the seed's user list makes dotagentd on a golden image from
before this change, so the new runtime disk starts there too; install.sh and
provision.sh still refuse an image that has no such user.
2026-10-06 16:43:54 +02:00
feder-cr ee7b80cf76 licenses: nothing GPL is installed by default; the WhatsApp client is an opt-in install, and the GeoIP notices name the data licenses (S3)
What and why

npm ci installed Baileys and its GPL-3.0 dependency libsignal for everyone. Now
no package of the default install needs a GPL license:

- Baileys is declared by no workspace (not regular, dev, optional or peer) and is
  out of the root package-lock.json (49 packages fewer: libsignal, curve25519-js,
  whatsapp-rust-bridge and what only the client needed).
- It lives in optional/whatsapp/: its own package.json (Baileys 7.0.0-rc14, one
  exact version) and package-lock.json (integrity hash of every package),
  outside the workspaces. One command installs it: npm run whatsapp:install
  (npm ci --prefix optional/whatsapp --ignore-scripts).
- packages/channels/src/whatsapp-baileys/client.ts is the one file that names the
  library. It looks for it in that folder when a connection opens, loads it by
  path only if it is there, and otherwise throws WhatsAppClientMissingError, whose
  message is the command and INVISIBLE_DOTS_WHATSAPP=1. That text is the channel
  status, is logged at server start when the variable is set without the client,
  and is what linking answers when WhatsApp is off (WHATSAPP_ENABLE_HELP).
- Nothing imports the library by name, not even as types: the adapter writes down
  what it uses of it (WhatsAppClient) and compiles without it. A typecheck with the
  client installed (npm run typecheck:whatsapp, tsconfig.whatsapp.json, test-optin/
  shape.ts) proves the real module has that shape and that the key groups are the
  library's own, which the old compile-time check did. The tests that need the real
  library moved to packages/channels/test-optin and run with npm run test:whatsapp
  (vitest.whatsapp.config.ts); a new CI job, whatsapp-client, installs the client the
  way a person does and runs both, with PostgreSQL. The command bundle no longer
  lists baileys as external: it has no import of it.
- tests/repo/default-install-licenses.test.ts scans the root lock for licenses: no
  GPL or AGPL (SPDX expressions read with OR/AND), a license stated for every
  package, LGPL only among @img/sharp-* (Next.js's optional image binaries, named in
  the notices), and the same scan flags libsignal in the opt-in lock.
  tests/repo/whatsapp-optin.test.ts now proves the opt-in shape: no workspace
  declares it, it is outside the workspaces, pinned with integrity, one documented
  command, one loader, no static import, not in the bundle.

GeoIP notices

The first entry said the merged file is GPL-3.0, which is the license GitHub shows on
daijro/geoip-all-in-one: the license of its scripts. Read at the pinned release
(2026.09.30): its README states no license for the merged data and only credits
IP2Location LITE, GeoLite2 and DB-IP. geoip-notices.ts now lists the actual data
licenses of the sources (CC BY-SA 4.0 for IP2Location LITE, IPinfo and IPLocate.io;
the GeoLite2 EULA; CC BY 4.0 for DB-IP Lite; CC0 1.0 for the tdulcet GeoFeed + Whois
+ ASN database, which sapics lists as PDDL 1.0; ODbL 1.0 for the OpenStreetMap-derived
time zone boundaries), with GEOIP_STATEMENT: the image is built by the person on their
own machine, invisible_dots does not publish or redistribute it, and whoever shares it
meets those terms. Every golden manifest records it as notices_statement beside
notices. THIRD_PARTY_NOTICES.md, the README, the image builder README and the
architecture document say the same; the third-party notices test pins the source list
and license of each source and keeps the notices, the statement and the manifest equal.

Tests removed or moved (none of a feature that went away)

- packages/channels/test/whatsapp-auth.test.ts (9 tests) moved to
  packages/channels/test-optin/auth-state.test.ts, unchanged in what it asserts: it
  needs the real BufferJSON, initAuthCreds and protobuf, which are not in the default
  install. It runs in the whatsapp suite on PGlite and PostgreSQL.
- packages/channels/test/baileys.test.ts: "reading a Baileys message" (5 tests) and
  "the connector on a socket that cannot connect" (1) moved to
  test-optin/reading.test.ts for the same reason; endOf and the addresses (4 tests)
  stay in the default suite.
- hub-whatsapp.test.ts "goes with the Dot" used the real library only to give the hub a
  session whose writes must not come back; it now uses a JSON stand-in for the
  library's three helpers (the real ones are in test-optin/auth-state.test.ts).
- tests/repo/whatsapp-optin.test.ts: the five old checks are replaced by checks of the
  new shape; "pinned to one exact version in the channels package and the lock file",
  "declared by no other package" and "imported by the glue file alone" no longer apply
  because the dependency is not in the channels package or the root lock and no source
  imports it.

Floors

- vitest: linux 1187 -> 1196, win32 1190 -> 1199 (measured 1199 on win32).
- postgres: min_passed 675 -> 659 and pg 273 -> 264, LOWERED: the 9 pg runs and 9 PGlite
  runs of the auth-state tests moved to the whatsapp suite, and the 6 moved glue tests
  (measured 659 passed, 264 pg, against a real PostgreSQL 18.4).
- whatsapp (new): linux 24 with 9 pg (CI, PostgreSQL), win32 15 (no PostgreSQL).

Suites: typecheck, typecheck:whatsapp, vitest 1199 passed (win32), postgres subset 659
passed with 264 pg, whatsapp suite 24 passed with PostgreSQL and 15 without, go test,
engine 2778 passed, web build, english and claims gates clean.
2026-10-06 16:42:01 +02:00
feder-cr 7b270be9b1 cli: the key and the token pasted at a prompt are not echoed (final review)
Final review, MEDIUM: the OpenRouter key and the Telegram bot token are echoed
in clear on the terminal while they are pasted -> fixed. main.ts read the line
with readline { terminal: false }, which leaves the terminal's own echo on, so
the value stayed in the scrollback and in any session recording.

readSecretLine (apps/cli/src/secret-input.ts) reads the line through a
readline whose output swallows what it is given (readline keeps handling
backspace, paste and Ctrl-C; the terminal is in raw mode), writes the one
newline that Enter would have echoed, and rejects on Ctrl-C ("cancelled").
CliIo.readLine is renamed readSecret, since a secret is the only thing the CLI
reads from a terminal. Tests play a TTY (a stream with isTTY and setRawMode):
nothing of the value reaches the screen as it is typed, pasted in pieces, or
after Enter; Ctrl-C and the end of input; the raw mode is put back; and a control
shows the same value on the screen through a readline that does write there,
so the check can fail. The README and architecture say the paste is not shown.
2026-10-06 16:40:55 +02:00
feder-cr c822091a3f channels: no message or edit asks for a link preview (final review)
Final review, MEDIUM: approval prompts and Dot messages in Telegram get link
previews: Telegram fetches the URL, query included, before anyone approves;
Baileys would fetch from the control plane -> fixed.

An approval prompt carries the arguments of the call (the engine keeps the
query of a navigation on purpose, architecture 8.x), so the data left through
Telegram's crawler as soon as the prompt was sent. Telegram: every
sendMessage and the editMessageText send link_preview_options.is_disabled
(one NO_LINK_PREVIEW constant). WhatsApp: textContent() is the one builder of
a text message and says linkPreview: null, which stops Baileys from fetching
the first link itself (3 s fetch from this machine); sendText and editText use
it.

Tests: FakeBotApi records a link a message would have had fetched
(linksFetched), asserted empty over a text, an approval prompt and its edit,
and non-empty for a raw call that does not say no (so the check can fail);
Baileys' own generateWAMessageContent is run with a recording getUrlInfo to
show textContent never calls it and a bare text does; BaileysConnection
(exported) is driven on a fake socket for sendText and editText.

Architecture 9.8 owns the rule (no link previews: nothing a Dot names is
fetched on its behalf); README Privacy says that no service fetches a link of
a message on its own.
2026-10-06 16:38:50 +02:00
feder-cr 6664c31852 database, scheduler: the migrations that only repaired data of unreleased builds are deleted, 0010 is 0008 (final review)
Final review, MEDIUM: migrations 0008 and 0009 and a scheduler guard exist
only to repair data that unreleased builds wrote -> fixed (NO PATCHES,
"migrations are unreleased: fold, never fix-up"; nothing is released, so no
database holds that data).

- 0008_orphaned_secrets (the secrets of Dots deleted before DotsRepository.delete
  removed them) and 0009_removed_config_names (stored configs that name the
  five deleted permissions or a model role other than summary), with
  stale-data-migrations.test.ts, are deleted. The origin of the first is
  already fixed: DotsRepository.delete removes the secrets in the same
  statement (repositories.test.ts keeps that test); the second is refused at
  the origin by the config schema.
- 0010_computer_next_automation is 0008 in the same unreleased sequence.
  A development database that already recorded the old 0008 to 0010 is not
  upgradable by this change: it is started again from nothing. No such
  database exists on this host (no ~/.invisible-dots), and the owner
  confirmed none is needed by the review's own wording; if one exists, it is
  the one thing to throw away.
- resolveApproval no longer refuses "always" for an approval whose permission
  is not in PERMISSIONS (a pre-C1 row): the real client parses every event
  with the permission list, so no such row can be made. The test keeps what
  it was about (always on a rejection, always: false, an unknown approval)
  and loses the seeded old row.
- architecture section 9.1 loses the paragraph on the two cleanup migrations
  and the mentions of them.

Test floors: this removes the 7 tests of stale-data-migrations.test.ts per
adapter (and nothing else); the floors file is lowered for them in the
commit that measures the final counts.
2026-10-06 16:36:18 +02:00
feder-cr 47ba4030da web, api: the chat opens on the newest messages and goes back a page at a time; the chat and the Tasks page read a bounded part of the log (final review)
Final review, HIGH: the chat stops showing the Dot's replies once a
conversation passes 500 messages -> fixed. GET /api/dots/:id/messages
answered the OLDEST 500 and the web had no way to the newest. It now has the
paging contract of /api/approvals and /api/dots/:id/events (limit up to 500,
order=asc|desc, before = the event id of the oldest message of the last page,
desc only), one orderParam shared by the three routes, the SDK option, and
Scheduler.conversation takes the page. useChat reads the newest page of 100
and goes back with "Show earlier messages"; withNewestPage keeps the window
gap-free (a page that does not reach back to what is held replaces it). The
CONVERSATION_LIST_LIMIT notice is deleted. Tests: API (three messages, a
limit that keeps the oldest by default, desc, a cursor, refused values),
chat-window units, component tests over 250 messages, a reply past message
500, an earlier page that fails, and a Playwright spec over 120 messages.

Final review, MEDIUM: the chat and the Tasks page read the Dot's whole event
log on open -> fixed. The chat reads its activity from the oldest message
shown on (readEventLog with after) and, going back, only the range between
(readEventRange, newest page first); with no message it reads the newest
window. The Tasks page reads one request per running task for its newest
progress line (task_id and types in the database, the 0006 index) and a
task's story only when its drawer opens, by task_id. For the story to hold
its approvals' answers, approval.resolved now carries task_id when the
approval was asked in a task (host event, optional, documented in
architecture 9), and isTaskEvent is simply "names a task". The Activity
page and the Browser view were already bounded.
2026-10-06 16:35:00 +02:00
feder-cr 24ab868bbe tests: every floor is the measured count of the merged branch (M6)
Both sides of the merge had raised the floors of their own suites; the numbers
are now what the merged tree reaches, each measured on the host it is
floored for (no floor goes down):

- vitest: linux 2157 passed with its 3 PowerShell skips (was 1187 on this
  branch, 1998 on lane-web), win32 2160 (was 1190 and 2001);
- postgres (the CI job's postgres 18.4 service, the same five workspaces):
  687 passed, 279 of them named pg (was 675 and 633, 273 and 255);
- go: linux 110 in a golang:1.25 container, win32 64 with 2 skips, both
  unchanged by the merge (lane-web had 108 for linux);
- pytest: 2778 passed, nothing skipped, in python:3.12-slim (was 2778 and 2421);
- playwright: 95 passed on linux (node:24 container, chromium installed as the
  web job does) and 95 on win32.

Also run on a git archive of this commit from WSL: the engine smoke, 238 checks
passed, and the browser smoke, 75 passed, none skipped.
2026-10-06 15:58:31 +02:00
feder-cr 93469d37f2 README: the new web UI (setup checklist, presets, inbox, live browser view, channels), and every statement true for the merged product (M6)
The web UI part of the README was the old one (paste a YAML, six tabs, an
Approvals page, channel settings only through the API). The merged product has
a new web UI, and the README now says what it does and nothing it does not:

- "Open the web UI" (sign in, and that the web client is a companion) stays in
  the quick start; the new section "Using the web UI" describes the first-run
  setup checklist and the key field, the Create a Dot page and its three
  permission presets, the Inbox, the seven tabs of a Dot's page, watching the
  computer (pictures, a view only), what Stop asks and says, and the Channels tab.
- The browser section says what the web UI does with identities (create with a
  proxy field that does not show what is typed, close, delete, watch the window).
- The channel settings have a switch in the Channels tab, not only the API.
- The status says what the live view is not: it is not a remote desktop, and
  nothing done there reaches the computer.
- Troubleshooting, privacy (the web UI loads nothing from other sites) and the
  development section (the Playwright tests, counted against a floor like the
  other suites) follow.
- No screenshot is shown: none was taken.

tests/repo/readme.test.ts compares the preset table with the presets the page
uses. The two prose gates (English, claims) are clean.
2026-10-06 15:30:50 +02:00
feder-cr c33ae54b64 web: the proxy field of a new browser is a password input (M6)
A proxy URL may carry `user:password@`, and the field showed it in clear while
it was typed (over a shoulder, on a shared screen, in a screenshot). It is now
a password input, said in its hint. The label is the field's name, so the tests
find it by label (a password input has no textbox role): the unit test and the
Playwright test of the Browser view also check that its type is password.
docs/architecture.md says it.
2026-10-06 15:27:27 +02:00
feder-cr 0e9db79974 web: a proxy URL is redacted by the one rule the engine and shared code use, and the web's own mask is deleted (M6)
`maskProxy` of lib/format.ts was a second rule for the same fact (it hid the
user as well as the password, by a regular expression of its own) beside
`redactProxy` of packages/shared, which the engine's `redact_proxy` mirrors
case for case. The identity card and the approval views (the Proxy fact and
the raw arguments) now call `redactProxy` from @invisible-dots/shared/browser;
the web has no rule of its own. What a person sees changes in one way: the
user stays, as in everything else the engine shows
(`http://user:***@proxy.example:8080`).

Tests: the unit test of the deleted function goes (the rule is tested in
packages/shared and test_identity_rules.py, against the same cases); the
web's tests that pinned the old shape expect the owner's, and the Browser view
test now hands the page a proxy with its password in clear to prove that the
page replaces it whatever reached it. docs/architecture.md says it.
2026-10-06 15:27:23 +02:00
feder-cr cca48791f5 web: the Computer and Automations views say when the person's stop paused the automations and when the next one is due (L13)
L13 left the web part undone: the host records why a computer is off
(`stop_reason`) and when the Dot's next automation is due
(`next_automation_at`), and no view showed either. The old Computer tab, where
the stop confirmation lived, is deleted by the new UI; the facts live where it
owns them now:

- lib/computer.ts: `automationsNote` turns the host's record into the one
  sentence (paused because you stopped it; next one due at a time, with "the
  computer is asleep and starts shortly before then" for a sleeping computer;
  none due). `confirmText(action, taskRunning)` is the question before a power
  action, null where none is asked.
- components/computer/automations-note.tsx reads `GET /computer` (the host's
  record, so it is there while the computer is off) and follows computer.* and
  automation.next_run events. It is the Automations card of the Computer page's
  Usage view and the note above the Memory page's Automations view, which shows
  it for a stopped computer too.
- Stopping the computer always asks and says that the automations do not run
  while it is stopped (the L13 decision; the new UI had kept only the question
  while a task runs, now folded into the same text). A reboot still asks only
  while a task runs. The three browser tests and the home test that stop a
  computer accept the question; the dot-shell test of the stop under a running
  task expects the longer text.

Tests: web vitest +8 (the note: paused, due, asleep, none, follows the event,
not on the Notes view; the stop question for an idle computer; confirmText and
automationsNote), the Playwright test of the Computer page checks the question
and the note before and after Stop. docs/architecture.md says the same.
2026-10-06 15:23:26 +02:00
feder-cr a4435fac4d merge: lane-web (the new web UI, W1-W12) into the merged branch (M6)
Merges caf63b9d: the whole new web UI with its own merges of older browser and
host work. 16 files conflicted; each is resolved keeping both sides' behavior
with one owner per fact:

- .github/test-floors.json: both sides raised the floors of their own suites;
  the file takes lane-web's shape (the playwright entry) and the numbers are
  set to the measured counts in the commit that ends the merge (M6 step 4).
- THIRD_PARTY_NOTICES.md: the GeoIP section of this branch and the notices of
  the web client's derived files (shadcn/ui, OpenDots, AI Elements, ...) are
  both kept; the nanobot web UI paragraph stays next to the nanobot license it
  refers to.
- packages/shared/src/states.ts: STOP_REASONS (this branch) and
  COMPUTER_STOPPED / computerIsUp (lane-web) are different facts, both kept.
- apps/scheduler/src/scheduler.ts: the ENGINE_BROWSER_ANSWERS table that
  lane-web still had is the one this branch deleted when the identity error
  statuses got one owner (shared, engine); deleted, nothing used it.
- apps/vm-manager/src/guest-client.ts and its test: lane-web's refusal of a
  frame that is not image/jpeg (502 frame_failed) is kept; this branch only
  had the doc line.
- docs/architecture.md: this branch's GET /state (pending_approval), GET /tools
  (offered with managed identities) and the identity proxy validation; lane-web's
  events filter text (tools, order, before), with the migration named by this
  branch's clean sequence (0006_events_task.sql).
- invisible_engine_dots/nanobot/dots/server.py: this branch's version (the
  shared IDENTITY_ERROR_STATUS owner; lane-web's own table is the older copy).
- invisible_engine_dots/tests/dots/test_browser_manager.py: this branch's more
  robust waits for the cancelled-launch test; lane-web's until_recorded helper
  is kept for the slow-launch helper.
- invisible_engine_dots/tests/dots/test_permissions.py, wire_shapes.json,
  tests/fakes/engine_harness.py: this branch's (the navigation approval tests,
  the managed-identities cases of the tool table, the manager made in
  new_engine); lane-web's wire_shapes is a subset of it, checked as parsed JSON.
- apps/web ComputerTab.tsx, lib/timeline.ts, test/timeline.test.ts: deleted, as
  lane-web deleted them. What this branch had put in them (L13: the automation
  next-run line of the timeline, the stop confirmation) lives where the new UI
  owns it: lib/events/view.ts describes automation.next_run (family computer,
  with its test in event-view.test.ts); the stop confirmation and the Computer
  views are the next commits.
- apps/web/test/format.test.ts: both sides' tests; confirmText kept.

Migrations stay the one clean sequence 0001..0010 of this branch (lane-web did
not change a migration after the point both branches share).

Checked after the merge: npm ci, typecheck (root, web, e2e), vitest 2150 passed.
2026-10-06 15:15:29 +02:00
feder-cr 3d60dc95e0 engine, docs: an approval of browser_navigate shows the URL that will run, with its query (merge1 review)
Finding 2 (real, fixed). approval.requested masked every query value of the URL
of browser_navigate while the parked call ran the real one, so the person
approved an address they could not see. A query string is the usual way a
prompt-injected model sends data out, and exec and browser_type already show
the approver everything they will do. The masking also blinded the web UI and
the CLI, not only chats, and went beyond Q5 (arguments are truncated and shown).

Fixed in the engine where the approval is built: navigate_arguments now only
removes the user and password of the authority (without_userinfo), keeping the
query and the fragment; a text that is no URL is shown as it is. The target of
tool.called is unchanged: it is logged, not decided on, and keeps its query
values masked. Whoever does not want query values in a third-party chat turns
show_arguments off for the channel, as for the text of browser_type. Recorded
in docs/architecture.md section 6 as an owner decision.

Tests rewritten for the new rule (not weakened away): test_permissions now
asserts the full URL minus user and password in the arguments and the masked
target separately, the quote and space cases check the target and the
user-less address, and test_browser_tools asserts the approval carries
?token=s3cret&q=1 while the parked call keeps the full URL with its user.
2026-10-06 14:51:05 +02:00
feder-cr f1107a9f86 channels: the Telegram adapter is connected only after a poll was answered, and a 429 on a poll is waited out (merge1 review)
Finding 1 (real, fixed). The adapter reported connected right after getMe and
deleteWebhook, and the supervisor starts its backoff over on that report, so a
poll that failed every time (a 409 from a second server on the same bot, a 429,
a network fault) restarted at the initial delay on each cycle: about two
channel.status events and three Bot API calls per second for ever, and the UI
showed connected for a bot that never polled. A 429 on a poll lost its
retry_after, because only sends honoured it.

At the origin: the first getUpdates is a short poll (timeout 0) and connected is
reported after it is answered, so the backoff grows across cycles that never got
an answer; a 429 on any guarded call becomes ChannelRateLimitedError carrying
retry_after, and the supervisor waits at least that long. @grammyjs/auto-retry
named in the design is not adopted: the hub owns retries and backoff (the
adapter header says so), and a second retry layer under it would hide the
failures it reports. FakeBotApi forgets the failures set for a token when that
bot is added again, so a persistent failure of one test does not leak into the
next.

New tests (both fail on the previous code): a persistent 409 on getUpdates keeps
a growing delay between tries (at most 20 in 1.5 s where the old code made 39),
never reports connected and writes at most 3 channel.status events; a 429 with
retry_after 1 on a poll is waited out although the backoff is a few
milliseconds. The wait for the next poll in the redelivery test of telegram.test.ts
counts from the polls made before the second run, because a run now makes one
more (short) poll before its long ones; what it checks is unchanged.

Floors raised for the two tests: vitest 1187 linux, 1190 win32; postgres 675,
pg 273.
2026-10-06 14:51:05 +02:00
feder-cr caf63b9de8 web, channels, shared: a reconnecting linked number stays linked, Cancel deletes nothing that holds people, and the channel marks follow a pause or a removal (webd review)
Finding 1 (high), fixed. A linked WhatsApp number that reports `connecting`
again (every server start, every Resume, a credential restart) was shown as
not linked, and its Cancel removed the channel, its credentials and its people
without a question. The card now decides from the host's account: a record with
an account is linked whatever its connection says, and only needs_relink or a
link with no account yet goes through the scan. The chip says Connecting, not
Reconnecting: Telegram also holds its account while it first connects, so that
word would be wrong there and the shell has one chip for both. Cancel removes
the channel only when nothing is held (no account, no people: the channel this
page's own start made); a Link again on a number that holds people only pauses
it, which loses nothing and which the person resumes. The architecture sentence
"Cancel removes the channel the start made" now says exactly that.

Finding 2 (medium), fixed at the origin. Pause, Resume and Disconnect emitted
no host event, so the rail, the Channels tab, the Inbox count, the title and the
favicon kept counting a channel the person had paused or removed until a reload.
The hub now appends `channel.changed {kind, change}` (change: paused, resumed,
removed) when the person pauses, resumes or removes a channel (not when the
state is already that), added to HOST_EVENT_TYPES, HostEventDataMap, the
architecture contract and the Activity labels; the guest never sees it, so
there is no Python side. The shell and the Channels page follow the event, so a
second tab follows too.

Tests: component tests for connecting with an account (linked, no Cancel, no
watch), Pause then Resume of a linked number, Cancel after Link again keeping
the people, and the marks going on Pause and Disconnect of a needs_relink
channel (each fails without the fix); hub tests of the event; two Playwright
tests of the same against the real hub. The fake control plane now clears the
account on a new link and announces channel.changed as the host does. No test
was removed; units.test.ts and events.test.ts list the new event type.
Floors raised: vitest linux 1994 to 1998, win32 1997 to 2001, playwright 93 to 95.
2026-10-06 14:16:24 +02:00
feder-cr ccdb8adbec merge: lane-readme (the new README) into the merged branch, with the README made true again (M5)
Merges lane-readme, which holds one commit, the README written against lane-fixes
at a258d512. Textually it merges without a conflict: nothing else touched
README.md. It is not true of the merged tree as it stands, because lane-fixes
went on after that point (automation wake A1, the person's stop L13, the URL
shown in an approval) and this branch's browser work (L5, L7, L11) came in with
the first merge. Every statement was checked against the merged code; the ones
that changed or were missing:

- Automations. The README said they fire only while the computer is on and are
  skipped, not run late, while it sleeps, and that a Dot on a schedule needs
  idle_timeout 0. After A1 and L13 the control plane starts the computer for
  them (90 s ahead by default), keeps it awake while one is due, runs a missed
  one once and late, and leaves a computer the person stopped off until they
  start it again. The intro, the note under "What a Dot can do", the fares
  example and the status list say that now; the "Automations need the computer
  on" limitation became "a computer you stopped by hand does not wake for its
  automations".
- Privacy. A browser launch also asks three address-echo services for the exit
  address (architecture section 6, L5); the README named only the library's
  launch counter. The bullet is added, and says the GeoIP lookup itself is local
  (the database is in the golden image).
- Everything else the README states was compared with the code and holds: the
  commands, flags and exit behavior against `invisible-dots --help`, the
  permission defaults against ASK_BY_DEFAULT, the tool list against the engine's
  table (select, scroll, back, forward are there), the environment variables,
  the web UI's tabs against the routes of the web build, the Telegram and
  WhatsApp details against packages/channels, the CI description against
  tests.yml, and every link into docs/architecture.md and within the page.

A test keeps that from drifting again: tests/repo/readme.test.ts (9 tests)
checks that every link into the architecture and within the page lands on a
heading, and every relative link on a file; that each row of the command table
names a command and flags the CLI's usage text has; that the environment
variable table is the host's ENV (less the one it sets for its own child);
and that the numbers the text gives (idle_timeout, max_open, max_identities,
computer sizes of the sample, the 90 s wake lead, the 16 MiB file limit, the 300
character approval arguments) are the code's defaults, with the automations
paragraph in place. It was written first and the automation test fails on the
README as lane-readme had it.

Floors raised for the added tests: vitest linux 1176 to 1185, win32 1179 to
1188 (measured: 1188 passed on Windows; 1185 passed and 3 skipped on Linux).
Nothing lowered. No test removed.

Database migrations at the end of the branch (unchanged by this merge, one
numbered sequence, unique and without a gap):
  0001_initial, 0002_inbound_events, 0003_task_spend, 0004_channels,
  0005_channel_prompts, 0006_events_task, 0007_dot_config_version,
  0008_orphaned_secrets, 0009_removed_config_names,
  0010_computer_next_automation.

The English and claims gates are clean on the new README.
2026-10-06 13:58:57 +02:00
feder-cr ce519ea69c merge: lane-fixes (channels, host API, integration fixes, automation wake, L13) into the browser block (M5)
Merges lane-fixes into dots-ui-browser-channels. lane-fixes already held an
older merge of this branch's browser block (9afef731), so the conflicts are the
places where this branch kept improving the browser after that point (L5, L7,
L11 and their reviews) and lane-fixes moved the MCP server's home. Every
conflict keeps both sides' behavior, with one owner per fact.

Conflicts and how each was resolved

- .github/test-floors.json: both sides raised every floor. Set to the counts
  measured on the merged tree: vitest linux 1176 (3 skipped by the existing
  allowance) and win32 1179, postgres linux 671 with 271 pg-named, go linux 110
  and win32 64, pytest linux 2778. Nothing lowered.
- THIRD_PARTY_NOTICES.md: both sections kept (the GeoIP data of the golden
  image from this branch, the opt-in WhatsApp client and libsignal from
  lane-fixes); they are independent.
- apps/scheduler/src/lifecycle.ts: both options kept: prepareSleepTimeoutMs
  stays the shared PREPARE_SLEEP_TIMEOUT_MS (this branch, one owner in
  packages/shared) next to lane-fixes' automationWakeLeadMs.
- apps/scheduler/src/scheduler.ts: the imports are the union. lane-fixes'
  ENGINE_BROWSER_ANSWERS table is dropped: the identity error statuses have one
  owner, IDENTITY_ERROR_STATUS / isIdentityAnswer in packages/shared (this
  branch, 39ac3052), which the pass-through already used. lane-fixes' homePath
  (the files routes) is kept.
- apps/scheduler/src/testing.ts: imports are the union (the shared status table
  for the fake guest's identity faults, and the file routes' constants).
- apps/scheduler/test/scheduler.test.ts: one import line with both sides' names.
- packages/shared/src/protocol.ts: GUEST_PATHS keeps both the GeoIP database
  path (this branch) and mcpHomes (lane-fixes).
- invisible_engine_dots/nanobot/dots/browser.py: imports are the union
  (GEOIP_DATABASE and MCP_HOMES_DIR).
- invisible_engine_dots/nanobot/dots/server.py: imports are the union of
  lane-fixes' automations (CronService, automation_json) and this branch's
  BrowserIdentity / BrowserIdentityError; the old ErrorCode import went with the
  status table that lane-fixes' copy still carried (this branch's
  IDENTITY_ERROR_STATUS answers from the shared mirror).
- invisible_engine_dots/tests/dots/test_browser_manager.py: imports are the
  union (mcp_home and proxy_forms; BROWSERS_DIR, GEOIP_DATABASE, MCP_HOMES_DIR).
- guest/image-builder/test/smoke/browser/proxy.py: this branch's version, whose
  credentials come by the environment and not by the command line.
- guest/image-builder/test/smoke/browser/smoke.sh: this branch's checks (GeoIP
  file untouched, Firefox killed under a live server, proxy without a port
  refused, launch behind the proxy, no password in the command line) combined
  with lane-fixes' (the servers' homes under /var/lib/invisible-dots/mcp, the
  session file outside /home/dot, refused by the TCP port, no MCP home in the
  identity's directory, the delete removes the home). The "known finding" that
  the session file under /home/dot holds the password is gone: the file is no
  longer under /home/dot, and the check now is that no file there holds it. The
  event counts are this branch's (six launches, four closes) plus lane-fixes'
  one browser.identity.deleted.
- guest/image-builder/test/smoke/smoke.sh: the lossy identity's control file is
  written in the server's home (MCP_HOMES), with this branch's comment.
- guest/image-builder/test/smoke/README.md, docs/architecture.md: the text says
  what the merged code does: the MCP session file is at
  /var/lib/invisible-dots/mcp/<identity_id>/sessions/<id>.json, outside
  /home/dot (the paragraph on the proxy's copies and the reopen paragraph both
  said <identity>/mcp), and that the server keeps the whole proxy is recorded as
  the library's defect that the smoke watches.

Database migrations of the whole branch after this merge (one numbered
sequence, unique and without a gap; no number was taken by both sides, and
loadMigrations refuses a repeated one):
  0001_initial, 0002_inbound_events, 0003_task_spend, 0004_channels,
  0005_channel_prompts, 0006_events_task, 0007_dot_config_version,
  0008_orphaned_secrets, 0009_removed_config_names,
  0010_computer_next_automation.

Verified on the merge: typecheck; vitest 1179 passed on Windows, 1176 passed and
3 skipped on Linux; postgres job 671 passed (271 pg-named) on PostgreSQL
18.4; go 64 passed (Windows), 110 passed (Linux); engine pytest 2778 passed;
web build; bundled CLI and doctor; the English and claims gates; engine smoke
238 passed, 0 failed, 0 skipped; browser smoke 75 passed, 0 failed, 0 skipped.
No test removed.
2026-10-06 13:52:01 +02:00
feder-cr fa3b9d6b1c web: channels
The Channels tab of a Dot (S12), on the host's channel routes (H4 to H6), with
the channels' share of the attention model.

- /dots/<id>/channels: Telegram (token form that is write-only and emptied at
  once, bot link, pairing with a deep link, QR code, words to type and a
  countdown, people paired with Revoke, the three settings as switches, Pause,
  Disconnect after asking, a refused token asks for a new one and keeps the
  people), and WhatsApp when the server offers it (risk statement first, then
  the host's QR frames followed from /whatsapp/qr, linked / failed / Link
  again / Cancel, a link already going on is followed on open). Live on
  channel.status and channel.peer.paired.
- qr-connect.tsx derives from nanobot's ChannelQrConnectFlow (header and the
  nanobot notice name it); the state machine is a pure reducer in
  lib/channel-link.ts over ChannelLinkFrame, and the code is drawn as an SVG
  from the qrcode modules (qrcode 1.5.4 and its types, pinned like the CLI).
- Channel attention: DotAttention.relinks, counted by needsYouCount, so the
  rail marker, the Dot's Channels tab marker, the Inbox card ("Channels to
  link again", with an alert when a Dot's channels cannot be read), the title
  and the favicon all read the one model. A paused channel is not counted.
- The origin chips of S5 and S11 were already in; the e2e now shows them with
  messages that arrive through the real adapters.
- The three copies of the chip's dot colors move to TONE_DOT in dot/tone.ts.
- e2e harness runs the Telegram and WhatsApp adapters against FakeBotApi and
  FakeWhatsAppConnector; apps/web depends on @invisible-dots/channels for it.
- docs/architecture.md describes the page and the attention rule.

No gap is left for later steps: everything the page shows is on the SDK today.

Tests changed: attention.test.ts expects the new relinks field and a count of
four things, dot-shell.test.tsx expects the Channels tab; nothing removed.
Floors: vitest linux 1935 -> 1994, win32 1938 -> 1997 (+59); playwright
85 -> 93 on both hosts (+8).
2026-10-06 13:32:42 +02:00
feder-cr c6484814b9 scheduler: recovery finishes an interrupted idle stop without checking idleness again (autowake2 review)
Finding 1 (medium), fixed: since L13 recovery called stop(dotId, "idle"), which
re-runs the idle check. After a control plane crash the computer row is set to
RUNNING and the Dot row is still READY, and an automation that came due during
the downtime makes keepsAwake true, so the stop was called off. Nothing then
made the Dot ready (the in-memory ready set is empty), pumped its events or put
it to sleep, and the guest could be left suspended after prepare-sleep.

stop() is split at the origin: stop() takes the per-Dot lock and calls
#stopLocked(dotId, reason, recheckIdle) with true; recovery takes the lock and
passes false, so the stop it found is finished for the reason it was asked for.
The computer ends STOPPED with stop_reason idle and the next pass wakes it for
the due automation or task through the normal path. Architecture 9.5 says so.

New test: an interrupted idle STOPPING with an automation due and one with a
pending task both end stopped once, started again, READY and pumped; it failed
before the fix (the stop was called off). Floors raised by one for the added
test: vitest linux 1144, win32 1147, postgres 669. No test removed or weakened.
2026-10-06 13:15:20 +02:00
feder-cr 920d6ea8ba web: activity, and the last of the old stylesheet
The Activity page (S11) replaces the Timeline tab, and the old stylesheet goes with the last screen that used it.

Activity: the whole event log of the Dot as readable lines. lib/events/view.ts describes every type the log can hold against the data the contract gives it (a Record over the contract's own types, so a type or field added there does not compile until it is described; a test fails for a type with no family), replacing describeEvent's hand parsing of unknown data. Each row has a family, a tone, a title, one line of detail (cut at 280 characters; the whole event is under "Data"), and "via Telegram" for a message that came through a channel. A tool.called line names the tool in the words of the engine's table, what it acted on (target), how it ended (ok, failed, denied, interrupted) and the policy decision; agent.started says "The engine started" with "The key was sent again" instead of showing empty data; user.message, the approval "always" answer and the task priority are now said too.

Filter chips by family (chat, tasks, tools, approvals, browser, computer with the agent, memory, channels, the Dot's own config) become the types of the request, so a family that is not chosen is never read, and a live event is kept only if it is of a chosen type. Search is over the lines read so far and says so; a newest-first switch; export of the events on screen as JSON Lines named for the Dot and the id range. The page reads the newest 200 first and "Load older events" goes on from the oldest one held, with no gap and no repeat, and says where the log starts. The old tab read the log from its OLDEST end and only reversed the first 200, so a long log never showed its newest events; that was the defect behind "paging by 200".

Origin fix for that: GET /api/dots/:id/events takes `before` (an event id, order=desc only, 400 otherwise), through the database list, Scheduler.listEvents, the SDK's events() and the architecture's route table, the same shape as the approvals list's `before`. readRecentEvents of lib/event-log.ts takes it too, so there is one function that reads the newest page of the log.

Moved to their owners: Tone to lib/tone.ts and mergeEvents to lib/event-log.ts (both were in the file that goes), and the browser download dance to lib/save-file.ts, which the Files view's download and the export both use. The tab order is now Chat, Tasks, Computer, Memory, Activity, Settings; /dots/<id>/timeline redirects to /activity.

The last of the old stylesheet: legacy.css and its layer in globals.css are deleted, TimelineTab.tsx and lib/timeline.ts too. ErrorBox (styled only by the old sheet, so the chat, the header and the computer panel showed it bare) is deleted and those three use ErrorAlert, the design's own.

No fake data and no gaps: everything shown is read from the log. Channel origin chips on Activity use the origin the log already holds; nothing waits on W12.

Docs: architecture.md describes the Activity page, `before`, and that there is no other stylesheet.

Tests: unit tests of the view models (15: the old cases plus tool.called with its target and decision, interrupted, agent.started, the channel origin, the approval answered for good, and the families, which read the contract's list of types), of the search, types and export (7), of readRecentEvents with `before` and mergeEvents; twelve component tests of the page (empty log, newest page first with the words of each line, paging with no gap or repeat, oldest first with the button above, families asked at the host and live events filtered, an answer to a choice changed in flight dropped, search and its count, search that needs older pages, a live event once, a failed read and Try again, the export whole and after a search); six Playwright tests against the real control plane (lines, family filter at the host, live, redirect of /timeline, paging 430 events, JSON Lines export, keyboard, 390 px, contrast in light and dark); `before` in the database (including the 400 of order asc), API (route, 400s), SDK and Scheduler paths.

Existing tests changed, none weakened: test/timeline.test.ts moved to test/event-view.test.ts with every case kept (its tool.called expectations carry the new words: the tool's label as title, target, outcome and decision on the line; its mergeEvents case moved to test/event-log.test.ts); the tab list of test/dot-shell.test.tsx has Activity in place of Timeline; the control plane fake of the web tests answers `before`.

Tests removed because their feature no longer exists: e2e/tasks.spec.ts "a Dot's tab that is still in the old design keeps the old stylesheet" (there is no old stylesheet or screen), and the six `.legacy` count-is-zero assertions in e2e/chat, computer, dot-settings, memory and tasks specs (the `.legacy` scope no longer exists to be kept off a page).

Floors raised: vitest linux 1909 to 1935, win32 1912 to 1938 (measured 1938 on win32; linux skips the same three PowerShell tests as before); playwright 80 to 85 on both (six added, one removed). Nothing lowered.
2026-10-06 12:59:20 +02:00
feder-cr 4c400dd521 host, database, shared, cli, web: the person's stop wins over the automations, and "has work" has one owner (L13)
Review findings of the automation wake (A1), one by one:

1. Poison automation.next_run with a time past what a Date holds -> already fixed
   in 83eb9d12 (the engine refuses such a next run where it is made, shared
   bounds next_run_at_ms with MAX_RUN_AT_MS, a parity test pins both sides);
   confirmed against the tree, nothing left to change.
2. Two definitions of "has work" (#exited used tasks.hasWork) -> fixed: the
   restart after an unexpected exit reads Lifecycle.keepsAwake, as the idle
   check and the idle stop under the lock do. Test: a VM that exits while a run
   is due within the lead is started again by the exit itself (no pass runs in
   the test); it failed before.
3. The host test did not prove "once" and the fake guest never made a missed run
   -> fixed: FakeGuest has a clock and, at a boot, does what the engine does
   (runs a past due job once, late, moves its time on, reports the earliest next
   run when it changed); the misleading "nothing at a boot" comment is corrected.
   The test now drives the whole loop: wake for the missed run, the guest runs
   it and reports the next time, the stored time moves forward, keepsAwake is
   false, the idle check puts the Dot to sleep, and further passes start nothing.
   The tests that used a user stop to "put a Dot to sleep" now sleep it through
   the idle check, so they keep proving the wake and not the new stop rule.
4. A Dot asleep when migration 0010 is applied is never woken for automations
   it has -> rejected as a code change, documented: the column can only come from
   a guest that ran; telling "never reported" from "none due" would need a second
   column and a boot of every sleeping Dot at every upgrade. architecture 9.5
   and the migration comment say it: such a Dot reports at its next start (a
   message, a task, the person) and is woken for automations from then on.
5. A person's explicit stop undone within one pass -> fixed (decision: the
   person's stop wins). computers.stop_reason (idle | user | exited, migration
   0010 changed in place, CHECK constraint) is recorded with STOPPING/STOPPED
   and cleared by any other state; stoppedWithAutomationBy leaves out
   stop_reason = user. A message or a task still starts the Dot (as before) and
   from then on it sleeps and wakes like any other. The person's stop of an
   already sleeping computer is recorded too, and a stop interrupted by a
   control plane restart is finished for the reason it was asked for (it used to
   become a user stop). computer.stopped events and ComputerRecord carry the
   reason; `invisible-dots status` shows "paused: the computer was stopped by you
   (start it to resume)" instead of the next time, `computer <dot> stop` says
   automations are paused while it is stopped, and the web stop confirmation says
   the same (confirmText in lib/computer.ts).
6. The web Computer tab does not show next_automation_at -> not done here, as
   decided: the web lane shows it after the final merge.

Tests added: database 2 (stop_reason kept/cleared, the user filter), scheduler 5
(the person's stop and restart, stop of a sleeping computer, exit restart by
keepsAwake, exit with nothing due, interrupted stop recovery), cli 1, web 1;
the missed-run test replaced by the whole-loop one and the recovery test also
asserts "exited". No test removed or weakened. Floors raised: vitest linux
1134 -> 1143, win32 1137 -> 1146, postgres 654 -> 668 (pg-named 263 -> 270).
Suites: vitest 1146 passed (also on PostgreSQL 18: 669 tests of the postgres
job), typecheck, engine pytest 2479 passed, go ok.
2026-10-06 12:39:38 +02:00
feder-cr 09384fa594 web: the setup checklist and its key field reach the page in one commit
SetupChecklist latched whether the key field is needed in an effect, after the
render that first showed the checklist. A test (or a person with a fast hand)
that found the checklist region could look for the key field in the one commit
before it existed, which made home.test.tsx fail now and then in a full run
("Unable to find a label with the text of: OpenRouter API key"). The latch is
now set during the render that first sees something missing, so the region and
the field are always committed together. No test changed.
2026-10-06 12:29:55 +02:00
feder-cr fd51b7f54d web: Dot settings, permissions and tools
A Dot's settings page (S14), on the host routes U3 (tools) and the config PATCH with expected_config_version; the YAML textarea of SettingsTab is replaced.

One draft of the whole DotConfig, edited as a form (General, Model, Permissions and tools, Computer, Browser, Memory, Limits) or as the same YAML, never a second model of the config. lib/config-fields.ts is the one table of what can change (where each field lives, its words, when a change reaches the Dot) and gives the review's list of changes, the notice a save ends with, and the rebase; lib/config-draft.ts holds the draft and the version it began from.

Permission editor: one row per permission of PERMISSIONS with its words and risk, an allow / ask / deny control, the default said, and the tools of the Dot's own table (GET /tools, offered or not, as the Dot has its config now). A permission set to its default is saved as no entry, so presets and rows agree. The preset picker is the create page's. A stopped computer has no table: the page says to start it and the rows still work.

Save: a review lists each changed field from what it is to what it will be, and nothing is sent before it is confirmed. The PATCH carries the config_version the edit began from, so a config changed meanwhile is refused (409 dot_changed) and never undone; when the host's config moves on under edits that are under way (another tab, an Always allow), the edits are kept on top of the new config and the page says so. The notice says when the Dot has the change: its next turn, when its computer starts, and for processors, memory and disk the next start of the computer. The disk slider starts at the disk's size (a disk never shrinks). The cross-field rule of the browser limits and every schema problem show on their control; the host's refusal stays in the review. The summary model is the one role (E5); the cost cap says the last request may go over (E3). Danger zone: delete after typing the Dot's name (ConfirmDialog gained confirmDisabled).

Origin fixes: the ranges of the step, context-token, identity and open-browser numbers moved into CONFIG_BOUNDS in packages/shared (the schema reads them from there, as it already did for the sizes and the cost cap), so a control can never offer what the API refuses; ConfigYamlEditor is the one YAML editor of the create page and the settings (it was private to the create page); the memory switch's explanation (MEMORY_EFFECT) is the one text of what memory off means; gibOf and gibSize are the one conversion between the config's sizes and the sliders' GiB. SettingsTab.tsx is deleted, and the legacy rules only it used (.card, .hint, .danger-zone, the legacy input and textarea rules, button.danger, .text-ok, .text-error) are deleted from legacy.css.

No fake data and no gaps: everything shown is read from the config or from GET /tools.

Docs: architecture.md describes the settings page and the extended CONFIG_BOUNDS list.

Tests: unit tests of the field table, setPermission, rebase and the save notice, the draft, and the permission rows (26); component tests of the page (33: every section from the config, allow / ask / deny writing the right patch conditional on the version, presets, tools and their offered state, a stopped computer, the review, a refused save, the summary role, the browser rule, the disk floor, the YAML view both ways, a config changed under the edit by event and by a refused save, delete by typed name); eleven Playwright tests against the real control plane and FakeGuest (allow / ask / deny reaching the host and the engine with the tools offered following, presets, the other settings, the browser rule and the host's disk refusal, YAML, a config changed in another tab, a stopped computer, delete, keyboard and 390 px, contrast in light and dark); one shared test for the new bounds.

Existing tests changed, none weakened or removed: e2e/tasks.spec.ts "a Dot's tab that is still in the old design keeps the old stylesheet" now opens the Timeline tab, the one that is still old, instead of Settings, which has moved; the web test fake gained DELETE /api/dots/:id.

Floors raised: vitest linux 1842 to 1909, win32 1845 to 1912 (the 60 tests added here, and 7 more that earlier merges added without raising it; the measured count); playwright 69 to 80 on both. Nothing lowered.
2026-10-06 12:13:05 +02:00
feder-cr 83eb9d120a engine, shared: a next run past the year 9999 is refused where it is made, so no event can stop a Dot's stream (A1 review)
Finding 1 (medium, real, fixed): the cron tool took any every_seconds (minimum 1, no maximum),
_validate_schedule_for_add only checked every_ms > 0 and _compute_next_run returned now + every_ms, so an
interval of about 8.7e12 seconds made an automation.next_run that zod accepted (any safe integer) but that
Date and the database cannot hold: the scheduler's setNextAutomation threw inside the transaction that also
advances the event cursor, the pump reconnected from the same cursor and hit the same event forever, and the
web timeline threw a RangeError on it.

Fixed at the origin and with one bound both sides share:
- engine: MAX_RUN_AT_MS (9999-12-31T23:59:59.999Z, the last moment a datetime holds) in nanobot/cron/types.py.
  _compute_next_run gives no next run past it (also for a job already in a store), the schedule validation
  refuses an every_ms or at_ms past it for add and update, and the cron tool answers the model with an error
  it can read instead of raising.
- shared: MAX_RUN_AT_MS in protocol.ts bounds next_run_at_ms of the automation.next_run event and of the
  automation row (.max()), so a value past it is refused by the contract and never reaches the database.
- parity: wire_shapes.json carries limits.max_run_at_ms, written by the engine's wire-shapes test, and the
  host's engine-shapes test checks it equals the shared constant and the schema's edge; a separate engine
  test pins the constant to datetime.max.

Tests added (engine 11, host 2): an interval, a one-time time and an update past the bound are refused and
leave no job; a stored job past it has no next run; the last moment itself is still a run; the tool returns
the error text; the shared schema accepts the bound and rejects one past it (8.7e15, MAX_SAFE_INTEGER too).
No test removed. Floors raised: pytest 2468 -> 2479, vitest linux 1132 -> 1134, win32 1135 -> 1137.
2026-10-06 12:00:35 +02:00
feder-cr f5b9954797 image-builder: the engine smoke's golden image has the GeoIP file the engine now requires
The previous commit makes the engine refuse a golden image without the GeoIP
database. The engine suite of the smoke builds its golden image by hand and never
installed that file (its browser is a stand-in), so the engine refused to start and
the suite hung waiting for it. prepare-engine.sh now installs an empty file at
GUEST_PATHS.geoipDatabase, root's and read-only, for the engine suite; the browser
suite keeps installing the real database through build-browser-env.sh. A test pins
the path to GUEST_PATHS.

Floors: vitest linux 770 to 771 and win32 773 to 774 (1 test added).
2026-10-06 11:36:26 +02:00
feder-cr 1836f16d97 web: memory and automations
The Memory tab (S10), on the host routes U2 (file list and read) and U3 (automations).

Notes: the files the Dot wrote under /home/dot/memory, read only (the Dot owns them). There is no notes route, so the list walks the folder with GET /files/list, at most 40 folders and 4 deep, and says when it stopped short. Newest written first, search by name, the open note in the address (?note=) and looked up in the list, never read by the path in the address. A .md note is shown as Markdown by the renderer the chat uses (FilePreview gained a markdown flag; one reader for files and notes). A note written while the page is open (memory.written) is tinted and shown as a chip that leads to it, added or updated by what the list held. The chat's Remembered chip now leads to the note.

Memory switch: memory.enabled is saved with the whole config and expected_config_version, so a config changed elsewhere is refused (409 dot_changed), said and read again, never undone; it says what off means for the Dot.

Automations: the Dot's cron jobs as schedule cards: schedule in words (common cron shapes, any other shown as the expression, always with the zone it is read in), next run, last run and its error, what the Dot is told, a switch that pauses or resumes, delete after a question. The person does not create one: the empty list says how one comes to be by what the config does with the automations permission. The engine reports only the last run of a job, not a history, so the card shows that one and nothing is invented.

Code: schedule-card and memory-chips derive from assistant-ui (copy #4; notice section extended, headers say what changed: no forget button, one last run, links), ui/switch from shadcn (off track is muted-foreground so the switch keeps three to one against its surface), ViewTabs is the one segmented view bar of the Computer and Memory pages (ComputerView uses it now). Tests: unit tests of the schedule words, the notes walk and the address; component tests of both views, the switch and the stale-config refusal; ten Playwright tests against the real control plane and FakeGuest (notes, live chip, chat chip, switch saved and pushed with the memory tools no longer offered, stale config, automations pause/resume/delete/appear live, permission-dependent empty text, stopped computer, 390 px, light and dark contrast).

Existing tests changed, none weakened or removed: dot-shell's tab list now has the Memory tab; the web test fake's default Dot config carries permissions and memory like every real record (the API parses configs with the schema defaults); chat gained a test for the chip's link.

Floors raised for the 68 vitest tests and 10 Playwright tests added: vitest linux 1774 to 1842, win32 1777 to 1845; playwright 59 to 69 on both. Nothing lowered.
2026-10-06 11:30:51 +02:00
feder-cr 2e40bfcafb engine, shared, host: an automation runs when its time comes, also after the computer was off (A1)
Automations (cron jobs) lived only in the guest. The host's idle check did not
know them, so a Dot powered off after idle_timeout and a job never fired while
it was off; and on start CronService._recompute_next_runs counted every next
run from now, so a run that came due meanwhile was dropped, a one-time job
(whose time was then past, so it had no next run at all) never ran.

Engine (the origin of the fact):
- cron/service.py: a next run already stored is kept, past or not; only an
  enabled job with none gets one from now. The first tick finds a missed job
  due and runs it once, and _execute_job counts the next run from that moment:
  a one-time job runs late and is over, a recurring one runs once for all the
  occurrences it missed (never a backlog). nanobot's tick/execute path already
  did that; the start-up recompute was the only thing discarding it.
- A firing is recorded under cron:<job>:<due time> before the service saves
  that the job ran (already so), and the engine ignores an id it holds, so a
  kill -9 between the two finds the job due again and runs it once.
- CronService(on_next_wake=...) is told the earliest next run of the enabled
  jobs after every arm of its timer (every change and every tick). The engine
  reports it as the new outbound event automation.next_run {next_run_at_ms |
  null}, written only when it differs from what the host was last told (kept in
  dots_kv in the same transaction, so a restart does not repeat it).
- main.py: the cron timer now stops before the engine, so no firing falls on a
  stopped engine (recorded nowhere while the job moved on as if it had run).

Contract (packages/shared first): automation.next_run in OUTBOUND_EVENT_TYPES,
its schema and data type; nanobot/dots/protocol.py carries the same name
(vendored-nanobot parity test) and tests/dots/wire_shapes.json pins the data the
real engine store writes, which engine-shapes.test.ts parses with the host
schema and holds the fake guest to.

Host: migration 0010_computer_next_automation adds computers.next_automation_at
(also in ComputerRecord, `invisible-dots status` shows it); applyGuestEvent
stores the report. Lifecycle owns automationWakeLeadMs (90 s) and
keepsAwake(dot): tasks.hasWork or an automation due within the lead (past ones
included), used by the idle check and again under the Dot's lock; the
scheduler pass starts every STOPPED computer whose Dot is not ERROR, DISABLED
or CREATING and whose next automation is due within the lead. The web
timeline shows the new event instead of raw JSON.

Docs: architecture 5.4 (the event), 8.8 (missed runs and how a kill -9 cannot
repeat one), 9.1 (the column), 9.5 (wake and no sleep), 10 (recurring jobs were
wrongly out of scope); engine UPSTREAM.md records the cron change; smoke
README. README.md is not touched.

Tests (written first where practical; the missed-run and kill -9 tests fail on
the old recompute):
- engine: test_cron_missed_runs.py (one-time and recurring missed once, no
  backlog, stored time kept, report after every change, failing listener),
  test_automation_catch_up.py + automation_victim.py (a subprocess is SIGKILLed
  right after the engine recorded a firing; the restart runs it once and the
  job it never reached is not lost), TestTheNextRunReport (dedupe, null,
  restart, stopped), test_main (route to event, cron stops before the engine),
  test_protocol, test_wire_shapes.
- host: scheduler (reports stored and cleared, no sleep within the lead and
  sleep outside it, a due run keeps it up, a stopped Dot started shortly
  before and not earlier, a missed run starts it once, ERROR Dots and Dots with
  nothing due left alone, an idle sleep called off under the lock), database
  (column, stoppedWithAutomationBy), shared events, engine-shapes, web
  timeline, CLI status.
- smoke: a one-time job whose time passed is written into the stopped
  engine's cron store; at the next start the host is told its due time, the job
  runs once and the host is told nothing is due; after a kill -9 and another
  start it is not run again; the host's route lists and removes it. The
  count of agent.started in the stream goes from eight to ten (two more
  starts), the other checks are unchanged.

Removed tests: none. One expectation of kept behavior is extended, not
weakened: the smoke's count of agent.started (8 -> 10) for the two starts the
new section makes.

Floors (.github/test-floors.json), raised: vitest linux 1120 -> 1132, win32
1123 -> 1135; postgres linux 635 -> 654 and its pg-named tests 254 -> 263;
pytest 2442 -> 2468. Nothing lowered.

Suites: engine 2468 passed; vitest 1135 passed (1397 on both adapters against
PostgreSQL 18, 654 in the postgres job's files); typecheck, web build and go
test green; smoke 237 passed, 0 failed, 0 skipped.
2026-10-06 11:27:42 +02:00
feder-cr e3f8110c34 database: the two list-limit tests no longer depend on the clock of two inserts
"tasks: a Dot's list is the newest TASK_LIST_LIMIT" and "approvals: a list is
the oldest APPROVAL_LIST_LIMIT" insert 201 and 501 rows in a row and expect the
last or first one to be cut. Two inserts can get the same created_at, and the
order is then the ids', which are random, so each failed now and then under
load (seen twice in full runs). Both now set the creation times from the
insertion order first (createdInOrder); what they assert is unchanged.
2026-10-06 11:02:49 +02:00
feder-cr 24547c31d4 docs: a README that says what a Dot is, how to start one and why it can be trusted 2026-10-06 10:51:51 +02:00
feder-cr 5fe52b67ac web, api, database, sdk: History and the Browser view read the newest of the log, not the oldest or all of it
Finding 1 (Inbox History stops showing new answers after 500 approvals): real,
fixed at the origin. GET /api/approvals listed the OLDEST 500 of every status,
so pending ones used places and no answer after the 500th ever appeared.
ApprovalsRepository.list now takes one status or several, `order: "desc"`
(newest first by the time of the last change, COALESCE(resolved_at,
created_at), id as the tie-break) and `before`, the id of the last row of the
previous page (a row-value comparison against that row, so no opaque token);
the route takes status=<a,b>, limit (1 to APPROVAL_LIST_LIMIT), order and
before (400 for a bad value, for before without order=desc, for a repeated
parameter); scheduler.listApprovals and the SDK pass them on. History asks for
ANSWERED_APPROVAL_STATUSES (one owner in packages/shared) with order=desc and
limit=50, and "Show older answers" asks for the next page with before=<last
id>; a live refresh reads the newest page again and keeps the older rows it
reaches, and drops them when more was answered in between than a page holds
(lib/inbox.ts newestHistoryPage/olderHistoryPage, components/inbox/use-history.ts).
The "first 500" notice and historyOrder (the browser-side filter and sort, now
the route's job) are deleted. A filter that hides every row of the newest
pages says so while older rows remain. The oldest-first default of the route
is unchanged for the CLI and the channel runner.

Finding 2 (Browser view reads every tool.called the Dot ever logged): real,
fixed at the origin. GET /api/dots/:id/events takes order=desc (a limit keeps
the newest of what the filters keep) and tools=<a,b>, which narrows the
tool.called events to those tools in the database and leaves other types alone
(EventQuery.tools/order, scheduler.listEvents, SDK events). The view now makes
one request, readRecentEvents: the newest BROWSER_ACTIVITY_WINDOW (500) events
of the browser tools' calls and browser.identity.launched|closed, with the tool
names derived from the one tool table (BROWSER_ACTIVITY_TOOLS), and the merged
list in memory is cut to the same window. Stated limit (architecture.md and the
constant): a very long run of calls to one browser can push another browser's
last navigation out of the window, and that browser then shows no page.

Tests (each fails without the fix): repositories on both adapters (newest
first, several statuses, cursor, unknown cursor, before without desc, the
newest answer past more than APPROVAL_LIST_LIMIT pending ones; events desc,
tools filter, limit counts what is kept); the API over HTTP (same, plus the
400s); web: History pages newest first with the cursors the page sent, keeps
older rows on a live refresh, says when the filters hide the newest; the
Browser view sends one request with types, tools, order=desc and the window
against a log of 2500 other calls; BROWSER_ACTIVITY_TOOLS equals the browser
tool names and the merge is capped; readRecentEvents. Verified on PostgreSQL
18.4 as well as PGlite (633 passed, 255 on pg), the web build and the 59
Playwright tests on the real control plane.

Tests removed because the feature no longer exists: inbox-model "puts the
answered approvals last answered first, and leaves the waiting ones out of the
history" (historyOrder is gone; the route's order is covered by the repository
and API tests) and inbox "says that only the first approvals are listed when
the control plane's list is as long as it can be" (the notice is gone; replaced
by the tests above). Floors raised for the 13 tests added: vitest linux 1761 to
1774, win32 1764 to 1777, postgres 625 to 633 with pg 251 to 255; none lowered.

Not changed: the chat's "first 500 messages" notice (CONVERSATION_LIST_LIMIT)
is the same kind of gap but is not part of this finding.
2026-10-06 10:47:40 +02:00
feder-cr d4c9c949a4 engine: refuse a golden image without the GeoIP file, and a lost browser is the library's whole sentence (L11 review)
High, confirmed: a Dot on a golden image built before L11 that starts on a new
runtime disk got STEALTHFOX_GEOIP_MMDB pointing at a file only the new builder
installs. invisible_core raises on a missing override instead of falling back to
its cache, so launches behind a proxy failed and the others took the VM's own
timezone. The engine's code ships on the runtime disk while each Dot keeps its
golden image, and the lock comparison did not cover this file. Fixed where the two
halves are tied: main() now also refuses to start, beside the lock check, when a
runtime disk (the one that carries the lock; a development checkout is not checked)
runs on an image without GEOIP_DATABASE, with 'build a new golden image'.
GoldenLockError is renamed GoldenImageError because it now covers a missing file
as well as another lock. Tests: the check alone (file present, missing, a
directory in its place, development checkout) and main() refusing before it opens
the state.

Medium, confirmed: the browser-lost pattern searched its phrase anywhere in an
error, and a failed click or select echoes page-controlled text (the covering
element's text, the option asked for), so a page could close the identity and skip
browser_close. The pattern is now the library's two main-browser sentences as the
whole error, behind the 'Error executing tool <name>: ' prefix FastMCP puts on a
raise, matched with fullmatch. The fake server now speaks with that prefix like the
real one and has overlay_says_gone for a click whose diagnosis carries the
sentence. Tests: that click leaves the identity open and usable; a table of whole
sentences (with and without the prefix) and embedded phrases, the support browser
and a trailing text. Both new behaviours were run against the old code first and
failed.

Floor: pytest min_passed 2692 to 2707 (15 tests added, none removed or weakened).
Docs: architecture sections 3.3 and the browser-lost paragraph.
2026-10-06 10:36:24 +02:00
feder-cr bd25e43352 engine: the approval of browser_navigate shows the URL by its parts, so a quote or a space cannot hide it (integ2)
Finding 1 (medium), fixed: navigate_arguments and browser_navigate_target sent the URL through
_shown_word, the masking rule of command words, which replaces a word with *** when it holds a
quote or whitespace. The schema only checks the http(s) prefix, so a model could add one quote
and the person approving saw url "***". The URL now has its own owner in targets.py,
shown_url (no user or password in the authority, values of the query masked, path and host
kept), used by the navigation approval, its target and the URL branch of _shown_word. A text
that is no URL is masked whole. New test
test_a_quote_or_a_space_in_the_url_of_a_navigation_never_hides_where_the_page_is fails before
the fix (got ***) and passes after. Pytest floor 2441 to 2442.
2026-10-06 10:27:05 +02:00
feder-cr b4a0f20491 web, shared, vm-manager: the low findings of the second web review (L8)
Findings of .local/next/webb-review.json, one by one.

Last step of a finished turn (low): fixed. lastStepSinceUser stops at the Dot's
answer as well as at the person's message, so a Dot that works again after
answering (a task, a schedule, a resumed approval) does not show a step of the
turn that is over. Test: chat-thread "does not name a step of a turn the Dot
already answered".

computerIsUp disagreed with the host (low): fixed. computerIsUp moved to
packages/shared/states.ts and says RUNNING only, as the scheduler's
#runningGuest does; the scheduler (guest routes, reboot, lifecycle.guest and
reboot) and the web (panel, Computer page, usage tab, attention ring) all call
that one function, and COMPUTER_STOPPED is the one name of the 409 code. The
reboot button follows it too (an IDLE computer cannot be rebooted). Tests: the
shared state test, the panel asking nothing of an IDLE computer, the ring and
power actions for IDLE.

Facts duplicated in the web layer (low): fixed. IDENTITY_EVENT_TYPES is exported
by packages/shared/events.ts and OUTBOUND_EVENT_TYPES is built from it; the
panel and the Browser tab import it (the IdentitiesTab copy named by the review
no longer exists). ApprovalOutcome is declared once, in lib/task-events.ts, and
chat-thread imports it. The frame error codes come from shared FRAME_ERROR_CODES
(not_open, busy) and COMPUTER_STOPPED. On the next merge of the browser branch,
FRAME_ERROR_CODES should be reconciled with its IDENTITY_ERROR_STATUS (39ac3052),
which will name the same two codes.

Lane misses 39ac3052 and 2e32efa2, scheduler duplicates the engine's status map
(low): rejected here, as the finding itself says. The next merge of the browser
branch brings both in and removes ENGINE_BROWSER_ANSWERS; a copy of that fix in
the lane would only conflict with it.

Frame route always says image/jpeg (low): fixed at the host's boundary. The
contract of the route is a JPEG (architecture section 5.3, and invisible-
playwright-mcp's browser_watch emits one), and every layer after the guest client
labels the bytes that way, so the guest client now refuses any other media type
as a 502 frame_failed instead of letting it be served mislabelled. Carrying a
media type through the scheduler, the API, the SDK and the web was rejected: it
would add a field every caller must handle for an answer the contract excludes.
Test: guest-client "refuses a frame that is not a JPEG".

useFrame did not serialize the manual Refresh (low): fixed. One read is in
flight at a time; a refresh asked for meanwhile waits for it and starts none, so
an older answer cannot replace a newer one and the identity's call lock is not
asked twice. Tests: use-frame.test.tsx (two, the first failed before).

A failed send restores the old text over a newer draft (low): fixed. The refused
message goes back in front of what was typed meanwhile. Test: chat "puts a
refused message back in front of what the person typed while it was on its way".

screen-view header mentions a caption that does not exist (low): fixed in the
header (the alt text names the subject; no figcaption is added).

Forced-colors shimmer (unproven point): confirmed as a hazard and fixed. Under
forced colors the shimmer keeps a transparent text fill; it now draws the label
in the system text color with no gradient and no animation. Chromium's forced-
colors emulation showed the label visible with and without the rule (the engine
overrides the fill there), so the rule makes it independent of the engine rather
than fixing a reproduced failure. Test: tokens.test.ts reads the rule.

Removed or changed tests: computer-panel "which computer states have a picture"
moved to packages/shared/test/states.test.ts (the function moved, and it no
longer accepts IDLE); attention "ring ready for an IDLE computer" now expects
"stopped" and format "allowedActions" gains an IDLE case, both because IDLE is
not a state in which the guest answers.

Floors raised for the added tests: vitest linux 1754 to 1761, win32 1757 to 1764.
2026-10-06 10:07:49 +02:00
feder-cr d17052431d engine, image-builder, shared: use what invisible-playwright-mcp and invisible_core already do (L11)
The owner's rule inside this repository: reuse what the libraries do, never
re-implement it. This applies the verdicts of .local/next/reuse-actions.json that
live here, and the low findings of the third browser review that the GeoIP change
does not settle. Nothing outside this repository is touched.

reuse verdicts

- DELETE_OURS, silent reopen and repeat of a call whose browser died: done.
  BrowserManager.call_tool no longer runs browser_open again and repeats the call
  (the library removed that on purpose: a repeated click lands on a blank page and
  the model is never told). The server's GONE meaning goes to the model in the
  Dot's words (the model cannot call browser_open): the call fails with `crashed`,
  "the browser of identity X is gone: it closed or crashed during <tool>. The
  identity is closed and keeps its profile: call browser_identity_launch to open
  it again as the same person, then navigate again, because it comes back on a
  blank page". The identity is closed and its server stopped (one `closed` event,
  no slot held), so browser.identity.launch alone still decides whether a browser
  starts. A frame that finds the browser gone closes the identity the same way
  (before, it answered not_open and left the identity "open"). One owner for ending
  a session whose process or browser is gone: _end_session takes the reason.
- DELETE_OURS, browser_select_option said "the value, not its label": done. The
  tool says "by its visible label or by its value", and the fixture
  tests/fixtures/mcp-tools-0.70.2.json now carries each tool's description as the
  server serves it (captured from the real pinned server over stdio, schemas
  unchanged), so a test ties our wording to the library's.
- USE_MISSING, max_chars of browser_read_text: done (integer or null, minimum 1,
  passed through; our text states no number, the server owns its default).
- USE_MISSING, GeoIP through the library's own knob: done. build-browser-env.sh
  installs the hash-checked file at /usr/local/share/invisible-dots/geoip-aio-all.mmdb
  (root's, 0644, GUEST_PATHS.geoipDatabase / GEOIP_DATABASE), and the relay env gets
  STEALTHFOX_GEOIP_MMDB=<that path> (ENV.GEOIP_MMDB / BROWSER_ENV). The writes into
  invisible_core's cache layout and the tag check in provision.sh are deleted; the
  script now checks, as dot and with the knob, that the library hands back exactly
  that file and reads it as a database, and that dot cannot write it. The pin holds
  at runtime (the library no longer looks up the latest release at a launch, nor
  downloads one, nor prunes the pinned one). The digest input is unchanged.
  build-browser-env.sh takes <lock> <env dir> <zip> <sha256> (the tag argument is
  gone).
- USE_MISSING, INVISIBLE_CORE_AUTOFIX=off: done (ENV.CORE_AUTOFIX / BROWSER_ENV,
  set in the browser environment), asserted in the engine smoke (fake server's
  recorded environment) and in the browser smoke (the real server's /proc environ).
- USE_MISSING, engine version from CLI prose: done. provision.sh reads tag, Firefox
  version and BuildID from invisible_core instead of sed over `invisible-playwright
  version`; checked against the real pinned library: the output is identical to the
  old engine line, so manifests keep their wording.
- FIX_UPSTREAM, port-less proxy: done in the repo's part. identity_rules.py,
  identity-rules.ts and the shared fixture refuse a proxy URL that does not write a
  port ("proxy must name a port, as in http://host:8080; got <redacted>"), one rule
  in two languages (a written default port such as http://host:80 counts, which
  URL.port alone cannot tell).
- FIX_UPSTREAM, proxy password in the MCP session file: done in the repo's part.
  docs/architecture.md section 6 no longer says /proc environ is the only readable
  copy, and says the environment is the only source for the first launch only (a
  reopen takes who the browser is from the session file). The browser smoke now
  launches a real identity with a proxy (a small authenticating proxy,
  browser/proxy.py, credentials by environment) and greps /home/dot for the
  password: the MCP session file holds it, as expected until upstream is fixed; the
  check accepts exactly that file and fails for any other, and prints
  "KNOWN FINDING STILL OPEN" (or "FIXED UPSTREAM" when the file is clean).
- FIX_UPSTREAM, usage counter: the in-repo part only (architecture section 6 lists
  the connections a launch makes that nothing here turns off). The upstream knob is
  not ours.
- KEEP_OURS items: untouched, except that the fake server's GONE sentence now is
  the real one.

low findings (browserlows3-review.json)

- GeoIP cache growth: not settled by the knob (the host archive cache stays), so
  done separately: after a build has its pin's archive, golden.ts keeps it and the
  two archives downloaded most recently before it and removes older ones
  (pruneGeoipArchives; a failed fetch removes nothing).
- launch_failed fallback lost its traceback: done (logger.opt(exception=error)).
- GoldenManifest.notices typed as always present: done (optional, with a test that
  an old manifest reads).
- stale doc comment on GeoipPin.tag: done.
- notices do not name daijro/geoip-all-in-one's license: done (first entry of
  GEOIP_NOTICES and of THIRD_PARTY_NOTICES.md; GPL-3.0, GitHub's license field of
  the repository and invisible_core agree).
- medium holdback finding: already fixed by c2094738, not repeated.

deferred, with the reason

- Bump to invisible-playwright-mcp 0.70.10 (input lock, background typing): needs
  the owner's decision on a permission for the new browser_upload_files, a
  regenerated hashed lock, a new captured fixture and the golden digest.
- Back, forward, reload and scroll as key presses: the fix is upstream tools; the
  smoke probe for it is not in this step's list.
- checks.py browser_installed through engine_status/doctor: low, not asked.

tests removed because their feature is gone (all replaced)

- test_reopens_the_browser_once_when_the_server_says_it_is_gone and
  test_reopens_only_once_and_then_returns_what_the_server_said: the reopen and
  repeat no longer exist; replaced by three tests (lost browser closes the identity
  and is not reopened, a launch makes it work again, the slot is freed).
- guest-files: "provision.sh records the engine line of `invisible-playwright
  version`" and "the engine sed picks the engine line": the sed no longer exists;
  replaced by a test of the new program, run against a stand-in invisible_core and
  compared with the old line's format.
- guest-files: "has the pinned GeoIP database unpacked beside the engine" and the
  smoke-prepare test: the cache-layout writing is gone; replaced.
- engine smoke: the check that a lost browser is reopened and the call repeated,
  and the check that the fifth launch closes the second (the lost identity now
  holds no slot); replaced, counts of events unchanged (nine launches, six closes).
- test_an_error_of_the_server_is_an_error_result became the lost-browser tool test.
- identity-rules fixture: "an https proxy" now carries a port (the rule changed).

Tests and floors: vitest 773 passed (win32; linux floor +12), pytest 2692 passed,
go 63 passed 2 skipped (win32), engine smoke 215 passed, browser smoke 71 passed.
.github/test-floors.json raised: vitest linux 758 to 770, win32 761 to 773, pytest
2674 to 2692. Nothing lowered.
2026-10-06 10:02:39 +02:00
feder-cr 14714d71df database, engine, cli: the low findings of the first integration review (L12)
Finding -> outcome (.local/next/integ1-review.json):

- Migration 0009 rewrote dots.config without moving config_version -> fixed in
  place (the migration is unreleased, no fix-up migration). The two UPDATEs are
  one statement now, so a Dot that needed both lists cleaned is one rewrite and
  its version moves once, as a save does; a Dot the migration does not touch keeps
  its version. architecture.md names the migration as a writer of the version.
  Test: stale-data-migrations.test.ts checks the version of a Dot rewritten for
  both lists, one rewritten for models only, and two left alone (fails before the
  fix: the version stayed 1). Run on PGlite and on PostgreSQL 18.4.
- Browser approvals sent to Telegram and WhatsApp carried the URL of
  browser_navigate raw (user, password, query tokens) while the tool.called target
  masks them -> fixed in the engine, the one owner of what an approval shows:
  approval.requested arguments for browser_navigate use the same function as the
  target (targets._shown_word), so the host, the web UI and every chat show one
  thing. The parked call keeps the real URL. Tests: test_permissions.py (the URL
  of the arguments is the URL of the target, for four URL shapes) and
  test_browser_tools.py (a parked navigation's approval.requested carries no
  password or token, and the stored call is unchanged). The channels package
  redacts nothing; approval-text.ts says so.
  Not changed, by the recorded choice of architecture section 6: the text of
  browser_type and the value of browser_select_option stay in an approval (a
  person approving typing sees what is typed); section 6 now says that this
  includes a chat and that show_arguments off leaves it out. exec's full command
  also stays in its approval (section 5: the full command stays behind the
  approval). A channel test would only re-test that the prompt prints what the
  event holds, so none was added.
- setup's nextSteps recommended the web build when the report had no web row
  (DoctorDeps.webBuild is optional) -> fixed: the step is named only when the row
  exists and is not ok. Test: a report without the web row (fails before).

Changed test, not weakened: test_browser_tools.py "a policy that asks before
navigating shows the person the url ..." used a URL with a query (?q=1), whose
value the approval now masks; it uses a plain URL, and the new test covers the
masked one. No test was removed.

Floors raised for the 4 tests added: vitest linux 1118 -> 1120, win32 1121 ->
1123, postgres 633 -> 635 (pg 253 -> 254), pytest 2439 -> 2441. None lowered.
2026-10-06 09:59:55 +02:00
feder-cr 76d0a8a180 web: computer and browser view
The Computer page (S9) replaces the old Computer and Browser identities
tabs. It has four views named in the address (?view=screen|browser|files|usage):

- Screen: the desktop as a live picture (3 s while the page is in view, LIVE,
  a warning after 15 s), with "The Dot has control". The chat panel and this
  view now draw it with one FrameView.
- Browser: identity cards (open first; state in words, last use, proxy with
  user and password hidden), the window of an open browser under a bar with
  the page the Dot last sent it to and a "using this now" mark, Close (B8,
  asked first when the Dot is working in it), Delete (asked first), New
  browser (checked by the engine's own checkIdentityRequest), the limits and
  the managed_by_dot note. The page and the mark are read from the event log
  (the target of tool.called starts with the identity id): lib/browser-activity.ts.
- Files: a read-only walk through /home/dot, folder and file in the address;
  text and images are shown, everything else and any file over 1 MiB (text) or
  8 MiB (image) is a download only; markup and svg are text, never run.
- Usage: given and in use (live meters), images, why the last start failed,
  model spend today and in total, and Start, Reboot, Stop.

A stopped computer says so on the first three views and offers Start; a
route's own 409 computer_stopped gets the same answer. The old
/dots/<id>/identities redirects to ?view=browser, and its tab leaves the bar.

One owner for what a file is: fileType (packages/shared/src/file-kinds.ts) is
now the table the API serves a file by (apps/api/src/file-types.ts) and the
table the web previews by; before, the API held it alone.

Removed: ComputerTab.tsx and IdentitiesTab.tsx (replaced), StatusBadge of
components/ui.tsx (nothing used it any more) and the legacy.css rules only
they used (.badge, .tone-*, .visually-hidden, .table-wrap, table, .facts,
.meter-*, meter, .screenshot, .grid-2, .row, .notice).

Gaps named, nothing faked: GET /api/dots/:id/browser-identities/:identityId is
not used (the list is read again on each browser.identity.* event, which gives
the same card; a second path to the same data would be a second owner); the
person cannot take over a browser or the desktop (decided: watch only).
No code was copied from open-cowork in this step (its entry in
THIRD_PARTY_NOTICES.md already covers screen-view.tsx); nothing to add.

Tests: 4 shared (fileType), 34 web units (computer-view, files, identity,
browser-activity), 38 component tests (computer-page: views, stopped
computer, browsers, files, usage), 7 Playwright specs (computer.spec.ts:
views, browsers end to end, redirect, stopped computer, 390 px, contrast in
light and dark). Vitest 1681 -> 1757 on Windows (linux floor 1678 -> 1754),
Playwright 52 -> 59.

Tests changed, none removed: dot-shell.test.tsx's tab list loses
"identities" (the tab no longer exists); computer-panel.test.tsx imports
computerIsUp and frameProblem from lib/computer (they moved there from
ComputerPanel.tsx, no re-export). Floors raised as above, none lowered.
2026-10-06 09:48:29 +02:00
feder-cr a258d512bb engine, shared, image-builder: the MCP server's home is outside /home/dot, so no file route serves its session file (integ1)
Finding (medium), confirmed and fixed: the pinned invisible-playwright-mcp
saves who `main` is, the proxy with its password included, in
<INVISIBLE_MCP_HOME>/sessions/<session id>.json on every browser_open. The
engine set INVISIBLE_MCP_HOME to /home/dot/browsers/<id>/mcp, so with the
host file routes of lane-host (GET /api/dots/:id/files and /files/list,
limited to /home/dot, with no exclusion for that directory) the password
was one request away from the API, the web proxy, the SDK and the Files tab.

Fix at the origin: the home is /var/lib/invisible-dots/mcp/<identity_id>,
owned by dot, made by provision.sh and install.sh (the parent is root's).
dot-agentd's TCP listener already refuses a real path outside home with 403
outside_home, so nothing new is filtered. One owner for the path:
GUEST_PATHS.mcpHomes in packages/shared and MCP_HOMES_DIR in the engine's
protocol.py, kept equal by tests/repo/vendored-nanobot.test.ts. The
BrowserManager makes the subdirectory with the profile and removes it with
the identity (before, it went with the identity's directory).

Tests that fail before the fix: the manager tests (the home is not under
browsers, the environment names it, a delete removes it, and the session file
with the password is under the home and nowhere in the browsers directory), the
shared test that checkHomePath refuses the home, the image-builder test that
both scripts make it. The engine's fake MCP server now writes the session file
as the real server does. The engine smoke has the stand-in's session file refused
by the TCP port (the file, its directory, a link to it, a ../ path) and removed
by a delete; the browser smoke creates an identity with a proxy (a small
authenticating proxy, browser/proxy.py), launches it on the real server, and
checks the real session file is under the new home, that no file of /home/dot
holds the password, that the TCP port refuses it and that the identity's
directory has no MCP home. files_get, files_list and refuses_outside_home move
to lib.sh, which both smokes use.

docs/architecture.md: sections 4.2, 6 and 9.6 name the session file as the
second copy of the proxy on disk, outside /home/dot, and the new directory.

Floors raised: vitest +2 (linux 1118, win32 1121), pytest +2 (2439); none
lowered. No test of kept behavior was weakened or removed: the existing tests
that read the server's record now read it from its new home.
2026-10-06 09:33:51 +02:00
feder-cr 47148bd438 web: approvals inbox and card
Step W6 of the UI plan (S6 Inbox, S7 approval card, the U4 wiring, the keyboard), built on the routes the host has now.

What
- /inbox (in the rail, with a badge) is where everything that needs the person is, from every Dot. Its address holds its whole state: ?tab=history, ?dot=<id or name>, ?permission=<name>. "Needs you" lists the waiting approvals (the one that has waited longest first), the Dots in ERROR and the tasks that failed in the last 24 hours (each can be dismissed; the dismissals live in this browser only). History lists every approval that is no longer waiting, the last answered first. /approvals and /dots/<id>/approvals redirect to it; the Dot's tab bar has no Approvals tab any more, the header's "Waiting for you" pill and the Home card link to the Inbox filtered to the Dot.
- The approval card says what the Dot wants to do (from the engine's tool table: a new `ask` phrase per tool in tool-labels.ts, which the existing test keeps equal to permissions.py), the permission with its risk (PERMISSION_INFO), who and where, the Dot's reason, and the call's arguments the way they read best: a command box, a diff for write_file / edit_file / apply_patch (lines trimmed to a little context, cut at 24 lines with the rest on request), an address, a schedule in words; the raw arguments sit under Details and a proxy password is masked there too. It is destructive for a command, a browser identity's deletion and a change to a file outside the workspace.
- Answers: Allow once, Always allow, Deny with an optional note. Always allow first asks what it changes (the permission, its risk, the tools it covers from GET /dots/<id>/tools when the computer answers) and then sends approve with always: true (U4); only a permission a config can name offers it. A 409 already_resolved is the news that someone was first, not a failure. Buttons wait while an answer is on its way and a second answer is ignored. An answered card stays in place as a receipt until the person leaves the page.
- The card is where the question was asked: in the chat (instead of the line that said "Answer it in Approvals"), on a task's card and in its drawer, and in the Inbox. Under 768 px the answers stick to the bottom of the screen.
- Keys in the Inbox: j and k move, a allows the selected card once, d denies it. The design says "d deny (with confirm for destructive variants)"; a denial can do no harm, so the confirmation is on the key that can: a does not allow a destructive card, it moves the focus to its Allow once button, whose press is the confirmation. Keys meant for a field, a menu or an open dialog are left to it.
- The attention model counts failed tasks (lib/attention.ts): the rail's Inbox badge, the tab title and the favicon read one count of approvals, Dots in ERROR and failed tasks, and the Inbox lists that many things. The shell reads each Dot's task list for them (no route lists failed tasks of every Dot) and the Inbox says when some Dot's list could not be read.

Where things live
- lib/approval-view.ts (the question, risk, body per tool, workspace rule), lib/diff.ts, lib/range.ts, lib/inbox.ts (address and filters), lib/failed-tasks.ts, lib/dismissed.ts; components/elements/approval-card.tsx, components/approvals/* (card, body, diff preview, always-allow dialog, the answers hook, the task and inline wrappers), components/inbox/*; components/tool-family-icon.ts is the icon map the chat steps and the cards share.
- Copied code, each file with its Derived from header and its notice: assistant-ui (MIT) elements/approval-card.tsx and utils/range.ts (only clamp and take); OpenDots by Shashankss1205 (MIT, which includes the Spots source) the diff palette and line vocabulary of app.js and style.css, in diff-preview.tsx and diff.ts. A line's kind comes from how the preview is built, not from its first character, so a removed line of dashes is not taken for a heading. THIRD_PARTY_NOTICES.md gains both sections.
- Deleted: components/ApprovalsList.tsx, components/DotApprovalsTab.tsx, pendingApprovalCount (the rail reads needsYouCount).

Named gaps, nothing faked
- GET /api/approvals answers with the oldest 500 approvals (no paging, no order), so History cannot reach newer ones past that. APPROVAL_LIST_LIMIT in shared is now the one owner of the number (the database default uses it, as TASK_LIST_LIMIT does for tasks), and the History says when its list is that long. Open: paging or newest-first on the route (host lane).
- A History row cannot say "always allowed": the approval record has no such field, only the approval.resolved event has it. The chat and the task story, which read the events, do say it.
- Failed tasks of every Dot cost one task-list read per Dot until the host has a route for them (a cross-Dot or status-filtered tasks route).
- Channels that need a relink join "Needs you" with the channels page (W12).

Tests (written with the code)
- apps/web vitest +93 (diff and range 8, approval-view 18, inbox model, failed tasks and dismissals 12, approval card and element 20, Inbox 29, shell +2, tasks +1, chat +1, tool-labels +1, attention +1), tests/repo/notices +8 (four derived files), repositories +1 (the approvals list limit).
- Playwright +11 (inbox.spec.ts: counts in the rail and title, an answer heard by the Dot, Always allow changing the Dot's config and its computer's, Keep asking changing nothing, Deny with a note and the History, the old addresses, keyboard-only, a 409, a task's approval card and drawer, failed tasks and dismissal, 390 px with sticky answers, contrast in light and dark). The Inbox counts across every Dot, so its specs run on a control plane of their own (`fresh` fixture) and each leaves it empty; look.ts is the contrast helper tasks.spec.ts had inline, now shared.
- FakeControlPlane (test support) answers approval answers (with 409 for a second one) and the tool table.

Changed tests, none weakened: test/attention.test.ts drops the assertions on pendingApprovalCount, a function that no longer exists (the same counts are asserted per Dot and through needsYouCount, and failed tasks are new cases); test/shell.test.tsx expects the rail's "Inbox" entry with "3 need you" (two approvals and a Dot in ERROR) where "Approvals" with "2 waiting" was; test/dot-shell.test.tsx drops the Approvals tab and its per-Dot count badge (the tab is gone, the count is on the rail and the header pill) and expects the pill to link to /inbox?dot=d1; test/home.test.tsx expects the same link; test/tasks.test.tsx and test/chat.test.tsx replace the tests of the "answer it in Approvals" link with tests of the inline card answering; e2e shell.spec.ts and chat.spec.ts follow the new heading, entry and card. The two deleted components had no tests of their own.

Floors raised (no floor lowered): vitest linux 1576 to 1678 and win32 1579 to 1681 (+102: 93 web, 8 notices, 1 repository), postgres 624 to 625 with the pg requirement 250 to 251, playwright 41 to 52 on both.

Suites: npm run typecheck; npx vitest run (122 files, 1681 passed); apps/web playwright (52 passed); next build; go test ./... in guest/dot-agentd; engine suite in WSL (2421 passed). The smoke was not run: no engine, guest or image file changed.
2026-10-06 09:17:07 +02:00
feder-cr 1932f16069 merge: lane-host (channels, host API and their fixes) into lane-fixes (M4)
First step of the final integration: the 17 commits of lane-host (tip 1690fd3c,
the Telegram and WhatsApp channels, the host API routes for automations, tools,
files and events, the doctor moved into the vm-manager, the CLI channel commands)
meet the browser block, the cleanup migrations and the CLI web serving of
lane-fixes. Behavior of both sides is kept.

Database migrations
-------------------
One numbered sequence, each file stating its final form once. Since the
engine fork commit f0e86ad7 the migrations added are:

  0001_initial                (unchanged since f0e86ad7)
  0002_inbound_events         (unchanged since f0e86ad7)
  0003_task_spend             (both sides added the same file: one file)
  0004_channels               (lane-host, final form from L10)
  0005_channel_prompts        (lane-host)
  0006_events_task            (lane-host)
  0007_dot_config_version     (lane-host)
  0008_orphaned_secrets       (lane-fixes, renumbered past 0007 before this merge)
  0009_removed_config_names   (lane-fixes)

No migration was edited after it was added, there is no duplicate number, and
a repeated number is refused by the existing guard (database.test.ts). 0008 and
0009 clean rows that databases of the released engine can hold (secrets of Dots
deleted before DotsRepository.delete removed them, stored configs naming a model
role or a permission that no longer exists); they fix no migration of this
branch. repositories.test.ts lists the nine versions. The same chain runs
through stale-data-migrations.test.ts on PGlite and PostgreSQL.

Conflicts and their resolutions
-------------------------------
- .github/test-floors.json: every floor set to the measured count (below).
- README.md: the channels section (Telegram, WhatsApp) from lane-host, and the
  web client paragraph of lane-fixes (served by `invisible-dots server`,
  `--no-web`, INVISIBLE_DOTS_WEB_LISTEN) in place of lane-host's two-command
  paragraph.
- docs/architecture.md: the engine route table has the identity frame and close
  routes (fixes) and the automations and tools routes (host); the `offered` rule
  of GET /tools now also names the managed-identities rule; section 11.1 says the
  web row belongs to `invisible-dots doctor` and `setup`.
- packages/shared/src/protocol.ts: both sets of agent routes. DoctorCheckId gets
  "web" (packages/shared/src/api.ts).
- packages/sdk/src/client.ts: getIdentityFrame and closeIdentity (fixes) beside
  the channel methods (host).
- apps/api/src/server.ts, apps/scheduler/src/{driver,scheduler,testing}.ts,
  apps/vm-manager/src/guest-client.ts and its test: the identity frame and close
  routes beside the automation, tool and channel routes; the engine's browser
  answer codes (busy, frame_failed, crashed, launch_failed) next to homePath.
  The fake guest has two browser identity tool rows and offers
  browser_identity_create only while the Dot manages its identities, as the
  engine does.
- apps/cli/src/cli.ts and host.ts: channel commands and `--no-web` serving
  together. The doctor now lives only in the vm-manager (host's move):
  apps/cli/src/doctor/checks.ts stays deleted, and the web build row of fixes
  moves there with it. DoctorDeps.webBuild is optional: only a process that
  serves a web client has the row, so `GET /api/doctor` (read in the web client,
  which is built by then) has none and `invisible-dots doctor` and `setup` do.
  WEB_BUILD_COMMAND and WebBuild are the vm-manager's; apps/cli/src/web.ts
  imports them.
- apps/cli/test/doctor.test.ts: host's version (the checks' tests live in
  apps/vm-manager/test/doctor.test.ts). The three web row tests of fixes moved
  there (row order with web, built or missing, a check that throws).
- apps/cli/test/setup.test.ts: host's fakes, plus the web build next step test.
- packages/database/test/repositories.test.ts: the nine versions above.
- guest/image-builder/test/smoke/smoke.sh and README.md: fixes' managed_by_dot
  argument on every offered-tools check, host's GET /tools, /automations and
  file route checks after them; the table has 32 tools, not 13.
- invisible_engine_dots/nanobot/dots/{engine,server}.py: Engine keeps both the
  BrowserManager and the registry (for tool_table); both import sets.
- invisible_engine_dots/tests/fakes/{engine_harness,local_computer}.py and
  tests/dots/test_permissions.py: the harness keeps the shared CronService of
  host and the browser manager of fixes; local_computer drops the unused `os`
  import and keeps base64; both groups of permission tests stay (the table tests
  now build the browser manager too).
- tests/repo/e2e.test.ts: the doctor check ids the e2e run requires are read
  from DoctorCheckId in packages/shared (the union that used to be in the
  deleted apps/cli/src/doctor/checks.ts).
- invisible_engine_dots/tests/dots/wire_shapes.json is written again with the
  browser tools in the table, and two cases cover the managed-identities rule;
  apps/scheduler/test/engine-shapes.test.ts holds the fake guest to it.
- Expectations that list the fake guest's tool table gain its two browser rows
  (apps/scheduler/test/scheduler.test.ts, apps/api/test/api.test.ts).

Removed tests: none. Floors: all raised, none lowered.
  vitest   linux 817 to 1116, win32 820 to 1119
  postgres linux 228 to 633, pg-named 97 to 253
  go       linux 98 to 110, win32 63 to 64
  pytest   linux 2424 to 2437

Suites, measured on this tree: typecheck clean; vitest 1119 passed on win32 and
1116 passed, 3 skipped (the real PowerShell tests) on Linux; postgres suite 633
passed (253 on pg); Go 64 passed, 2 skipped on win32 and 110 passed on Linux;
pytest 2437 passed; web build; engine smoke 225 passed; browser smoke 52 passed;
english and claims gates clean. No Playwright suite exists in this tree yet.
2026-10-06 08:55:56 +02:00
feder-cr c20947389b engine: the stderr pump holds back as much as the longest text its filter looks for
Finding (medium, fixed): the stderr pump kept a fixed 4096 characters behind when it cut a long line, on
the assumption that a proxy URL is a few hundred characters. Nothing bounds the proxy length, and the
escaped forms _escaped_forms adds are up to 12 times longer than the password (json.dumps turns one
astral character into 12 characters), so a pattern longer than 4096 characters that straddled a cut was
never whole in one buffer and went to the journal in clear. Confirmed on the old pump with a 600-fox
password: 7 of 22 offsets around the cut leaked.

The fix is where the secret is defined. The filter now says how long its longest text is: StderrFilter
(scrub, longest_match) replaces the bare callable of MCPServerConfig.stderr_filter, _proxy_stderr_filter
takes longest_match from the patterns _proxy_scrubber builds, and FilteredStderr holds back exactly that
many characters (HOLDBACK is deleted) and raises its line cap to twice that when the default 64 KiB is
smaller. The shared and Python identity rules are left alone: capping the proxy length instead would
have to stay below 4096 / 12 characters to cover the escaped forms, which refuses legitimate proxies.

Tests: a 70 000 character secret that straddles the cut of a line read in 64 KiB pieces, a proxy
password longer than 4 KiB in its JSON form at 22 offsets (the old pump leaks at 7 of them), and the
longest_match of a proxy filter. The existing pump tests pass the filter object. pytest floor raised from
2645 to 2674; no test removed.

Suites: engine 2674 passed, typecheck, vitest 761 passed, go test, web build.
2026-10-06 08:29:56 +02:00
feder-cr 957e1d248d web: onboarding, host settings, the OpenRouter key
S13, the Home setup checklist and S15, with the end-to-end tests of each.

What: a Settings page in the rail (/settings) with the host checks (the doctor's report and the control plane's own answer, each failing row with its command and a button that copies it, checked again on request and when the person returns to the window while something is missing), the OpenRouter key (a write-only field over PUT /api/secrets/openrouter; whether a key is stored comes from the health answer; the answer says how many running Dots were given it), the theme, Sign out and About. Home with no Dot shows the same checklist under the invitation while the host is not ready, with the key field when no key is stored, and keeps it on the page turned green once the last thing is done. The login page is restyled as a card (no rail, no API calls, token field focused). The rail's missing-key line links to Settings.

One owner each: useHostChecks is the one list that the Settings page, the Home checklist and the create form's preflight draw (CheckList and its Section replace the panel's own markup); the health answer moved into the shell's shared data, so a saved key reaches the rail at once instead of at its next 30 second poll; CopyButton is the one copy-to-clipboard button (the code block of the markdown renderer uses it too, same behavior and tests).

The About card needed what no route told the web: GET /api/health now also answers database_kind, data_dir and logs_dir (HostFacts in packages/shared, read by startServer from the paths and the database it opened, a required buildServer option). No other host change; the design's 'logs directory' is that, and a Dot's own logs are read with 'invisible-dots logs <dot>', which the card shows.

Nothing is faked: no data is shown that a route did not answer. The doctor's own key row stays out of the lists (the key is one fact, read from health). Gaps left open on purpose: none for W3. The check that setup commands were run is a re-check, not a launcher, because setup needs an elevated terminal and an image build runs for minutes (design S13).

Tests: apps/web vitest +34 (host-settings 4, preflight 3, shell 3, settings 13, Home checklist 5, login form 6), playwright +8 (settings.spec.ts, a second harness without a key for the first-run host), API health test now pins the exact answer and start.test pins the real paths. Removed tests: none. Floors raised: vitest linux 1542 to 1576, win32 1545 to 1579; playwright 33 to 41 on both. No floor lowered.

Suites: npm run typecheck, npx vitest run (117 files, 1579 passed), apps/web playwright (41 passed), next build, go test ./... in guest/dot-agentd. The engine suite and the smoke were not run: no engine, guest or image file changed.
2026-10-06 08:22:07 +02:00
feder-cr 4c02321fb6 shared, e2e: the e2e run follows the engine's state and target contracts, and waits for the pumped events (fixes4)
Findings of the fixes3 review, each confirmed on the code.

1. GET /state pending_approval (high, fixed at the contract). packages/shared
   declared an object (ApprovalRequestedData) that the engine never sent; the
   engine, its test (test_engine.py state_answer), the engine smoke (jq
   .pending_approval==$id) and dot-agentd's proxy all carry the bare approval
   id, and the host holds the approval row by that id already. The contract is
   now the id: AgentStateAnswer.pending_approval is string | null, the
   PendingApproval type (used nowhere else) is deleted, section 9.x of
   docs/architecture.md says so. run.ts reads the id (steps g and k) through
   the AgentStateAnswer type of lib.ts. Tests: tests/repo/e2e.test.ts assigns
   the shared type to the run's (a compile-time pin, typecheck fails if they
   drift again) and pins the engine's two lines; test_server.py gains the
   answer of GET /state with two pending approvals (the oldest one's id).

2. browser_navigate target (high, fixed in the run). The engine writes
   "<identity id>: <detail>" (targets.py _on_identity). lib.ts names the format
   once (identityTarget), step e asserts the target starts with
   identityTarget(research.id, "https://example.com"), and e2e.test.ts pins the
   format against targets.py (the f-string and browser_navigate_target using
   _on_identity).

3. Step h reads right after POST close and DELETE (medium, fixed). The events
   reach the host's log through the scheduler's pump after the route answered.
   Both reads now wait (waitFor, TIMEOUTS.pump 30 s, every 1 s); the delete
   waits for deleted and then asserts closed came first (the pump delivers in
   order). A guard test in e2e.test.ts fails on the old shape. The other
   asserts that read the log right after runTask stay: a task's end is one of
   the pump's events, so everything before it is there.

Floors raised: vitest 814/817 -> 817/820 (three tests), pytest 2423 -> 2424.
No test was removed or weakened.
2026-10-06 08:11:09 +02:00
feder-cr 1690fd3cce database, guest: the channel migrations state their final form once, and a write on a link is tested for the file case (L10)
Nothing of this branch was ever released (main and dots-ui-browser-channels
have migrations 0001 to 0003 only), so migrations 0004 to 0010 are one first
form that grew in steps, and three of them were fix-ups of an earlier one.
Each migration now states its final form once, and no database exists that
needs a repair:

- 0004_channels creates the Telegram-only unique account index, drops the
  channel_inbound table it used to create and have 0006 drop, and carries the
  events_user_message_origin_key index that replaced that table.
- 0006_inbound_by_event, 0008_channel_account_and_arguments and
  0010_account_key_telegram_only are deleted: their schema is in 0004, their
  data repairs (dedupe of a bot bound twice, the show_arguments default for
  bindings made before it) fix rows no database can hold.
- 0007_events_task and 0009_dot_config_version are renumbered 0006 and 0007 so
  the list has no gaps. A local database that applied the old names must be
  recreated.

The text that named the old files is updated (hub.ts, architecture.md).

The write on a link: a PUT on a symbolic link at the path itself replaces the
link with a regular file (a rename does that, on both listeners), and does not
create the file the link points to; the directories of a path are followed.
The previous test comment and commit text said the file would be created at
the target. The test now asserts the replacement for a dangling file link, a
new test asserts it for a live file link (the target keeps its content), and
the README states the rule.

Findings of host3-review.json:
- low, test and commit text disagree with the behavior of a write on a dangling
  file link: fixed by stating the replace rule and testing the file-link case
  (dangling and live). Writing through the link was rejected: it would make
  the web editor and the engine's socket write through links in a way no
  other atomic save does, for no case that needs it.
- low, migration 0010 is a fix-up of an unreleased 0008: fixed by deleting it,
  and by folding the other fix-ups (0006, 0008) into 0004 the same way.
- the medium finding of that review (relative dangling target resolved from
  the lexical directory) was fixed by 5757281b.

Removed tests, because the behavior they tested no longer exists:
- adapters.test.ts "0008 gives a binding made before it the setting it
  lacked, and keeps the ones it has": the data repair is gone with 0008.
- adapters.test.ts "0008 resolves a Telegram bot bound twice by keeping the
  oldest ...": the dedupe is gone with 0008. The rule itself (one bot per Dot,
  a WhatsApp number on several) stays tested in channels.test.ts.
repositories.test.ts lists the new migration versions.

Floors: vitest linux 1020 -> 1018 and win32 1023 -> 1021 (the two removed
tests), postgres 614 -> 610 with pg 245 -> 243 (the same two, on both
adapters), go linux 108 -> 109 (the live file link test). Measured: vitest
win32 1021, postgres suite 610 with 243 on pg, go on linux 109.
2026-10-06 08:02:54 +02:00
feder-cr 066835d761 web: the gaps U1-U4 and H2 left open: the log read by type, "via Telegram" on a message, the host report in the preflight
The earlier web steps built on what the SDK offered and named what the host
lane would add, with no fake data and no stub. The merge of lane-host (M3)
brings that data; this closes the gaps it answers.

Closed

- The event log has no filter (declared by the chat and Tasks steps, U2):
  readEventLog now asks the route for the types a caller names (`types`), so
  the agent's state, messages and every other event no longer cross the wire to be
  dropped in the browser. It is still the one function that pages through the
  log; the chat passes CHAT_ACTIVITY_EVENT_TYPES, the Tasks page passes
  TASK_EVENT_TYPES (new, the nine types a task's story is made of) and keeps its
  predicate for the tool calls and approvals of the chat, which name no task and
  which a type cannot tell apart. The per-task `task_id` filter is not used: the
  page reads every task's story at once, and one call per task would be more calls,
  not fewer.
- No "via Telegram / WhatsApp" on a message (declared by the chat step, H2): a
  user message carries `origin` in the conversation answer; the bubble says
  "via Telegram" or "via WhatsApp" (lib/channels.ts, a Record over the contract's
  kinds, so a kind added there is a compile error until it has a name). Nothing is
  shown for a message typed in the web, the CLI or the SDK.
- The create form's checks showed only /api/health (declared by the create step,
  U1): the preflight panel now also shows the host report of GET /api/doctor
  (Node, QEMU, the accelerator and its probe, disk, the golden image and the
  runtime ISO), each row that is not ok with the command that fixes it. The
  doctor's own OpenRouter row is left out: the key is one fact and /api/health is the
  web's one reader of it. The two are asked together; if only the report fails the
  control plane's rows stay and one "This computer" row says it was not checked.
  The panel informs; creating stays possible.

Still open, and why (nothing faked)

- The conversation route answers the OLDEST 500 messages (the host lane did not
  change it): the chat keeps saying that only the first 500 are listed.
- The tasks route lists the newest 200: the Tasks page keeps its notice.
- "Last activity" on a Home card: no route returns the newest event (the new
  filters read forward from a cursor), so the card still has none.
- The inline approval card with Allow once / Always allow / Deny is W6's; U4's
  `always` is in the SDK and waits for it.
- Memory chip as a link (W9), the Files, Automations and Tools views that U2 and
  U3 serve (W8, W9, W10).

Tests (written first)

- apps/web/test/event-log.test.ts, task-events.test.ts: the control plane is
  faked with its real filter; they pin that the types are asked for on every page,
  that a page is cut by what is kept, that the chat's tool calls and approvals
  are left out of a task's story. chat.test.tsx: the chat asks only for what it
  reads, page by page, and a step after a thousand kept events is found.
- preflight.test.ts, new-dot.test.tsx: the host rows, a missing row with its fix,
  a doctor that cannot run, a control plane that does not answer, check again.
- channels.test.ts, chat.test.tsx: the names of the kinds and the chip.
- Playwright (2 new, against the real control plane over the fake VM layer):
  the checks beside the form name what the host lacks and the fix, and clear when
  the host is fixed; a message from Telegram says "via Telegram" and one typed here
  says nothing. The e2e harness gives the doctor a healthy host of its own, so no
  test depends on the QEMU of the machine it runs on.

Removed tests: none. Rewritten for the new reading of the log (same behavior
kept, the filter now at the route): event-log.test.ts "keeps what the caller's
predicate accepts" (now two tests), task-events.test.ts "reads the log page by
page to its end" and chat.test.tsx "reads the log page by page"; they once
filled the log with events a client had to drop and now fill it with events the
route keeps, and also assert the types asked for. The "before you create" tests of
new-dot.test.tsx count the host's rows too.

Floors (raised, none lowered): vitest linux 1532 to 1542 and win32 1535 to 1545,
playwright 31 to 33 on both.
2026-10-06 07:48:17 +02:00
feder-cr 5757281b5d guest: a relative dangling link resolves from the real directory of the link
Block host3 review, one finding, fixed.

resolveLinks (the confinement of the host's file routes to home) joined the
relative target of a dangling link to the directory of the link as written,
whose own links were not followed yet. Join cleans a ".." lexically, the
kernel applies it after following those links, so a path through a link got
the wrong real location: a link out of home could be served from a different
file in home (home/out -> outside and outside/d -> ../stolen answered 200 with
home/stolen), and a location inside home got a 403 (home/a -> home/s1/s2/s3 and
s3/l -> ../../y answered 403 where s1/s2/s3/l answers 404). Nothing outside home
was read or written, os.Root confines the open, but the answer was wrong both
ways. The directory of the link is now resolved (EvalSymlinks, it exists:
Readlink just went through it) before the target is joined to it.

TestRemoteFilesResolveARelativeDanglingLinkFromItsRealDirectory has both cases;
it fails on the previous files.go (200 with the home file, then 403 instead of
404) and passes now. One part of the finding is rejected: a PUT to a/l is not
expected to create home/s1/y. Every PUT replaces the entry it names through a
temporary file and a rename, a link included (the daemon's contract, README:
the directory is resolved, the entry is not), so it lands as a file at
home/s1/s2/s3/l, inside home; the test says so. The PUT through out/d stays a
403.

The go floor of linux is 108 (was 107), one test added.
2026-10-06 07:46:52 +02:00
feder-cr db84713979 Merge lane-host into lane-web: its low findings L9 (M3, the tip that arrived while the first merge was made)
lane-host gained 3f4f3fd4 (a dangling link under home that leaves it is a 403,
expected_config_version in the shared PATCH request, migration 0008 resolving a
bot bound twice) after the first merge of this step, so its current tip is merged
now.

Conflicts and their resolution

- .github/test-floors.json: both sides raised floors again. Set to what this tree
  measures without the web step that follows (nothing lowered): vitest 1532
  linux / 1535 win32, postgres 624 with 250 pg tests, go 108 linux / 64 win32,
  pytest 2421, playwright 31.

One fix beside the merge, in a test of lane-host (packages/channels/test/hub-whatsapp.test.ts,
"shows the connection's failures as status..."): it read the channel's status as
soon as the new connection existed, but the loss is written as a status after
the new connection is made (reports are applied in order, after a database
write), so on PostgreSQL it read the stale "connected" and failed (seen once in
a Linux run against PostgreSQL 18; the test passes on both after the change).
The test now waits until the status has left "connected"; the assertion that it
is error or connecting is the same.

Removed tests: none.
2026-10-06 07:46:20 +02:00
feder-cr ae68f3aff0 engine, image-builder: the low findings of the third browser review and two unproven items (L7)
Block browserlows2 review: four low findings and two items of its unproven list.
Every one was checked against the code; each is fixed at its origin with a test
that failed before the fix, none is rejected.

Fixed (low, one cache name for every GeoIP release): confirmed. downloadFileName
is the last URL segment, which is geoip-aio-all.mmdb.zip for every release, so a
pin bump made fetchVerified delete the only verified copy of the previous
release, which upstream has deleted by then (the README promised the opposite).
The cache now names the archive geoipCacheName(pin) = geoip-<tag>-<file>; the
seed keeps the plain name, which the guest finds the archive by. Test: after two
builds with different tags both archives are cached, and a third build for the
first tag, whose URL now answers 404, makes no GeoIP request.

Fixed (low, the "pinned release is gone" message chosen by matching message
text): confirmed. DownloadError has a typed status, set where the answer is read
(openResponse), and fetchGeoip tests status === 404. Test: a 404, a 503 and a
hash mismatch give status 404, 503 and undefined.

Fixed (low, architecture.md 3.3 silent on the GeoIP input): confirmed. Section
3.3 now lists the archive among the contents and among the pinned inputs (URL
and SHA-256, rechecked in the guest, recorded as pinned.geoip, cached by tag,
credits in the manifest), with the caveat that a later launch may fetch a newer
release. The engine paragraph of the browser manager and UPSTREAM.md describe the
new stderr route and the escaped forms below.

Fixed (low, a launch_failed that reaches a route raises KeyError): confirmed as a
latent hole: nothing but the structure of close and delete kept it out.
_identity_error looks the status up and, for a code no route answers with, logs an
error and answers 500 internal with the reason kept, instead of failing in the
table. Test: a close that raises launch_failed answers 500 internal with the
message and logs it.

Fixed (unproven a, the MCP server's stderr reaches the journal unfiltered):
confirmed by construction: stdio_client was started without errlog, so the server
inherits the engine's stderr, and the scrub only covered answers that pass through
BrowserManager._request. Whether the real server prints the password could not be
proven either way, and it does not matter: the fix removes the path. The one place
the engine starts MCP servers (connect_mcp_servers) now gives a server whose
config has MCPServerConfig.stderr_filter a pipe (nanobot/agent/tools/mcp_stderr.py):
a thread reads it, passes each line through the filter and writes it to the
engine's stderr. A line the filter fails on is withheld, never written raw; a long
line is cut with the last 4096 characters held back, so a password straddling a
read is whole in the next piece; a journal that cannot be written does not stop
the reading, because a full pipe would block the server. The BrowserManager sets
the filter to its own _scrub, so one scrub covers answers, errors, frames and
stderr; a server without a filter is started as before. The scrub also found only
the written, decoded, quote(safe="") and Basic forms; it now also hides
quote with its default safe="/", and the URL and the password as repr (a doubled
backslash, both quote styles) and as the inside of a JSON string (ASCII-escaped
and not). Tests: the fake server (stderr_proxy) prints the URL and every form of
the password, one per line and once inside a 320 KB line, and the journal (capfd)
has all of it with the credentials hidden, for a percent-encoded proxy and for one
with a quote, a backslash and a slash in its password; the forms test now runs
every form for both proxies; _scrub is checked on a raw backslash and both kinds of
quote; 205 tests of the pump (a cut at every offset around a password, a multibyte
character cut by a read, a failing filter, a failing sink, a real child process);
connect_mcp_servers passes errlog only for a server with a filter.

Fixed (unproven b, the GeoIP data's attribution and licence): confirmed that
THIRD_PARTY_NOTICES.md covered only copied code. daijro/geoip-all-in-one (GPL-3.0
code) merges IP2Location LITE (CC BY-SA 4.0), MaxMind GeoLite2 (its EULA), DB-IP
Lite (CC BY 4.0), IPinfo and IPLocate.io free country databases (CC BY-SA 4.0), the
tdulcet GeoFeed+Whois+ASN country database (CC0) and, for the time zone computed
from the coordinates, OpenStreetMap data through tzfpy (ODbL); the credit lines
were read from the sources' own pages. guest/image-builder/src/geoip-notices.ts is
the one list: each golden manifest records it as notices, and a new section of
THIRD_PARTY_NOTICES.md carries the same text (the intro names it among what the
repository's license does not cover); tests/repo/third-party-notices.test.ts keeps
the document equal to the list. The image is built on the host of whoever runs
Dots and not distributed by this repository, which the section says. The
README of the image builder describes the cache name and the notices.

Not done, with the reason: the prepare-sleep budget item of the unproven list
(wait_for(close_all) can return while provider.aclose is still running) predates
the block, was not measured by the review and is not one of the two items this
step was given; no change was made and none is claimed.

Tests removed: none. Tests changed: the golden test's listing of images/.cache
expects the tag-first GeoIP file name (the name is what finding 1 changes), and
the golden manifest expectation gains notices. test_browser_manager's forms test
keeps every assertion and gains the new forms and a second proxy.

Floors (.github/test-floors.json), all raised, none lowered: vitest linux 753 ->
758, win32 756 -> 761 (+5: download 1, golden 1, notices 3); pytest 2417 -> 2645.
2026-10-06 07:41:52 +02:00
feder-cr 1444143876 Merge lane-host into lane-web: the channels H2-H6 and the host API U1-U4 (M3)
Merges lane-host at 96cfd800 (the channel hub, Telegram, approvals over
Telegram, WhatsApp through Baileys, the doctor in the API, event filters and
file reads, automations and tools routes, approve with always allow, and the
review fixes L6) into lane-web. Behavior of both sides is kept; the web gaps
that U1-U4 and H2 close are the next commit.

Conflicts and their resolution

- .github/test-floors.json: both sides raised every floor. Set to what this
  merged tree measures (nothing lowered): vitest 1531 linux / 1534 win32,
  postgres 622 with 249 pg tests, go 106 linux / 64 win32, pytest 2421,
  playwright 31 and 31.
- THIRD_PARTY_NOTICES.md: both sections kept (the web client's licenses, then the
  opt-in WhatsApp client and libsignal).
- package-lock.json: not text-merged. Started from lane-host's lock (it holds the
  Baileys and grammY trees) and ran npm install, which adds the web's
  dependencies; every version of the web lane's lock is unchanged except four
  hoisting changes (ansi-styles, tr46, webidl-conversions, whatwg-url) that
  Baileys' own tree forces.
- apps/scheduler/src/driver.ts, apps/vm-manager/src/guest-client.ts and
  packages/shared/src/protocol.ts (AGENT_ROUTES): union. The browser's
  identity frame and close next to the automations and tools calls.
- apps/scheduler/src/scheduler.ts: union of the identity frame and close, the
  automations and tools methods, the files-route path rule and the
  redelivery answer; the conversation keeps CONVERSATION_LIST_LIMIT (the web
  lane's owner of the 500) with the channel origin on its messages.
- apps/scheduler/src/testing.ts (FakeGuest): union; the identity frame and
  close, then automations, tools, files and links.
- apps/api/src/server.ts: union of the routes (identity frame and close,
  automations, tools, channels, files, doctor); the events route keeps
  MAX_EVENT_PAGE as its cap and takes the types and task_id filters.
- apps/api/test/api.test.ts, apps/vm-manager/test/guest-client.test.ts:
  union of the imports and of the tests (the frame and close test and the
  automations and tools test stay two tests).
- packages/sdk/src/client.ts: union (identity frame and close, then the
  channels calls).
- docs/architecture.md: the browser identity table keeps the frame and close
  rows with the automations and tools rows (lane-host's stale "the engine has no
  browser yet" rows are dropped); the messages line says the oldest 500 and the
  origin; the events limit paragraph and the doctor paragraph both stay; the
  web client section (9.7) and the channels section (9.8) both stay.
- guest/image-builder/test/smoke/smoke.sh and README.md: union. The tools table
  has 32 rows now (the browser's tools are in it), so the check says 32, and
  the memory-off push of the table check passes the browser's managed flag.
- invisible_engine_dots/nanobot/dots/engine.py, server.py: both sides' fields
  and imports (the browser manager and the registry the tools table reads; the
  cron automations).
- invisible_engine_dots/tests/dots/test_permissions.py, tests/fakes/engine_harness.py,
  tests/fakes/local_computer.py: union; lane-host's tool table tests take the
  browser manager's dependencies (_deps now needs the store) and the harness
  keeps the shared cron service next to the browser manager.

Fixed by the merge (not conflicts)

- invisible_engine_dots/tests/dots/wire_shapes.json is the engine's own answer
  (UPDATE_WIRE_SHAPES=1): its tool table has the browser tools of the table now.
  apps/scheduler's engine-shapes test reads it and passes.
- DotSummary has config_version: the web's FakeControlPlane records carry it.

Removed tests: none. Floors: raised only (see above).
2026-10-06 07:40:12 +02:00
feder-cr 6907ae0fa3 e2e: the real-VM end to end with the browser
tests/e2e/run.ts and README.md come back from 4b390f90 (the run that
drove the earlier TypeScript agent) and are rewritten for the engine, the
browser block and the one-command server. The run is a manual acceptance
test on a Linux host with an accelerator and a real OpenRouter key; it is
not in CI.

What the run checks now (steps a to o, README has the table):
- a: host preflight (Node 24, Linux, CLI, key file, dot-agentd is a
  linux/amd64 ELF, free ports, doctor with the kvm probe). `--check` is the
  dry mode: it stops after step a.
- c: `invisible-dots server` serves the web client too: login page, 401
  without a session, wrong token refused, sign-in, the proxy reaches the API.
- e: create the identity research, launch it explicitly, open example.com,
  read the heading, write heading.txt and a memory note; events, tool.called
  under each tool's permission with its target, task.progress,
  memory.written, the file hashed by the Dot and compared on the host, the
  spend on the events, the task and /usage.
- f: desktop PNG not blank; the identity's JPEG frame, directly and through
  the web proxy.
- h: no implicit launch; max_open evicts the least recently used identity
  and the browser servers in the guest are exactly the open identities; host
  close (frame then 409 not_open); host delete of an open identity.
- i: identity create/delete approvals: proxy password redacted in the
  approval, nothing happens before approval, password in no approval,
  identity record, call event or journal.
- j: stop/start: the open browser is closed on the way down, launched again
  with the same .stealth-identity.json; files, note, memory search and
  conversation kept.
- k, l: the computer is killed (SIGKILL of QEMU) during an approval and
  during a command. The engine cannot be killed alone from outside by design
  (dotengine and dot are separate users), so the old agent kill became a
  power cut; the control plane must record it and start the computer again,
  the same approval is pending, an interrupted call is reported once and
  never run again.

Changes from the old run: engine tool names (exec, write_file,
memory_search, browser_*), memory as a file under /home/dot/memory,
`/v1/agent/state` through dot-agentd's proxy instead of the agent socket
from inside the guest, DELETE answers 202, ground truth read through
dot-agentd instead of through the model where the model adds nothing.
tests/e2e/linux-host (stand-in Linux host for WSL) is restored; prepare.sh
builds the CLI and the web client instead of the deleted agent bundle.

CI coverage without a VM: tests/e2e/lib.ts holds what the run names and its
pure helpers; tests/repo/e2e.test.ts (39 tests) fails when a route, command,
flag, tool, permission, event type or doctor check the run names stops
existing in the product, when run.ts names something lib.ts does not
declare, and exercises the PNG and JPEG checks, the secret scans (across
chunk boundaries), the journal command under bash, the Dot's YAML through
the product's schema, the token read from a real seed.iso, the guest proof
and the waiting loop. tsconfig.json includes tests/e2e and allows .ts
import specifiers, because Node runs run.ts itself.

Rehearsed on this machine in the linux-host container (QEMU 8.2.2, kvm):
`run.ts --check` passes, and steps a and c (server, web client, key) pass
with a made-up key. THE RUN AGAINST A REAL VM IS STILL TO BE DONE BY THE
OWNER: this machine had no OpenRouter key and no image was built. The
assertions that guess the real model's or browser's behavior (the sentence
before each tool call that makes task.progress, the heading the model reads,
the engine closing a browser on the way down, the control plane restarting a
killed VM) are the likely first failures.

Tests removed: none. Floors in .github/test-floors.json raised for the 39
added tests: vitest linux 775 -> 814, win32 778 -> 817. No floor lowered.
2026-10-06 07:36:32 +02:00
feder-cr 3f4f3fd4bd guest, database, shared: the low findings of the block host2 review (L9)
Finding -> outcome.

1. A dangling link under home that points outside it answered 500 io_error
   instead of 403 outside_home (fixed). resolveLinks kept the missing tail as
   written, so a link to a path that does not exist yet looked like a missing file
   inside home, and os.Root then refused the escape with an error nobody maps. It
   now follows such a link by hand (bounded, 40 hops) to where its target would
   be, so the prefix check sees the real location: outside is a 403, inside is a
   404 on read and a created file on write, as for any link. Tests (fail before:
   500 on both): TestRemoteFilesRefuseDanglingLinksThatLeaveHome (absolute,
   relative, chained, directory links; GET, PUT and list; nothing is created
   outside) and TestRemoteFilesFollowDanglingLinksInsideHome.

2. expected_config_version missing from packages/shared PatchDotRequest (fixed;
   the field is expected_config_version since 96cfd800, not expected_updated_at).
   The wire contract now owns it, and the SDK types its PATCH body with it. The
   API server keeps its untyped body: it is untrusted JSON, and the scheduler
   validates it (a 400 invalid_request) rather than trusting a cast. The sdk test
   builds its conditional save from a PatchDotRequest, which does not typecheck
   without the field.

3. Migration 0008 aborted the start on a database with a bot bound twice (fixed).
   0008 now resolves the duplicates before it builds the index: the oldest
   binding (then by id) keeps the bot, each other goes to needs_relink with the
   account cleared and a detail naming the bot, which the hub shows and does not
   start; the credentials stay and a new token links it. The index is created
   there in its final Telegram-only form, so WhatsApp bindings that share a
   number (legal before 0008) are not touched. 0010 stays for databases that
   applied the first form, and its comment says so. Test (fails before with
   "could not create unique index"): a database before 0008 with one bot on three
   Dots and one phone on two, on PGlite and on PostgreSQL.

Removed tests: none. Test changes: none to kept behavior.
Floors raised: vitest linux 1020, win32 1023, postgres 614 with 245 pg tests,
go linux 107 (go win32 stays 63: the new Go tests are unix only).
2026-10-06 07:18:35 +02:00
feder-cr 96cfd8008a database, scheduler, channels: the save precondition is on the config version, and the account rule is Telegram's
Findings of the block host2 review.

Finding 1 (medium, fixed). `expected_updated_at` answered 409 dot_changed after
every status change, not only after a config change: `updated_at` is bumped by
`setStatus` too, which every task turn and approval runs. The precondition is now
on a new `dots.config_version` (migration 0009), bumped only by `updateConfig` and
`setPermission` and compared exactly, so the 1 ms tolerance window is gone as well.
It is on DotRecord, and the PATCH body field is `expected_config_version` (an
integer; anything else is a 400 invalid_request). SDK, API, architecture.md follow.
Tests: a status change between the read and the save is saved (repository and
scheduler); the always-allow test now asks the approval, waits for WAITING_APPROVAL
and only then reads the Dot, so the 409 can come from setPermission alone.

Finding 2 (medium, fixed). The unique index on (kind, account) also covered
WhatsApp, whose number is written at connect time: a second Dot linking the same
phone failed that write, which the runner swallowed, and the link stream never
ended. The decision is "one Telegram bot per Dot", so migration 0010 recreates the
index for kind = 'telegram' only; a phone may be linked on several Dots (each is a
device of it, as before 0008). Tests: hub-whatsapp links one phone on two Dots,
channels repository sets one number on two bindings; both fail on the old index.

Test changes: the adapters migration test assumed 0008 was the last migration, and
now takes the count of the ones before it (7) and what remains from the list.
Floors raised: vitest linux 1019, win32 1022, postgres 612 with 244 pg tests.
2026-10-06 06:58:25 +02:00
feder-cr 9527b8fa1e web: the web review findings (W5 chat, M1 screen): live scope by id, accepted messages, LIVE contrast
Fixed:
- The LIVE badge of the computer panel (screen-view.tsx) drew white text on --ok, which is about 1.9:1 in the dark theme. It now uses bg-ok-soft with text-ok, a pair tokens.test.ts already holds to 4.5:1 in both themes. screen-view.test.tsx reads the token classes of the LIVE and the stale badge and checks each pair in both themes; it failed on the white text.
- A Dot opened by its name (/dots/research/chat) had no live updates: DotEventScope, useDotLive, the rail and the attention state all key on the event's dot_id (the id), while the URL segment held the name. DotShell now replaces a name address with the id address (router.replace, keeping the tab, a task's address, the query and the fragment) as soon as the Dot is loaded and does not draw the tab's body under the name, so there is one address per Dot and everything below hears its events. chat.test.tsx renders the chat at a name address, follows router.replace, and delivers a live reply (it failed before: no replace, the reply never arrived); a Playwright spec does the same in a browser (31 specs now). The fake control plane resolves a name as the real routes do (requireDot). The task drawer's comment no longer says the address may be a name.
- An accepted message showed 'Sending...' for ever once the conversation list was cut at CONVERSATION_LIST_LIMIT, because the pending bubble is only dropped when the list holds its event. messageNote (chat-view.ts) says 'Sending...' only until the control plane answers (eventId null), and the Queued note now also applies to a pending bubble by its event id. The host route stays as declared (the oldest 500, a host-lane fix); the UI no longer states something false. chat.test.tsx fills the list to the limit and checks the answered message is not 'Sending...' and shows Queued (failed before); chat-view.test.ts covers messageNote.

Floors raised for the six vitest tests and the one Playwright spec added: vitest 1232 linux and 1235 win32, playwright 31. No test removed or weakened.
2026-10-06 06:55:54 +02:00
feder-cr 05496f445e image-builder, ci: the GeoIP pin has a daily check before upstream deletes it
Block browserlows2 review, one finding.

Fixed (high, pinned GeoIP release expires): confirmed. The pin names one release
of daijro/geoip-all-in-one by URL, upstream keeps only its latest two weekly
releases, so the URL answers 404 about two weeks after the release is published
and then the required browser-smoke job and every image build without a cached
archive fail with no code change. The sha256 pin and the golden digest input
stay. Of the two ways out the finding names, a project-owned mirror needs a
release store this repository does not have and creating one is not in this
change's reach (it is the owner's to decide), so the second one is taken and
written down in guest/image-builder/README.md: the pin is bumped by hand about
weekly, and nothing about it is a surprise any more.
.github/workflows/geoip-pin.yml runs .github/scripts/geoip-pin.mjs daily (and on
demand). It goes red as soon as a newer release exists, while the pin still
downloads, about a week before it is deleted; when the pin is gone; and when the
digest GitHub shows for the pinned asset is not the pinned sha256. Its message
names the tag, URL and sha256 to put into pins.json. It is a separate workflow
and not a job of tests.yml, so a red run is a reminder and never blocks a pull
request, and the gate keeps its ten jobs. A mirror, when the owner provides one,
is a change of the pin's URL only.

Test: tests/repo/geoip-pin.test.ts (6 tests: the newest pin passes; a newer
release, a deleted pin and a re-uploaded asset fail with what to pin; drafts and
prereleases are ignored; the workflow runs daily and is not part of the gate).
It could not pass before the change: neither the script nor the workflow existed.
The script sets process.exitCode and never calls process.exit after a fetch,
which aborts node on Windows (libuv assertion). Floors raised by 6 in
.github/test-floors.json: vitest linux 747 to 753, win32 750 to 756. No test was
removed or weakened.
2026-10-06 06:47:51 +02:00
feder-cr 63c371c062 host, guest, engine: the low findings of the host review (L6), and the host's file routes confined to home in dot-agentd
Every LOW finding of the channels and host API reviews was checked; each is
fixed at its origin with a test, none was rejected. One more, from the unproven
list, is real and is fixed first: the invisible-playwright-mcp server runs as dot
with the proxy password in its environment, so the lexical /home/dot rule of
the file routes let a symbolic link under home show /proc/<pid>/environ in the
UI, against the owner's decision that the password never appears in events,
logs or the UI.

Finding -> outcome

- hostapi 3 and the /proc link (the files routes follow symlinks): FIXED in
  dot-agentd, where the bytes are opened. The TCP listener (the host's door)
  now serves the three file routes through homeFS: every path is resolved with
  its symbolic links followed and refused, 403 outside_home, unless its real
  location is under the real home (so /proc is refused as it is outside home),
  then opened through an os.Root on home, which also refuses a link swapped in
  after the check. PUT, the listing and the atomic write go through the same
  file system; a link out of home lists as "other" with no size; a link that
  stays in home, absolute or relative, works. The engine's socket (agentd.sock)
  is not confined: the Dot owns its computer. checkHomePath stays as the early
  400. FakeGuest models the rule; a smoke check runs it as dot against the real
  /proc. Go tests: 4 fail with the confinement switched off (unix tests in
  files_links_test.go, the absolute-path ones in files_confined_test.go).
- hostapi 1 (index-use test): FIXED. The test now EXPLAINs the statement
  list() really issues (captured from the repository) on both adapters. The
  planner does use events_task_idx for it, so list() is unchanged and its
  comment, which blamed the wrong form, is corrected. This test could not fail
  before, by construction: confirmed, not a planner defect.
- hostapi 2 (pushed before delivered): FIXED. InboundDelivery.hold(dotId): the
  Dot's deliveries wait, before every row, from before the commit of an "always
  allow" answer until the config push is over. The new test, with a slow push and
  a kick and kickDue between commit and push, fails without the hold.
- hostapi 4 (full PATCH reverts an always-allow): FIXED. PATCH /api/dots/:id takes
  expected_updated_at; a Dot that changed since is 409 dot_changed and nothing is
  saved (DotsRepository.updateConfig, DotChangedError; SDK updateDot takes it).
  Optional, so the CLI and old callers are unchanged. W10 should send the
  updated_at of the Dot it loaded.
- hostapi 5 (U4 test premise): FIXED. The row of an unknown permission is seeded
  through the repository (the real client parses every event with PERMISSIONS);
  the guard's comment names the reachable case (a row stored before the
  permission was deleted, decision C1).
- hostapi 6 (no shape parity): FIXED. automationSchema and toolInfoSchema (strict
  zod) in packages/shared own the shapes, the interfaces derive from them. The
  engine test test_wire_shapes.py writes what automation_json and tool_table
  answer into tests/dots/wire_shapes.json (and compares to it); a host test parses
  the file with the schemas, checks the permissions against PERMISSIONS and holds
  FakeGuest to the same offered tools. FakeGuest.listTools now follows
  memory.enabled and has the two memory tools. The memory case fails without it.
- channels 3 (show_arguments, automation answers vs notify): FIXED, not
  recorded as a deviation. ChannelSettings gets show_arguments (default true);
  an approval prompt and its outcome leave the arguments out when it is off.
  notify_tasks now also gates an answer that answers no message (an
  automation's), as design 3.3 says. Migration 0008 gives existing bindings
  show_arguments true. Architecture 5.4/9.8 updated.
- channels 4 (one bot per Dot by read then write): FIXED. Migration 0008 adds the
  unique partial index channel_bindings_account_key on (kind, account) where the
  account is known; add and setCredentials map its violation to 409
  account_in_use; the early check stays for the readable answer. A test makes
  another Dot take the bot between check and insert; it fails without the index.
- channels 5 (30 bits): FIXED. The comment says 24 bits (the hex tail of a UUIDv4);
  the architecture does not give the figure.

Tests changed because their feature changed (none removed, none weakened)

- TestFilesAbsoluteAndTildePaths (dot-agentd): the absolute-path write to a
  temporary directory is sent to agentd.sock (doLocal), which still takes any
  path; the same through the TCP port is a 403 in the new tests. The ~/ half is
  unchanged.
- channels.test.ts (database): the "settings and enabled" test used the account
  my_bot, which the "checked account" test already holds; the unique index forbids
  two Dots with it, so it uses status_bot. Settings objects carry show_arguments.
- hub.test.ts "sends task results unless notify_tasks is off": its closing marker
  was an automation's answer, which notify_tasks now silences; it is an answer
  to the person's own message instead (same ordering argument). Settings
  expectations carry show_arguments.
- approval.test.ts: the prompt functions take showArguments; every assertion is
  the same with true.
- api.test.ts and scheduler.test.ts: the fake tool table has the two memory tools;
  the expected lists are extended.
- repositories.test.ts: the hand-written EXPLAIN is replaced by the captured one;
  the migration list gains 0008.

Floors raised: vitest linux 1000 -> 1016, win32 1003 -> 1019; postgres 578 -> 606
(pg 232 -> 241); go linux 97 -> 105, win32 61 -> 63; pytest 2286 -> 2288. No floor
lowered.
2026-10-06 06:33:23 +02:00
feder-cr aedd4e933b web, shared: the low findings and unproven accessibility points of the web review (L4)
Each finding of .local/next/weba-review.json that the three web fixes did not already close, confirmed or
rejected, and fixed at the origin with a test that failed before the fix. This step adds no UI feature.

Findings
- HIGH Tasks page under the legacy stylesheet: already fixed by 991ab863 (the shell no longer carries .legacy).
  Still true and still tested (e2e/tasks.spec.ts). This commit makes the e2e contrast read the fill the way the
  eye does: a translucent fill (the dark outline button's bg-input/30) is painted over the surface under it
  instead of being skipped.
- MEDIUM input/textarea/label headers (004b52c0) and MEDIUM the 200-task cut (cba11eae): already fixed, not touched.
- LOW loadTaskEvents copied the event page cap (1000): fixed. MAX_EVENT_PAGE is now exported by packages/shared
  and is the one owner for the store (packages/database), the route (apps/api, which also had a bare 1000) and the
  web's paging. The unused AbortSignal parameter of loadTaskEvents and readEventLog is deleted. The cost of reading
  the whole log while a task runs stays: it is the declared gap of the log route having no filter (step U2).
- LOW the drawer showed a task of another Dot under this Dot's address: fixed. The drawer compares the task's
  dot_id with the id of the Dot the shell loaded (the address may be the Dot's name) and treats another Dot's task
  as missing, showing nothing of it.
- LOW the Queue order lacked the dispatcher's id tie-break: fixed (priority, created_at, id). A test reads the
  ORDER BY of CLAIM_SQL from packages/database/src/tasks.ts and fails if it stops being the order the web uses.
- LOW docs/architecture.md not updated by W4: fixed. Section 9.7 now describes Home and Create a Dot, CONFIG_BOUNDS
  is named beside the config bounds, and MAX_EVENT_PAGE beside the events route.
- LOW every Home card's cost pill reloaded on every Dot's spend: fixed in CostPill, which now scopes itself to its
  Dot (DotEventScope), so every place that shows it is right.
- LOW the Playwright JUnit report was written only under CI, so a local guard run could read a stale one: fixed,
  the report is written on every run.
- LOW the ERROR banner lacked Reboot (design S4): fixed, with one deviation. The host reboots only a RUNNING
  computer (lifecycle.reboot), so with the computer itself in ERROR the banner offers "Start the computer"
  instead; with no computer it offers nothing. The confirm-while-a-task-runs and toast logic left PowerMenu for a
  hook, usePower, that the menu and the banner share.

Unproven points
- Dark-mode outline buttons on the Tasks page: measured. After 991ab863 the outline button has no text utility, so
  its text is the page's #f4f4f5 on the input color at 30 percent over the surface: more than 10 to 1. The review's
  prediction (primary-foreground text) no longer applies. New test/buttons.test.ts reads every variant of
  components/ui/button.tsx from the source, resolves each state (rest and hover, light and dark, on the page, a card
  and a popover, translucent fills composited) and requires 4.5 to 1. It found a real failure the review did not
  name: the default button on hover (bg-primary/90 with white text) was 4.41 to 1 on a card in the light theme.
  Fixed in the tokens: a new --primary-hover (#4a4ac4 light, #a3a4f5 dark), used by the button and the badge
  (their shadcn headers say so), and checked by tokens.test.ts like every other pair. The boundary of the outline
  button (border-input on the page, about 1.4 to 1) is rejected as a finding: the label identifies the control, so
  WCAG 1.4.11 does not ask for the border.
- Avatar initials: measured, 2.43 to 1 at the worst hue (60, the yellows) for the old 42 and 32 percent
  lightness. Fixed in lib/avatar.ts: the gradient ends are 28 and 22 percent, the worst hue is 5.06 to 1. A test
  samples 20000 ids, requires that every hue is reached, and requires 4.5 to 1 for white on both ends (the
  gradient between them is no lighter than its lighter end).
- Engine test 7386a0dc (pid gone instead of a graceful exit): not reproduced and not changed. The SDK reaps the
  child it kills, so there is no zombie for os.kill(pid, 0) to find, and a pid reused inside the test's
  millisecond window is not realistic. 25 of 25 runs pass in the engine container.
- Security, coverage and the remaining unproven items of the review need no change.

Removed tests, with the reason
- event-log.test.ts "asks for nothing more once told to stop" and the second half of task-events.test.ts
  "lets a failure through": they tested the AbortSignal parameter, which no caller ever passed and which is deleted.
  The kept behavior (a failure goes through to the caller) is still asserted.

Tests added: 75 vitest (buttons 63, tokens 2, avatar 1, tasks 1, task-view 2, home 1, dot-shell 3, playwright
config 2); the event-log and task-events tests now use MAX_EVENT_PAGE instead of the literal 1000.
Floors (.github/test-floors.json): vitest linux 1151 to 1226, win32 1154 to 1229. Playwright stays at 30 (no
spec added). Nothing lowered.

Suites: typecheck; vitest 1229 passed; Playwright 30 passed; engine pytest 2408 passed; dot-agentd go test ok.
2026-10-06 06:20:54 +02:00
feder-cr 2e32efa27b image-builder, engine, shared: the low findings of the second browser review (L5)
The review of 199e8391 and 6b48fb1b left one medium and seven low findings, each checked on the code. Finding -> outcome:

1. (medium) The browser error code to status table had a second hand-kept copy in the scheduler: already fixed by 39ac3052 (IDENTITY_ERROR_STATUS in packages/shared is the one owner, mirrored by protocol.py, parity tested). Checked, nothing redone.

2. GeoIP "rejected without an owner decision": confirmed, and fixed the way the owner decided (prefetch it in the golden image, design B7: a golden digest input). daijro/geoip-all-in-one is now pinned in pins.json by its exact release URL and the sha256 GitHub shows as the digest of the asset (measured: the file downloaded from the pinned URL hashes to it). The host downloads it with the other golden inputs and refuses a file that does not hash to the pin, before it boots anything; the seed carries the archive and GEOIP_TAG, GEOIP_ARCHIVE, GEOIP_SHA256; build-browser-env.sh checks the hash again, unpacks the archive into invisible_core's cache (cache_root()/geoip/<tag>) as dot and fails when the browser's own lookup would not find that file; provision.sh refuses an image whose cache holds another release than the pin. pins.json is part of the digest, so another release is another image, and the manifest records pinned.geoip. The script no longer calls ensure_geoip_mmdb, so a build fetches nothing by itself; the browser smoke fetches the same pinned archive and runs the same script (browser smoke 52 passed, the file landed in geoip/2026.09.30). A pin that upstream deleted answers 404 (it keeps only its latest two releases: measured, 2026.09.16 and older are gone): the build stops and says what to put into pins.json. What stays unpinned is what invisible-playwright itself does at a launch (fetch a newer release, keep the pinned one when GitHub cannot be reached); the README says so. The L2 text that called the file unpinned and unhashed on purpose is gone.

3. The proxy scrub replaced only the literal stored URL: confirmed, fixed at the origin (_scrub of browser.py, which every server answer passes through). The server splits the URL and unquotes the password, so the password alone (as written, decoded, encoded again), the user and the password together, and the Basic credentials of a header are now found with the URL in one pass, longest first, and put out in the redacted form. Tests: the fake MCP server can say each form (proxy_forms, echo_proxy_as); one test per form checks a failed launch, a failed navigate and a read; they all failed before the change. A proxy without a password is left alone. A short password garbles the page text it also occurs in: accepted, written in the code and in architecture 6.

4. A test that asserts nothing (test_a_provider_without_on_terminated_ignores_the_end_of_the_transport): confirmed, replaced by test_a_provider_without_on_terminated_does_nothing_about_the_end_of_the_transport, which checks no reconnect, no new connection, nothing closed, _connections unchanged and no task left running. Checked against a mutation of _transport_ended that starts a reconnect without on_terminated: the new test fails, the old one passed.

5. launch_failed in the scheduler's pass-through: confirmed, and the cause was the table, not the scheduler: no route of the engine launches (list, create, get, delete, frame, close raise none of it), so IDENTITY_ERROR_STATUS named a status no answer carries. launch_failed is out of the shared table and its mirror; browser.py has RouteErrorCode (what a route can fail with) and ErrorCode = RouteErrorCode plus launch_failed (what the model's tools fail with); test_protocol.py checks that the table's keys are RouteErrorCode and that launch_failed is the one code without a status. The scheduler test runs the codes of the table that are not 4xx, now busy, crashed and frame_failed. architecture 5.3 says it.

6. The postgres floor not raised: confirmed. Measured on postgres 18.4 with DATABASE_URL (packages/database packages/events apps/api apps/scheduler): 213 passed, 91 matching \bpg\b, against 205 and 84. Raised to what the job reaches.

7. The prepare-sleep browser budget had no shared owner or test: confirmed. PREPARE_SLEEP_TIMEOUT_MS (60 000) is in packages/shared protocol.ts and is the default of the scheduler's lifecycle and of GuestClient.prepareSleep (two literals gone); protocol.py mirrors it as PREPARE_SLEEP_TIMEOUT_S, tests/repo/vendored-nanobot.test.ts keeps them equal, and test_engine.py checks that STOP_GRACE_S + _CANCEL_WAIT_S + _CLOSE_BROWSERS_ON_SLEEP_S plus a 5 s margin fit inside it (today exactly 60).

8. Doc and smoke wording: confirmed. The sentence of architecture 8.7 is rewritten, and the smoke check label that said "the delete below" before the idle kill says "the idle kill below".

Not findings, left as they are: the four unproven points of the review (idle death with the real server, what the real server echoes of the proxy, a SIGTERM during a prepare-sleep close, the window between the transport end and _close_ended). The scrub no longer depends on what the real server echoes: every form it can echo is hidden.

Removed tests: test_a_provider_without_on_terminated_ignores_the_end_of_the_transport (replaced by the stronger test of finding 4, same behavior); test_every_error_code_of_a_browser_identity_has_one_status of test_protocol.py (replaced by the route-code version of finding 5); the GeoIP check of guest-files.test.ts that required ensure_geoip_mmdb (the script no longer fetches by itself; replaced by the checks of the pinned unpacking).

Tests: engine pytest 2409 -> 2417 (7 scrub, 1 prepare-sleep budget), vitest 744 -> 750 on win32 (2 pins, 3 golden, 1 guest files), go unchanged, engine smoke 214 passed, browser smoke 52 passed. Floors raised in .github/test-floors.json: pytest 2409 -> 2417, vitest linux 741 -> 747 and win32 744 -> 750, postgres 205 -> 213 with \bpg\b 84 -> 91. No floor lowered.
2026-10-06 06:16:45 +02:00
feder-cr 7d27ba3bee web: chat
Step W5 of the UI plan (S5): the Chat tab is replaced by a conversation that
shows what the Dot did to answer, and a computer panel beside it, built on what
the SDK offers today.

The page (/dots/<id>/chat)
- The person's messages are bubbles, the Dot's are unboxed markdown with its
  face at the first message of a group. Markdown is the existing component:
  no raw HTML, links open in a new tab with rel=noreferrer; now also a copy
  button on fenced blocks, and an image is drawn as a link to it (a page the
  Dot read can make it write any image address, and loading it would tell
  that address who is looking).
- Between the messages, the Dot's turn from the event log: each tool.called
  that names no task is one quiet line (words from lib/events/tool-labels.ts,
  the call's target, duration, and ok/failed/denied/interrupted when it was
  not an ordinary success); more than three in a row fold into one line that
  opens; a memory.written of such a call is a "Remembered trips/rome.md" chip;
  an approval.requested that names no task shows where it was asked and its
  answer as a receipt. A step is placed between two messages by event id
  (the messages route and the log are two views of one log). Calls and notes
  that belong to a task stay in that task's story.
- A working row at the end while the agent thinks, plans or runs a tool, with
  the last step of the turn. Live: everything above follows the stream.
- Composer: grows with the text, Enter sends, Shift+Enter adds a line,
  focused on arrival, a draft per Dot in localStorage (try/catch, a
  convenience), closed with a reason for a disabled or deleting Dot, a hint
  when the computer is stopped or starting. A sent message shows at once and
  is replaced by the logged one (POST answers its event_id); a refused one
  gives its text back; a "queued" delivery says the computer is waking up
  until the agent reports.
- Empty state: the goal and three first messages (the last drawn from the
  goal) that fill the box and do not send.
- The Dot header gets "Watch the computer" on the chat tab. The panel shows
  the desktop (3 s) or one of the open browsers (B8 frame, 2 s) as pictures
  read while the page is visible, with LIVE, a warning past 15 s, the words
  "The Dot has control. You are watching.", Refresh and Open full size. A
  closed browser sends the view back to the desktop, a busy one keeps its last
  picture and says why, a stopped computer says so and asks nothing. A column
  from 1024 px, a sheet below it; the choice is remembered on wide windows
  only.

Where things live
- lib/chat-thread.ts (the thread, steps, folding, pending messages),
  lib/chat-view.ts (composer state, suggestions), lib/draft.ts,
  lib/event-log.ts (the one function that pages through the event log, shared
  now by the chat and the Tasks page; loadTaskEvents is one line over it),
  lib/events/tool-labels.ts, lib/use-visible-interval.ts, lib/use-min-width.ts,
  components/chat/*, components/computer/*, components/dot/use-ring.ts (the
  ring that DotHeader computed inline, now shared with the chat).
- Copied code, each file with its Derived from header and its notice:
  AI Elements (Vercel, Apache-2.0) conversation.tsx and tool-status.tsx;
  CopilotKit/OpenDots (MIT) tool-labels.ts and use-visible-interval.ts;
  open-cowork (MIT, code only) screen-view.tsx; nanobot webui (MIT)
  activity-step.tsx. THIRD_PARTY_NOTICES.md gains the AI Elements section
  (with the Apache text once) and the open-cowork section, lists the new
  OpenDots files, and the nanobot section says which file derives from its web
  UI. use-stick-to-bottom (MIT) is the one new dependency.
- A test reads the engine's TOOL_PERMISSIONS and fails when the label map
  lacks a tool or names one that is gone.
- ChatTab.tsx is deleted and the old stylesheet rules only it used (.chat,
  .messages, .message*, .chat-form) are removed from legacy.css. The Tasks
  drawer, which showed raw tool names, now shows the same words (the raw name
  is the line's title).
- CONVERSATION_LIST_LIMIT (500) in shared is now the owner of the number
  that scheduler.conversation() used as a literal.

Gaps left on purpose, with no fake data and no stub
- The event log has no filter by type or task yet (step U2): the chat reads
  the Dot's log page by page from its start (only the chat's event types are
  kept) and follows it live. U2 changes the body of readEventLog and nothing
  else.
- GET /api/dots/:id/messages answers with the OLDEST 500 messages, so a
  longer conversation never shows its newest ones. Fixing the route (the
  newest, or paging) is a host change that belongs to the host lane; the chat
  says, when the list reaches the limit, that only the first 500 are listed.
- Approvals in the conversation are a line that links to the Dot's approvals
  (and a receipt once answered); the inline card with Allow once / Always allow
  / Deny is W6 (S7) and needs U4.
- No "via Telegram/WhatsApp" chip on a message: origin is step H2.
- The memory chip is not a link: the Memory tab (W9) does not exist yet.
- The panel's "the Dot is using this browser now" marker and the URL bar of a
  browser frame need the tool target of W8's Browser sub-tab; the panel offers
  the open browsers and their frames, which B8 provides.

Tests added: 97 vitest (thread view model, tool-label parity with the engine
table, composer state, draft, event-log paging, visible-interval hook,
format/agent helpers, markdown copy button and image links, the chat page over
FakeControlPlane which now serves messages, screenshots, identities and
frames, the computer panel and screen view, 12 notices checks for the six new
Derived-from files) and 7 Playwright specs (a turn from message to markdown
answer with steps, working row and chip; an approval asked in the chat and its
receipt; the panel with the desktop and a browser opened, busy and closed;
a stopped computer; keyboard and draft across a reload; 390 px with a sheet;
contrast of the bubble in light and dark).

Removed tests: none. One selector changed, not weakened: shell.spec.ts looked
for the header's goal button by /Watch the fares/, which the empty chat's
"Start on your goal: Watch the fares ..." suggestion now also matches; it is
anchored with ^. tasks.test.tsx gained two assertions (the drawer's tool
words).

Floors (.github/test-floors.json): vitest linux 1054 to 1151, win32 1057 to
1154; playwright 23 to 30 on both. None lowered.

Suites: typecheck, vitest 1154 passed (Windows), Playwright 30 passed,
engine pytest 2408 passed, go test ./... ok, next build ok. The smoke was not
run: no engine, guest or image file changed.
2026-10-06 05:53:38 +02:00
feder-cr ff3ee34716 api: GET /api/doctor runs one report at a time and keeps the image verdicts
Block hostapi review, one finding.

Fixed (medium): GET /api/doctor re-hashed the whole golden image and the
runtime ISO and re-ran the QEMU accelerator probe on every request, with no
cache and no coalescing; the onboarding checklist re-checks and polls, and
concurrent requests stacked up. Confirmed on the code: verifyImage runs
sha256File over each image on every call. Two fixes, each at its owner:
- server.ts: the route runs one report at a time; a request that arrives
  while it runs shares its answer. The probe is the slow row.
- doctor.ts: createImageChecks replaces imageChecks and keeps each verdict
  for as long as the image and its manifest keep the same size and
  modification time, so the hash reruns only when a file changes. The API
  server and the CLI both use it; the CLI runs once per command and loses
  nothing.
Tests: three concurrent GETs run the probe once and the next one runs it
again (fails before the fix: three probes); the image verdict is computed
once across repeated checks, again when the image or its manifest changes,
failed verdicts are kept with their fix, and no image means no hashing.
Floors: vitest 995 to 1000 on linux, 998 to 1003 on win32 (five tests added,
none removed).
2026-10-06 05:40:36 +02:00
feder-cr 39ac3052d6 shared, engine, scheduler: the identity error statuses have one owner
The status of each error code the engine answers on the identity routes was kept
by hand in three places: server.py _IDENTITY_STATUS, a table in the scheduler and a
literal union in the scheduler's test guest, with nothing linking them.
IDENTITY_ERROR_STATUS in packages/shared is now the one owner. protocol.py
mirrors it and server.py answers from that mirror; tests/repo/vendored-nanobot.test.ts
reads the mirror and compares it with the shared table, and test_protocol.py
checks its keys are the codes of browser.ErrorCode. The scheduler passes an
answer through when isIdentityAnswer (code and status together) says it is the
engine's, and FakeGuest takes its fault code and status from the shared table.

Finding (medium, a second hand-kept copy of the browser error code to status
table in apps/scheduler): fixed as described above. The scheduler's frame test
now runs every non-4xx code of the shared table.

Floors raised for the added tests: vitest linux 739 to 741, win32 742 to 744,
pytest 2408 to 2409.
2026-10-06 05:27:44 +02:00
feder-cr 9afef731f1 merge: the browser block (dots-ui-browser-channels) into lane-fixes
Brings the browser block B1-B8 and its review fixes (browser tools, routes and
health, identity frame and close for the UI, the real invisible-playwright-mcp
in the smoke, the L2 low findings) onto the lane fixes. No migration number
clashes: the browser block adds none (the host's cleanup migrations stay 0008
and 0009 and the engine's store schema version stays 7, set by lane-fixes).

Conflicts and their resolutions:

- invisible_engine_dots/nanobot/dots/permissions.py: both sides added a field to
  ToolEntry (lane-fixes: starts_terminal, with tool_starts_terminal; the browser
  block: needs_managed_identities and arguments, with tool_arguments) and the
  browser block changed the offered_tools signature (managed_identities). Kept
  every field and function of both sides, in that order; offered_tools takes
  the browser block's signature (lane-fixes did not change it).
- invisible_engine_dots/tests/dots/test_browser_manager.py: both sides fixed the
  same test, a launch cancelled once the server has answered. Kept one version:
  the wait is on the server's "done browser_open" record (lane-fixes), through
  the browser block's single `_until(condition, timeout_s)` helper, which now
  also serves the wait for the killed process; lane-fixes' second `_until` and
  its `asyncio.wait_for` wrappers are removed.
- .github/test-floors.json: both sides raised floors. Set to the measured
  counts of the merged tree (below).

Floors (all raised, none lowered):

- vitest: linux 757 -> 775, win32 760 -> 778 (778 measured on win32; linux is
  win32 minus the 3 tests that need a real Windows PowerShell)
- postgres: 220 -> 228, pg-named 93 -> 97 (measured against a postgres 18.4
  container)
- go: linux 97 -> 98 (measured in golang:1.26 with --init), win32 61 -> 63
- pytest: 2290 -> 2423

Run on the merged tree: typecheck clean; vitest 778 passed; the pg suites 228
passed; go 63 passed on Windows and 98 on Linux; engine pytest 2423 passed;
web build ok; engine smoke 215 passed 0 failed 0 skipped; browser smoke 52
passed 0 failed 0 skipped. No test was removed.
2026-10-06 05:24:23 +02:00
feder-cr 25d47c05c1 host: approve with always allow
POST /api/approvals/:id/approve takes { note?, always?: true }. With always, the
Scheduler records the decision, the approval.resolved event and
permissions[<the approval's permission>] = allow in the Dot's config in ONE
transaction (DotsRepository.setPermission is one jsonb statement that touches
no other key, so a PATCH racing with it is not lost), then pushes the config to
the guest like a PATCH does (the push is now one private method shared with
updateDot, which logs dot.updated) before the answer is delivered. An answer
that loses the race for the approval is a 409 and changes nothing. always on a
rejection, a value other than true, and a permission the host does not know are
400s that leave the approval pending. approval.resolved carries always: true.

Surface: SDK approve/reject take a request object (ApproveRequest with always,
RejectRequest; the unused ApprovalDecisionRequest becomes them), the CLI gets
approve --always, the channel hub's host port drops the note parameter it never
passed, the web approvals list follows the SDK signature, the architecture doc
lists the route.

Tests: setPermission (existing key, absent permissions, unknown Dot), the
Scheduler (config changed and pushed, events, 409 idempotent, refusals, a
sleeping Dot pushed on the wake), HTTP, CLI.

No test removed. Floors raised: vitest linux 990 to 995, win32 993 to 998,
postgres 568 to 578 and its pg-named require 227 to 232.
2026-10-06 05:19:53 +02:00
feder-cr aaa6f5b5a0 Merge dots-ui-browser-channels into lane-web: the browser block B1-B8 and its review fixes (M1)
Brings the browser block (engine browser tools, routes and health, the browser seams of the
smoke with a fake and with the real invisible-playwright-mcp, the identity frame and close
for the UI, the launch and navigate fixes, the low findings of the review) into the web
lane, so the UI work builds on the real SDK frames. The merge itself changes no behavior of
either side; the three conflicts, and how each was resolved:

- invisible_engine_dots/tests/fakes/local_computer.py (imports): this lane had dropped
  `import os` (the local computer ends a timed-out run with kill_process_group, so nothing
  in the file uses os any more); the browser branch added `import base64` for the double's
  desktop PNG and kept `import os`. Resolved to `import base64` only: both sides' behavior
  stays, and no unused import is left.
- invisible_engine_dots/tests/dots/test_browser_manager.py (the cancelled launch test):
  both branches had fixed the same flake, a test that cancelled a launch before the fake
  MCP server's process had started. This lane added Env.until_recorded (waits for the
  server's record of `browser_open`); the browser branch polled env.calls() inline, in
  that test and in start_slow_launch. One correct version is kept: Env.until_recorded,
  used by both places, so the wait exists once. The inline polling loops are gone.
- .github/test-floors.json: both sides raised the vitest and pytest floors for their own
  tests and this lane added the playwright floor. Every floor is now the measured count of
  the merged tree, not a sum.

Floors (measured on the merged tree; nothing lowered):
- vitest: linux 1036 -> 1054, win32 1039 -> 1057 (1057 passed, 0 skipped on Windows; linux
  is that minus the 3 tests that drive a real Windows PowerShell, the same difference as before)
- postgres: linux 206 -> 215, pg-parametrized 85 -> 92 (215 passed on PostgreSQL 18.4 with
  DATABASE_URL set; no test of these packages is platform conditional)
- go: linux 98 (unchanged, measured in golang:1.26 as a non-root user), win32 62 -> 63
  (63 passed, 2 skipped, the two allowed)
- pytest: linux 2275 -> 2408 (2408 passed, 0 skipped, in the engine's python:3.12 container)
- playwright: linux and win32 23 (unchanged, 23 passed)

No test was removed.
2026-10-06 05:05:40 +02:00
feder-cr a4f83f4919 shared, engine, host: automations and tools routes
The web UI needs the Dot's cron jobs (Memory > Automations) and its tool
table (Dot settings > Permissions and tools). Both are the engine's: the
jobs live in its CronService, the table in nanobot/dots/permissions.py.
The control plane keeps no copy of either, it passes through.

Engine routes (AGENT_ROUTES in shared and protocol.py, equal as the parity
test requires):
- GET /automations lists every job, paused ones too (automations.py shapes
  one: id, name, enabled, schedule with only the fields its kind uses,
  message, next/last run in milliseconds, last status and error,
  delete_after_run, created_at_ms).
- PATCH /automations/:id {enabled} pauses or resumes; a job already in that
  state is not touched, because CronService.enable_job moves the next run a
  whole interval on. 404 not_found, 400 invalid_automation, and a body with
  any other key is refused.
- DELETE /automations/🆔 204, 404, 409 protected for a system job.
- GET /tools: one row per tool of the permission table (tool_table in
  permissions.py): name, permission, offered now (what the projection
  offers; none before the first config) and the description of the tool's
  schema. The AgentServer takes the same CronService object the cron tool
  uses (main.py), so what the person sees is what the Dot made.

Host: GuestClient and the GuestApi driver interface get the four calls,
the Scheduler passes them through (a stopped computer is 409
computer_stopped, the guest's refusals keep their code, a flag that is not
a boolean is a 400 before the guest is called), the API serves GET/PATCH/
DELETE /api/dots/:id/automations[/:automationId] and GET /api/dots/:id/tools,
the SDK has listAutomations, setAutomationEnabled, deleteAutomation and
listTools. The wire types (Automation, AutomationSchedule, ToolInfo and the
list answers) have one owner, packages/shared protocol.ts. FakeGuest gets
putAutomation and a small tool table whose `offered` follows the pushed
permissions. docs/architecture.md 5.3, 8.8 and 9.6 describe the routes.

Tests: engine (server routes: list with each schedule kind and a last run,
pause/resume and the idempotent resume, strict body, remove and 404, system
job 409, methods, tool table before and after a config with memory off;
tool_table; the real process serves the jobs of the same service; the
server tests run the cron service as the process does, since a stopped
service replays its action log over every read), shared (route names), guest
client, scheduler (pass-through, bad flag before the guest, 409 and 502), API
over HTTP on both adapters, SDK. The smoke checks GET /tools through
dot-agentd against what the model was offered, GET /automations, and the
404 and 400 refusals (152 passed, 0 failed, 0 skipped on a git archive of
the tree). No test was removed or weakened; the harness and test_server
changes only pass the CronService the server now takes.

Floors raised: vitest linux 982 -> 990, win32 985 -> 993 (993 passed
measured), postgres 558 -> 568 with the pg-named requirement 222 -> 227
(checked against PostgreSQL 18.4: 568 passed, 227 pg-named), pytest 2275 ->
2286 (2286 passed). None lowered.
2026-10-06 05:02:27 +02:00
feder-cr 27c8b0881f database: the two cleanup migrations move to 0008 and 0009, past lane-host's 0007
Block fixes2 review finding (medium): migration 0007 collides again with
lane-host. Confirmed on the code. lane-host's 0cf127c3 added
0007_events_task.sql (the partial index behind the task filter of the events
route), so a merged tree held 0007_events_task.sql next to this lane's
0007_orphaned_secrets.sql, and loadMigrations, which refuses a repeated
number since 67e6fde1, would throw on every migrate and every test that opens
a database. The claim of 67e6fde1 that 0007 and 0008 are the next free
numbers was true when written and is out of date: lane-host's last is now
0007.

Fixed in this lane, the only one this step may touch: 0007_orphaned_secrets
is now 0008_orphaned_secrets and 0008_removed_config_names is
0009_removed_config_names (git mv, content unchanged). They only delete and
rewrite rows that already exist, so they depend on none of 0004 to 0007. The
review's alternative (lane-host's index taking 0009) would have had to be
made in the lane-host worktree. The version lists in repositories.test.ts
and the two constants of stale-data-migrations.test.ts, and architecture
7 and 9.1, name the new numbers. Checked: the union of this lane's
migrations directory and lane-host's (0001 to 0007) loads with loadMigrations
with no repeated number (0001 to 0003, 0004 to 0007 from lane-host, 0008 and
0009 from here).

Tests: the three tests that name the applied versions or the two files were
changed first and failed before the rename, passed after. The shipped
migrations carry unique increasing numbers (database.test.ts) stays as the
guard on the next merge. No test removed or weakened, none added, floors
unchanged. If lane-host adds a migration past 0007 before the merge, the same
guard fails loudly and the later one renumbers.

Suites: typecheck, vitest 760 passed, go test, web build, engine pytest 2290
passed.
2026-10-06 04:57:36 +02:00
feder-cr 199e839188 engine, host, guest: the low findings of the browser review (L2)
Review of the browser block found nine findings; the three high and medium ones were fixed in 6b48fb1b (checked on the code, not redone). The six low ones, each confirmed on the code:

1. Page-tool results and errors were not scrubbed of the proxy (fixed). Only browser_open and the frame scrubbed. The manager's _request is the one place a server answer enters the engine, so it now scrubs there (text, ToolResult errors and text blocks of a list result), and the two scrubs of browser_open and frame are gone. Test: a server that echoes the proxy in a navigate error and a read_text answer shows only the redacted proxy.

2. prepare-sleep gave the browsers 4 s in all (fixed). That budget is what is left of systemd's TimeoutStopSec=30 after the 20 s grace, which only SIGTERM has; the host waits 60 s for prepare-sleep. engine.py now has _CLOSE_BROWSERS_ON_STOP_S = 4 and _CLOSE_BROWSERS_ON_SLEEP_S = the browser_close timeout (30 s; 20 + 5 + 30 stays inside the host's 60), chosen by whether the engine is stopping. The review's second half (state left open when close_all is cancelled) does not hold since 6b48fb1b: the closes are tasks of the manager and the end of the wait cuts none. Test: a slow close finishes under suspend and is cut under stop.

3. The engine's coded 502 browser answers became 502 guest_unavailable (fixed). #guestCall passes busy 503 and now also frame_failed, crashed and launch_failed 502 through, from one table (ENGINE_BROWSER_ANSWERS); a 502 without the engine's code (a silent computer) is still guest_unavailable. The fake guest can fail a frame with a code. Test: each code passes through. Docs: the frame route in architecture 5.3 and protocol.ts say so.

4. A browser process that died while idle stayed open until the next call (fixed). Nothing watched the process. connect_mcp_servers takes on_ended, called when the read stream of a transport ends by itself (the read filter that already wraps it reports the end); MCPProvider with on_terminated passes it, ignoring a server that is not live or a provider that is closing, so a close of ours never reports. The manager closes an open session at once: one task per session closes it and emits closed once, shared by whoever finds out first (the transport, a call, a frame). Tests: the idle death (a kill with no call) closes the identity, frees its slot so a launch closes nothing alive, says not_open, and emits closed once; the read filter reports the end after the messages; the provider reports only a live server. Smoke: a kill -9 of an idle server through the real relay and dot-agentd closes its identity with no call (engine smoke 212 -> 214 with the relaunch check; the event totals of the stream check are now launched 9, closed 6).

5. UPSTREAM.md did not list the on_terminated change of mcp.py (fixed), and now lists on_ended too.

6. The GeoIP database enters the golden image unpinned and unhashed (rejected as a fix, documented). Pinning cannot hold: daijro/geoip-all-in-one is rebuilt weekly and keeps only its latest releases, older tags answer 404 (invisible_core/download.py says so and never pins, for that reason), so a pinned tag would fail every build within weeks, and a launch refreshes the file anyway. It is a data file for the timezone lookup, not code. The image-builder README and build-browser-env.sh now say that it is unpinned and unhashed on purpose, that it is not an input of the golden digest, and that the manifest records which release each build had (the review's own second option).

Changed test: test_a_process_that_died_between_calls_is_found_by_the_next_call_and_by_a_close is replaced by test_a_process_that_dies_while_idle_closes_its_identity_at_once_and_frees_its_slot. Its premise (a death found by the next call or a close) is what finding 4 removes: the death is found when it happens, so a later call says not_open and a later close is a no-op. The death during a call (crashed) stays pinned by test_a_process_that_exited_is_a_crash_closed_once_and_launched_again_only_on_request and the frame one, both unchanged. test_a_browser_that_does_not_close_does_not_hold_the_stop keeps its assertion with the renamed constant. The monkeypatched connect_mcp_servers of test_mcp_provider_connect_propagates_external_cancellation takes the new argument.

Tests: engine pytest 2403 -> 2408 (1 replaced, 1 + 3 + 1 added), vitest 741 -> 742 on win32 (one added), go unchanged, engine smoke 214 passed, browser smoke 52 passed. Floors raised in .github/test-floors.json: pytest linux 2403 -> 2408, vitest linux 738 -> 739 and win32 741 -> 742. No floor lowered.
2026-10-06 04:50:46 +02:00
feder-cr cba11eae31 web, shared, database: the Tasks page says when the host's list is cut at the newest 200
GET /api/dots/:id/tasks answers with the newest 200 tasks (listByDot's default
limit, no paging in the route), and the Tasks page grouped and paged that cut
list as if it were all of it: a pending task older than 200 newer ones was
missing from the Queue, so the Next and #n positions were wrong, and older
history was unreachable without a word.

Real, and fixing the route (paging or a status filter) is a host-API change that
belongs to the host lane, so this commit does the part that is the web's and
the origin's: the number has one owner, TASK_LIST_LIMIT in shared (the database
default uses it, the page reads it), and the page, when the list reaches it,
says that the newest 200 are listed and that older ones, and the queue places
that depend on them, are not shown. Named gap: paging or a status filter on the
tasks route, then the page's History pages through it instead of a client-side
page over the cut list.

Tests: e2e (fails before: no notice) a Dot with 199 tasks shows none, one with
204 shows it; repositories (both adapters) the default list is the newest
TASK_LIST_LIMIT and a given limit overrides it.

Floors (the tests of this and the previous two commits: 19 notices tests, the
shadcn header ones, 1 repository test per adapter, 4 browser tests): vitest
linux 1016 to 1036 and win32 1019 to 1039, postgres 205 to 206 with the pg
requirement 84 to 85, playwright 19 to 23 on both. The previous commit's message
says 6 notices tests; it is 19. No floor lowered.
2026-10-06 04:46:48 +02:00
feder-cr 004b52c02a web: input, textarea and label carry their shadcn/ui header; the notice's claim is tested
input.tsx, textarea.tsx and label.tsx (added in W4) keep shadcn new-york-v4's
structure and class strings and had no Derived from comment, though
THIRD_PARTY_NOTICES.md says each file in components/ui/ says what changed. Each
now has its header, naming what changed (relative cn import, tokens instead of
the dark overrides, no file-input or selection styles, no field-sizing, a plain
label in place of the Radix primitive).

tests/repo/notices.test.ts only checked files that already had a header, so a
missing one passed. It now requires the header on every file under
apps/web/src/components/ui/ and on lib/utils.ts, the files the notice covers:
6 more tests, 3 of which fail without the headers. The vitest floors for them
are raised in the next commit with the others.
2026-10-06 04:45:55 +02:00
feder-cr 991ab86370 web: the shell no longer puts the old stylesheet over every tab
DotShell wrapped every tab body in .legacy, the Tasks page included. The legacy
layer sits above Tailwind's base, so for every property a utility does not set
the old rules won: the outline Button had no text color and took the old
button's white (invisible on the light surface, about 1.04:1), lists got their
bullets and numbers back, links went indigo. The role and name queries of the
Playwright specs cannot see any of that.

Fixed at the origin: the scope. DotShell renders its children without .legacy;
each tab that is still in the old design (Approvals, Chat, Computer, Browser
identities, Settings, Timeline) carries the class on its own page, so a page
that moves, and every later W page, is outside it without anyone remembering to
remove a class. The false claim in globals.css (a new component is never
overridden by an old rule) now says what keeps a new screen safe.

Tests (they fail on the previous build): the Tasks page, in light and dark, has
no .legacy element, its lists have no marker or padding, and the Cancel, history
filter (pressed and not) and New task buttons read at 4.5:1 or more against the
surface they sit on, computed in the page from the painted pixels; a tab still
in the old design (Settings) keeps .legacy.

Playwright floor 19 to 22 here (23 with the next commit).
2026-10-06 04:45:46 +02:00
feder-cr 58cc48e67e cli, api, web, engine: the LOW review findings of the lane fixes
Findings of .local/next/fixes-review.json (severity low), each confirmed and
fixed at its origin, with a test that fails before the fix. The cross-lane
finding about the Playwright harness and `next start` is not here: it is
resolved when the lane-web branch is merged.

- kill -9 of `invisible-dots server` orphans the web child on port 3000 ->
  fixed. A child started with startProcess outlives a parent that is killed
  outright (confirmed on Linux: the child stays after SIGKILL of the parent;
  on Windows libuv's job object ends it, so there the finding did not show).
  `webEnvironment` now gives the child the server's pid
  (INVISIBLE_DOTS_WEB_PARENT_PID, shared ENV) and the web build's
  instrumentation.ts (lib/parent.ts, shared processExists) exits the server
  once that process is gone. Checked on the real standalone build: it answers
  while the parent lives and is gone 0.4 s after the parent exits. New:
  web.test (the pid reaches the child; a stand-in server SIGKILLed frees the
  port), parent.test (3 tests).
- The tool calls of a response with no cost still run before the turn fails ->
  fixed, not documented away. turns.py now calls spend.ensure_priced() before
  the assistant_tool_calls checkpoint as well as final_response, so the calls
  are neither written nor run. spend.py and architecture 8.2 say so. New
  test_turns case: no cost plus a tool call fails the turn, nothing is
  written, no tool.called.
- INVISIBLE_DOTS_WEB_LISTEN: the error cites the API's example, and port 0
  passes the parse -> fixed. parseListen takes a ListenSetting (variable,
  the setting's own example, fixedPort); serve.ts passes the web setting, so
  the error shows 127.0.0.1:3000 and port 0 fails the command before anything
  starts. serve.test now uses the real parseListen instead of a copy of its
  rule. New: config.test (settings), serve.test (2 cases).

Removed test: web.test "does not start at all when the build is incomplete, or
the port is 0" lost its port 0 assertion, because the refusal moved from
startWebServer to parseListen (one owner of the fact, checked at parse time,
where it now fails at once); the same assertion is in config.test and
serve.test. The incomplete-build assertion stays under its own name.
Replaced assertion: config.test's parseListen(value, "VARIABLE") call, the old
two-argument form, is now the ListenSetting case.

Floors (.github/test-floors.json), raised for 7 added vitest tests, 1 of them
in the postgres suite's files, and 1 pytest: vitest linux 750 -> 757, win32
753 -> 760, postgres 219 -> 220, pytest 2289 -> 2290. Nothing lowered.
2026-10-06 04:38:06 +02:00
feder-cr 0cf127c329 host: event filters and file reads
The web UI needs a task's own events (the task drawer, the chat's tool
steps) and a read-only view of what a Dot keeps in its home (Files,
Memory notes). Both come from the control plane, so the browser never
talks to a guest.

GET /api/dots/:id/events takes ?types=a,b and ?task_id=. The filter is in
the repository (EventQuery.taskId, data->>'task_id', the one place the
contract puts the task), added to the SQL only when asked for, and served
by the partial expression index of migration 0007_events_task.sql; a test
asserts the planner takes events_task_idx for the query list() builds. A
type name no event has is a 400 (STORED_EVENT_TYPES in shared), so a typo
does not look like a quiet Dot. Scheduler.listEvents takes one filter
object instead of positional arguments.

GET /api/dots/:id/files/list and GET /api/dots/:id/files read through the
guest client, only under /home/dot. The rule is one function,
checkHomePath in shared: absolute, relative or ~ paths are normalized and
any ".." segment is refused rather than resolved, with a 400 invalid_path
before the guest is called. A stopped computer is 409 computer_stopped, the
guest's own refusals pass through with their code, and the guest's file
read is bounded: GuestClient.readFile takes maxBytes and stops reading as
soon as a file passes it (413 file_too_large; MAX_HOST_FILE_BYTES is 16
MiB). The bytes are a Dot's own and the web server answers from the page's
origin, so apps/api/src/file-types.ts serves images as images, source and
markup as text/plain, everything else as a download, always with nosniff,
a sandboxing CSP and no-store; the web proxy now passes
x-content-type-options and content-security-policy on. The SDK gets
events({ types, taskId }), listFiles and readFile; FakeGuest gets a small
file system (putFile) with the real daemon's errors. docs/architecture.md
9.6 describes the routes.

Tests: shared (checkHomePath, stored event types), repository (task
filter, combinations, index use), guest client (limit by announced length,
by running count, error bodies kept), scheduler (filters, path rule before
the guest, pass-through errors, 413, 502), API (filters over HTTP, files
list and read, headers, the 16 MiB limit, 409 and 404, 401), SDK query
encoding, file-types, web proxy headers. No test was removed or weakened.

Floors raised: vitest linux 959 -> 982, win32 962 -> 985, postgres 540 ->
558 with the pg-named requirement 215 -> 222 (checked against a real
PostgreSQL 18.4: 558 passed, 222 pg-named). None lowered.
2026-10-06 04:29:53 +02:00
feder-cr 16bb4d5118 engine tests: the local computer ends a timed-out run with kill_process_group
A run whose time budget is 0 (test_find_files_enforces_time_budget) can time out
after the child has already exited and been reaped. The double then called
os.killpg on a process group that was gone and the ProcessLookupError (Errno 3)
surfaced as 'Error finding files' instead of the budget error. The double now
uses the same kill_process_group the production path uses, which tolerates a
group that is already gone, so there is one owner of that fact.

No test is removed or weakened; the pytest floor (2275) is met: 2275 passed.
2026-10-06 04:17:53 +02:00
feder-cr 1c89b98694 engine tests: the cancelled launch waits for the server, then for the process to be gone
The test cancelled the launch after a fixed 0.3 s and then required the fake's
record to end with a clean exit entry. On a loaded machine the cancel could
land before the process had started (empty record), and the SDK ends a server
that is slow to leave after 2 s without it writing an exit entry. Reproduced
in a container with the CPUs saturated: 1 failure in 1 to 4 runs before,
25 of 25 runs pass after.

The test now cancels once the server has answered browser_open, so the launch
is inside its retry loop, and asserts what the contract says: no identity is
open and the server process no longer exists. No test removed, count unchanged.
2026-10-06 04:17:42 +02:00
feder-cr 35d857d8d4 host: the doctor in the API
GET /api/doctor answers the host report of architecture section 11.1 as the
server's own machine sees it: { ok, checks }, the same rows in the same order
as `invisible-dots doctor --json`. The web UI's onboarding screen needs it,
and the report must describe the host the server runs on.

One owner for every fact:
- runDoctor and the checks moved from apps/cli/src/doctor/checks.ts to
  apps/vm-manager/src/doctor.ts. The CLI keeps only rendering and the command.
- The wire types (DoctorCheck, DoctorCheckId, DoctorStatus, DoctorAnswer) are
  in packages/shared/src/api.ts; the SDK gets client.doctor().
- The accelerator's host side (/dev/kvm, the HypervisorPlatform feature) and
  the read-only PowerShell helpers it needs moved from the CLI's setup/install.ts
  and setup/powershell.ts to apps/vm-manager (accelerator-access.ts,
  powershell.ts). hostAccessDeps() is now the one place that hands this host's
  platform over; setup's install steps spread it. tests/repo/platform-branches
  and the docs table follow.
- QEMU discovery, free space and the real deps are hostDoctorDeps() in
  vm-manager/host-doctor.ts; the images' rows (latestImage + verifyImage) are
  imageChecks() in apps/api/src/doctor.ts, used by the API and the CLI alike
  (the API now depends on the image builder).
- The OpenRouter row is built by openRouterCheck(configured): the API asks
  its Scheduler, the CLI keeps asking the running server's /api/health.
  STORE_OPENROUTER_KEY moved next to SETUP_COMMAND in vm-manager errors.ts.
- startServer takes `doctor?: Partial<DoctorDeps>` so tests fake the machine
  while the images, the home and the key stay the server's own.

Tests moved, not weakened: the doctor checks tests to
vm-manager/test/doctor.test.ts, the accelerator access tests (and the
PowerShell helper assertions) to vm-manager/test/accelerator-access.test.ts,
the shared fakes to vm-manager/test/doctor-fakes.ts (apps/cli/test/fakes.ts
removed because the fakes live there now). The "any other host is refused"
install test is split in two. No test was removed.
New tests (+10): openRouterCheck, doctorAnswer, locateQemu (2), freeSpace,
hostDoctorDeps, the PowerShell helpers, GET /api/doctor over the SDK, and
startServer serving the report with its own images and key.

Floors (.github/test-floors.json), raised only: vitest linux 949 -> 959,
win32 952 -> 962, postgres 537 -> 540 (pg-named tests 214 -> 215).
2026-10-06 04:07:55 +02:00
feder-cr ddd0f38c13 web: tasks with live progress
Step W7 of the UI plan (S8): the Tasks tab is replaced by a page that shows
what the Dot is working on, live, built on what the SDK offers today.

The page (/dots/<id>/tasks)
- Running: a card per task with the newest task.progress line (it follows each
  new report from the stream without a reload), what the task has spent
  (the row's spent_usd), how long it has run, and Cancel after a question.
  A task waiting for an answer says so and links to the Dot's approvals.
- Scheduled and Queue: a pending task with a time ahead is scheduled; the rest
  are listed in the order the dispatcher takes them (priority, then age) with
  "Next" and their place.
- History: a table (task, status, finished, took, spent, result or the error as
  the Dot gave it) filtered by how the task ended and shown twenty at a time.
- New task is a dialog: the description, the priority by name (Low, Normal,
  High, Urgent, mapped to -10, 0, 10, 100), the raw number under Advanced, and
  a not-before time. A normal priority and no time send the description alone.
- The drawer has its own address, /dots/<id>/tasks/<taskId> (a layout holds the
  list and the task's page is the drawer over it, so opening one does not
  reload the list): state, numbers, the result rendered as markdown or the
  failure reason, and the task's story from the Dot's event log (created,
  started, each progress line, tool calls with their target, approvals with
  how they were answered, the end). GET /api/tasks/:id is used for the first
  time.
- The newest progress line of a running task is also shown under the goal in
  the Dot header, linked to the task (dot-live keeps it per Dot and forgets it
  when the task ends or the agent restarts).

Where things live
- lib/task-view.ts (sections, order, priorities, labels), lib/task-form.ts (the
  dialog's request), lib/task-events.ts (the log read, progress, the story),
  components/tasks/*, components/markdown.tsx (react-markdown with remark-gfm:
  no raw HTML, links open in a new tab with rel=noreferrer; chat reuses it),
  components/ui/dialog.tsx (shadcn). lib/time.ts and task-status.tsx derive
  from CopilotKit/OpenDots TaskPresentation.tsx (MIT, THIRD_PARTY_NOTICES.md has
  the section). TasksTab.tsx is deleted; the old stylesheet rules it used are
  all still used by the tabs not yet redesigned, so none was removed.

Gaps left on purpose, with no fake data and no stub
- The event log has no filter by task or type yet (step U2), so
  loadTaskEvents reads the Dot's log page by page from its start and keeps the
  events of tasks. It runs only once a task is running or open. U2 changes the
  body of that one function and nothing else.
- The inline approval card inside a waiting task (S7) belongs to W6; until then
  the card links to the Dot's approvals.
- Tool calls in the drawer are named as the engine names them; the friendly
  label map (CopilotKit ComputerToolCard) arrives with W5.
- assistant-ui's task-card.tsx is not in the reference clone (the package is
  installed from its registry, only the docs demo is), so the card is written
  here from its documented states; no assistant-ui code is copied and no notice
  entry is added for it.

Tests added: 70 vitest (64 in apps/web: time, task view model, task form, task
events and story, dot-live progress, markdown, the Tasks page, dialog, cancel,
history and drawer components over FakeControlPlane, which now serves tasks and
the event log; 6 are the notices checks of the three new files with a Derived
from header: dialog.tsx, time.ts, task-status.tsx) and 5 Playwright specs (live progress line from the fake
guest to the history and the drawer by its address, cancel, a failed task's
reason, queue and scheduled order, 390 px). No test removed. One selector of
e2e/shell.spec.ts changes because the page changed: the New task heading
became a button of that name, so the tab-body check looks for the button.
Floors raised: vitest linux 946 to 1016 and win32 949 to 1019, playwright 14 to
19 on both.
2026-10-06 04:02:54 +02:00
feder-cr 6b48fb1b34 engine, guest: a slow launch holds nothing up, navigate is web only, the proxy is off argv
Review of the browser block found three problems. All three were real.

1. PUT /config, host close/delete/create blocked on the manager lock
   during a launch (fixed). launch held the lock across the browser_open
   wait (up to 900 s), and set_limits, create, delete and close queued
   behind it, so the host's 30 s guest timeout turned a slow launch into
   502 guest_unavailable (and a retried POST made a duplicate identity).
   The manager now has no lock around a browser: a session is opening,
   open or closing, each move is a task of its own, and the decisions
   (reserve a slot, pick the least recently used to close) are made in one
   step with no await. set_limits is synchronous and returns at once: the
   excess closes run in the background and no longer count as open from
   that call on. Only create keeps a lock (its count check and insert are
   apart by the directory it makes). A delete marks the identity gone for
   every caller before it closes the browser and removes the directory.
   A launch that is cancelled still stops its own process before it
   returns. A close that raises is logged and the process is ended anyway,
   so an identity can never be stuck closing. engine.apply_browser_limits
   is synchronous with it. Kept as tested: a delete or close of the
   identity that is opening waits for that launch, then closes what it
   opened. New tests: a launch in flight holds up no config change, close,
   delete or create of another identity (manager and PUT /config over
   HTTP); a lower max_open answers at once and closes in the background;
   it also closes a browser that is still opening; two launches of one
   identity share one process and one outcome; a launch of an identity
   being deleted is not found; a close that raises. Changed assertion: the
   server test of a lower max_open now waits for the closed event, which
   comes after the 204 by design.

2. browser_navigate accepted file:, about:, view-source: (fixed). The
   pinned server calls page.goto(url) unchecked, so navigate plus read
   returned any file dot can read, whatever files.read said. The one tool
   row now carries a refusal: only ^https?:// is sent, anything else is an
   error result and the server sees no call; the schema carries the same
   pattern for the model (one constant). A press_key route to the URL bar
   does not exist: the server presses keys on the page, not the browser
   chrome. The tool table of the architecture says so. The fake MCP server's crash
   trigger moved from crash://now to https://crash.test/now, in the two
   tests that used it, because the tool now refuses other schemes.

3. The proxy password was on the relay's command line (fixed in the
   code, documented where it is accepted). /proc/<pid>/cmdline is readable
   by every user, and the doc said the model cannot read the proxy. The
   relay has a new --env-from NAME: it reads the value from its own
   environment (readable by its owner only) and forwards it. The Computer
   takes secrets by name in relay_argv and by value in spawn_env, and the
   manager passes the proxy that way. What Q11 accepted stays and is now
   written in architecture section 6: the dot user, and so the model
   through exec, can read the proxy from the browser's environment. The
   relay-order test no longer lists the proxy among the --env pairs (it
   is in env_from by design). New: Go test of the flag and of the lookup
   (an unset variable is exit 2 and the refusal never echoes a value),
   Python tests of the argv and of the environment path, the fake relay,
   and a smoke check that the password is on no process command line with
   a positive control on the relay's own flag.

Floors raised for the added tests: pytest 2382 to 2403, go 97 to 98 on
Linux and 61 to 62 on Windows. No test of kept behavior was removed.
2026-10-06 03:56:30 +02:00
feder-cr 67e6fde1a0 database: the two cleanup migrations take 0007 and 0008, and a repeated number is refused
Block review finding (medium): migration number clash with lane-host. Confirmed on the code. lane-host already holds 0004_channels, 0005_channel_prompts and 0006_inbound_by_event (e90c4650 and later), so a merge left 0005_channel_prompts and 0005_orphaned_secrets, and 0006_inbound_by_event and 0006_removed_config_names, side by side. The migrator sorts by full file name, so it would have applied both, but the rule of design section 5 (the step that lands earlier takes the next free number) was broken, and the commit text of 14bf175f ("0005 and 0006, not 0004") was already out of date. The review's suggested 0006 and 0007 are not free either, because lane-host's 0006 exists: the next free numbers past lane-host's last are 0007 and 0008.

Fixed at the origin:
- 0005_orphaned_secrets is now 0007_orphaned_secrets and 0006_removed_config_names is 0008_removed_config_names (git mv, content unchanged). The migrations only delete and rewrite data that already exists, so they do not depend on 0004 to 0006.
- loadMigrations refuses two files that carry the same number, naming both. Two branches that each took the next free number now fail where the migrations are loaded (every migrate, every test that opens a database), not by accident of string order in production.
- The list of applied versions in repositories.test.ts, the two constants of stale-data-migrations.test.ts and architecture 7 and 9.1 name the new numbers. stale-data-migrations.test.ts compares `file.version < version` as strings, which is the numeric order now that the numbers are unique and four digits wide.

Tests (database.test.ts): the shipped migrations carry a number each, in increasing order (passes before and after, it is the guard on the next merge); loadMigrations refuses two files with the same number (failed before the change: it loaded both). Checked by hand that the union of this branch's migrations and lane-host's (0001 to 0006) has no repeated number.

No test removed or weakened. Floors: vitest linux 748 to 750 and win32 751 to 753, postgres linux 217 to 219 (the two new tests, measured: vitest 753 passed on win32; the pg suite 219 passed against postgres:18.4 with 93 pg-named tests, unchanged).

Suites: typecheck, vitest 753 passed (one earlier run failed the guest-client test "ends the stream when nothing listens on the guest port", a port-reuse flake under full-suite load that passed 3 of 3 alone and on the rerun), pg suite 219 passed, go test, web build, engine pytest 2288 passed (one run failed test_browser_manager "a launch that is cancelled", a timing flake under load that passed twice alone: 40 passed).
2026-10-06 03:40:09 +02:00
feder-cr 18ba542205 host: one transaction for a channel message, for WhatsApp keys, and no write after a delete
Review findings of the channels block, all three confirmed on the code.

1. high, FIXED. A channel message was stored by Scheduler.sendMessage and its
   idempotency key by a second statement (channel_inbound.recordInbound), so a
   failure between the two (kill -9, a lost connection) made Telegram offer the
   update again and the Dot got it twice. The test that claimed to cover it
   asserted before the redelivery landed and only passed by timing. Fixed at the
   origin: the user.message event already carries binding_id and external_id, so
   the event log is the one owner and a unique partial index
   events_user_message_origin_key (dot_id, binding_id, external_id) makes the
   insert itself the key (migration 0006, which drops channel_inbound).
   sendMessage stores the message and its queue row together, or finds the first
   one and answers the repeat with it. Deleted with the table: recordInbound,
   inboundMessageId, pruneInbound, the hub's prune timer and the
   inboundRetentionDays limit (a key kept with the event needs no retention).
   Test: "hands a message to the Dot once when the failure comes after it was
   stored" stores the message through the real Scheduler, fails after COMMIT,
   waits for the redelivery and the answer, lets the hub go quiet, and asserts one
   user.message, one echo and one delivery to the guest. It fails without the
   index (2 user.message rows). Also added: the Scheduler repeat, and the events
   store on both adapters (same id on another binding or another Dot is a new
   message). The restart test of the hub passes unchanged.
   Removed because the feature no longer exists: the database test "an inbound
   message is recorded once by its channel id, and old records are pruned", the
   hub test "forgets handled messages older than the retention at start", and the
   channel_inbound row counts in the stranger tests (the event counts they sit
   next to still prove nothing was written). telegram.ts and architecture 5/9.8
   no longer say "nothing runs twice" about a mechanism that did not hold.

2. medium, FIXED. A WhatsApp session still open when its Dot is deleted could
   write its keys back as orphan secrets, because the hub stops the runner after
   the dot.deleted event and secrets has no foreign key to dots. The adapter now
   writes through ChannelSecrets.putAll (BindingSecrets), a transaction that
   first takes a key share on the binding row (ChannelsRepository.holdBinding): a
   delete of the binding waits for it, and once the binding is gone the write is
   refused with ChannelGoneError. Test: "goes with the Dot" opens a real
   AuthStore over the hub's secrets, writes keys, deletes the Dot, fires keys.set
   and saveCreds after the delete (both refused), and asserts every name of
   type.secretNames and every row of the Dot's scope is gone. It fails without
   the hold. Residual: a write already inside its transaction while the delete
   statement is running can leave one new row, because the delete's secrets part
   reads its snapshot at the start of the statement; the session is closed right
   after, and that is the only window.

3. medium, FIXED. AuthStore.#flush wrote the credentials and each dirty group
   with separate puts, so a crash could leave them a step apart and force a
   relink. It now builds one batch and writes it with a single putAll
   transaction; a failed write puts all of it back as dirty. The "a step apart"
   caveat is gone from the file header and architecture 9.8. Tests: the
   credentials and the groups of one flush are one batch, and a failing write
   writes none of them.

Floors raised for the added tests: vitest win32 951 to 952, linux 948 to 949,
postgres 535 to 537 with 214 pg tests (measured on postgres 18.4).
2026-10-06 03:38:02 +02:00
feder-cr 2d18eed435 web: Dots home and create a Dot
S2 and S3 of the UI design, built on what the SDK offers today.

Home (S2) is a card per Dot: avatar ring, name, goal (two lines), the status chip with the recorded reason beside an ERROR, the model, spend today (the existing CostPill), the approvals that wait (linked to the Dot's approvals), Open chat, and the computer's power menu (the existing PowerMenu, with its confirm while a task runs). Search appears above six Dots. The empty state invites the first Dot. The cards read the Dots and the live state from the shell, so there is one owner of the list.

Create a Dot (S3, /new) is a three step form on one page (Identity, Brain, Computer and safety) with sliders inside the schema's bounds, the idle timeout, the Careful / Balanced / Autonomous presets, the cost cap and the optional summary model; or the same config as YAML, with the form refusing to take back YAML that sets what it has no control for instead of dropping it. The shared schema is the only judge: lib/dot-form.ts maps its issues onto the controls, and a name another Dot has is refused at once. A preflight panel shows the control plane, its database and the OpenRouter key. Submit is POST /api/dots, then the Dot's chat opens.

One owner of the bounds: packages/shared exports CONFIG_BOUNDS (cpu, memory, disk, idle timeout default, cost cap) and the schema takes its numbers from it, so a slider can never offer what the API refuses (test in packages/shared). Also: TONE_CLASS and taskRunning moved out of DotHeader into the one place both it and the cards read; the Rail's New Dot goes to /new; DotsPage.tsx and EXAMPLE_CONFIG (its only user) are deleted, the example stays as the fixture of the yaml round-trip test; new Input, Textarea and Label primitives and an ErrorAlert in the new design.

Gaps left on purpose, with no fake data and no stub:
- Last activity on the card: no route returns the newest event (GET /api/dots/:id/events only reads forward from a cursor), so it waits for the host API.
- Preflight shows what GET /api/health reports. The host checks and the golden image (step U1) and a link to the key's fix (step W3, the settings page) are more items of lib/preflight.ts when they exist.
- Spend today comes from the existing usage route; nothing else is shown for cost.
- The setup checklist of the empty Home belongs to W3.
- The model suggestions list holds the one id the repository itself names (z-ai/glm-5.3-flash); any OpenRouter id can be typed.

Tests added: 67 vitest (packages/shared 2, apps/web 65: presets, form model, preflight, card model, Home and create-page components) and 6 Playwright specs (create by form, create from YAML, name validation, Home cards and power, live Home, 390 px). No test removed. Floors raised: vitest linux 879 to 946 and win32 882 to 949, playwright 8 to 14 on both. Two selectors of e2e/shell.spec.ts are tightened, not weakened: the wrong-token alert is found by its text (Next's route announcer is also an alert once an earlier spec has navigated), and the rail's Home link by its exact name (a Dot named home-card matched it).
2026-10-06 03:33:30 +02:00
feder-cr 7386a0dc69 engine tests: a cancelled launch is judged by the process being gone, not by a graceful exit
test_a_launch_that_is_cancelled_leaves_nothing_open_and_the_next_one_works failed
intermittently on a loaded machine with the fake MCP server's last record 'done'
instead of 'exit'. Two timing assumptions were behind it:

- The test cancelled the launch after a fixed 0.3 s sleep, whether or not the fake
  server had started. It now waits until the fake has recorded the browser_open call,
  so the cancel always lands inside _open_browser (Env.until_recorded).
- When the answer to the cancelled browser_open arrives while the SDK closes the
  session, stdio_client's stdout reader raises BrokenResourceError, its task group
  cancels the graceful wait and the process is killed before it can record an exit.
  The product still ends the process (the invariant the behavior promises), so the
  test now asserts that the process is gone (its pid no longer exists) instead of
  that it exited gracefully. Reproduced 1 in 5 under CPU load before the change;
  40 of 40 runs pass under the same load after it.

No test was removed or weakened in what it protects: nothing is left open and the next
launch works. No test was added, so the pytest floor (2275) is unchanged.
2026-10-06 03:05:22 +02:00
feder-cr 8c6fd82240 cli: one command serves the web
invisible-dots server now starts the built web client next to the control plane, so a first run is one command and one terminal instead of two.

- The web build is a Next standalone server (output: standalone); apps/web/scripts/standalone.mjs copies the browser files beside it, which Next leaves out. The unused 'next start' script is deleted: the server is the one entry point.
- apps/cli/src/web.ts runs it as a child with 'node' on INVISIBLE_DOTS_WEB_LISTEN (default 127.0.0.1:3000), pointed at the control plane, with an allowlisted environment (no DATABASE_URL or other secrets; the token is read from api.token by the web server itself). It refuses a port something already answers on, waits for the first answer, and kills the child at process exit.
- apps/cli/src/serve.ts orders the lifecycle: signal handlers are installed before the web client starts, a stop closes the web client first and ends a start in progress, and a web client that cannot start or later exits is logged while the control plane keeps running. 'server --no-web' skips it.
- doctor gets a 'web client' check (built, or missing with the build command) and setup lists the build among the next steps.
- runServer is deleted: the CLI composes startServer and untilStopSignal itself; the api exports serverLogger for the one logger the process uses. parseListen names the setting it parsed in its error. INVISIBLE_DOTS_WEB_LISTEN and INVISIBLE_DOTS_WEB_ALLOWED_HOSTS move to the shared ENV, the one owner of environment names.
- README quick start and architecture sections 9.7, 11 and 11.1 describe it.

Tests: web.test.ts (11, real child processes), serve.test.ts (7), doctor (+1), setup (+1), parseListen (+1 assertion). Removed tests: none. Changed expectations: the doctor report counts 10 checks instead of 9 and lists 'web' before 'openrouter'; the fake host records the new --no-web option.

Floors: vitest linux 728 to 748, win32 731 to 751 (751 passed here on Windows). No floor lowered.
2026-10-06 03:04:34 +02:00
feder-cr 0b0187590d smoke: the README names the frame and close checks
The previous commit added the checks to both suites; the README lists what each
suite pins. Correction to that commit's message: the browser suite was run after
it, on a git archive of that commit, and passed (52 checks, 0 failed, 0 skipped)
with the real window's frame a 1280x669 JPEG; the engine suite passed with 211
checks, 0 failed, 0 skipped.
2026-10-06 03:03:05 +02:00
feder-cr e7b80098e1 host: WhatsApp through Baileys, opt-in
WhatsApp becomes the second channel of the hub, as an explicit opt-in adapter
over Baileys (7.0.0-rc14, pinned exactly), after the owner's decision to take
Telegram first and Baileys next (Q3). It is off unless the server is started
with INVISIBLE_DOTS_WHATSAPP=1: otherwise the type does not exist, GET
.../channels lists only the kinds the server runs (`available`) and linking is a
400 that says how to turn it on. It is not an official way to use WhatsApp and
the account can be banned; README, architecture 9.8 and the CLI say so.

Adapter (packages/channels/src/whatsapp-baileys/)
- port.ts: what the channel needs of a connection. baileys.ts is the only glue
  (types plus one dynamic import() when a connection opens, so a server that never
  links WhatsApp never loads it); FakeWhatsAppConnector implements the port for
  tests. whatsapp.ts holds the decisions, tested against the fake.
- auth-state.ts: Baileys' AuthenticationState over the encrypted secrets of the
  Dot, one secret for the credentials and one per key group (a Record over the
  library's own group list), written in order before Baileys is told, never
  plaintext files, never sent to the guest, deleted with the channel and the Dot.
- Who is who: the phone number when known (address, twin address, or what the
  account learned, locally), else the LID, so one person is one peer. Reply-only:
  nothing to a chat that did not write first, no groups, no own messages, no
  attachments; a stranger gets no reply, no read receipt, no typing; a read
  receipt, a typing indicator and a short pause go before each send.
- Approvals in words: the prompt ends with `Reply "yes ap-xxxxxx" ...`; the hub
  reads exactly that from a paired owner (ChannelCapabilities.approvalByText) with
  the same checks and the same resolveApproval as a button. A bare yes is a message.

Hub and host
- ChannelHub.link/watchLink/kinds, LinkSessions (the code to scan lives in memory
  only: not stored, not logged, not an event), ChannelSink.linkCode, ChannelType
  scanned/scrubNames/pairingMessage, create() gets put/delete on the secrets.
- Routes POST .../whatsapp/link (202) and GET .../whatsapp/qr (SSE of
  ChannelLinkFrame, no-store), the SSE framing of /api/stream moved to one helper.
  SDK linkWhatsApp/whatsappLink/channelsOverview. CLI `channel link whatsapp`
  (risk text, QR in the terminal, --json frames).
- A binding that needs its person is no longer started when the server starts:
  connecting with what is known to be refused only gets it refused again, which
  counts against a WhatsApp account.
- ChannelRecord.bot_username is renamed `account` (it now holds a phone number
  too); ChannelPairingAnswer gains `message` (what to send to pair).
- ApprovalsRepository.endingWith, ChannelsRepository.setStatus can clear the
  account. No migration: the kind column already allowed 'whatsapp'.

License: Baileys depends on libsignal, which is GPL-3.0. Nothing of it is in the
repository, and the command bundle now leaves baileys out (external in
apps/cli/scripts/build.mjs), so no build of ours embeds GPL code; a test keeps
that, and THIRD_PARTY_NOTICES.md and the README say what it means.

Tests (new): whatsapp.test.ts (channel on the fake: identity, policy, sending,
how a connection ends), hub-whatsapp.test.ts (linking, pairing, talking, strangers,
approvals in words, status), whatsapp-auth.test.ts (the state on real encrypted
secrets with the real Baileys helpers), baileys.test.ts (message reading over
Baileys' own protobuf, close codes, a socket that cannot connect), apps/api
whatsapp.test.ts (routes, SSE), SDK and CLI tests, LinkSessions, the reply words,
repository tests, tests/repo/whatsapp-optin.test.ts (exact pin, one glue file, lazy
import, out of the bundle). The real WhatsApp network is not reached by any test.

Removed tests: none.

Existing tests changed, none weakened: bot_username -> account (hub, hub-telegram,
api, cli, sdk); the Telegram test sink and FakeChannelType gained the new members
(linkCode, scrubNames, approvalByText, pairingMessage); the CLI usage-error
assertions name `link` ("use add, link, list, pair or remove"); the CLI pair
fixtures carry `message`. Two hub tests were racy on pg (a status row is written
before its event; the deep link needs the account the channel reported) and now
wait for the condition they assert.

Floors (.github/test-floors.json), raised for the added tests: vitest linux
851 -> 948, win32 854 -> 951; postgres min_passed 405 -> 535 and the pg require
167 -> 213. No floor lowered.
2026-10-06 03:03:02 +02:00
feder-cr 8eb187c527 shared, engine, host: identity frame and close for the UI
The UI's browser view needs to look at an open identity and to close it
without asking the model to. Two engine routes and the host path to them:

- GET /browser-identities/:id/frame answers the JPEG of the identity's window,
  taken with the MCP server's browser_watch. It is a look, not a use: it does
  not move the identity in the LRU order, does not touch last_used_at and never
  reopens a browser the server lost (409 not_open), so browser.identity.launch
  alone still decides whether a browser starts and a page polling every two
  seconds cannot keep one open. It waits at most 5 s for the call in flight on
  the identity and then answers 503 busy; a server that cannot give a frame is
  502 frame_failed.
- POST /browser-identities/:id/close ends the browser through browser_close, as
  the model's close does, and keeps the profile; closing a closed identity is a
  204.

BrowserManager.frame is the one place that does it (the identity lock, the
result decoding); split_result moves from browser_tools.py to browser.py, where
the manager needs it, instead of being imported back. AGENT_ROUTES gains
browserIdentityFrame and browserIdentityClose in packages/shared; protocol.py
names the two actions and the repository parity test checks them against it.
The scheduler (through #runningGuest, so 409 computer_stopped), the guest client,
the API routes and the SDK (getIdentityFrame, closeIdentity) follow. The
scheduler passes the engine's 503 busy through as itself instead of turning it
into a 502 "did not answer", and the API does not log it as a failure.

Tests at each layer: the manager (frame, no LRU use, closed/unknown, busy, a lost
browser is not reopened, frame_failed, a dead process), the engine routes (JPEG
and headers, 409/404/503/502, close idempotent, methods and paths), the guest
client, the scheduler, and the API with the SDK against the fake guest, which now
serves a JPEG for an open identity and can launch one. The smoke checks the real
routes: the fake-MCP suite (frame, 409, 404, host close of an open identity, the
event counts) and the real-browser suite (a JPEG of the real window, 409 after
the model's close). The browser suite was not run here (it downloads the
engine); the fake-MCP suite was.

architecture.md: the two routes in 5.3, the new error codes and the rule that the
engine has no launch route, the two API routes.

Removed tests: none. Floors raised for the added tests: vitest linux 734 -> 738,
win32 737 -> 741; pytest 2368 -> 2382. Go unchanged.
2026-10-06 02:52:48 +02:00
feder-cr 14bf175f22 host: clean up the data older Dots left behind
Review of the engine block (findings L1, C1/E5 and H1). Both are data written before a rule existed that the new code does not clean up; each is confirmed on the code and fixed by a migration, with a test on a database that holds the old data.

C1/E5, stored Dot configs that name what the schema no longer knows (fixed). `dots.config` is jsonb that is never parsed again on its way to the guest (lifecycle.ts pushes toRuntimeConfig(dot.config), which spreads `models` as stored). Since the summary-role commit the engine refuses a config with any role but `summary`, so a Dot stored with `models: {fast: ...}` (the old example of the docs) got its PUT /config refused, and since the permission commit a stored `web.fetch`, `web.search`, `subagents`, `message.send` or `memory.write` made every updateDot fail in parseDotConfig until the person removed it by hand. Migration 0006_removed_config_names removes the roles other than summary and those five permission names from every stored config and touches nothing else. Test (on pglite, and on pg when DATABASE_URL is set): a Dot with three roles and five removed names plus a kept `files.write`, a Dot with only a removed role, a clean Dot and a Dot with no models or permissions at all; each ends as the schema accepts it (parseDotConfig and the runtime config's round trip), the clean ones byte for byte as they were, and the run is applied once.

H1, secrets orphaned by earlier Dot deletions (fixed). The commit of Dots.delete removing a Dot's secrets in the same statement fixed future deletions only (81e519a3 deferred the rest); the rows of Dots deleted before stayed, an encrypted OpenRouter key among them. Migration 0005_orphaned_secrets deletes every secret whose scope is neither `global` nor the id of an existing Dot. Test: a global key, a live Dot's key and two secrets of Dots that do not exist; only the first two remain.

Numbering: 0005 and 0006, not 0004, because lane-host's channel hub takes 0004_channels. The two migrations do not depend on the order among them or on 0004 (they only delete and rewrite data that already exists), and the migrator records each by its own name, so any merge order applies both. The version lists of the migration test in repositories.test.ts and architecture 7 and 9.1 name them. No test removed or weakened; the one list of applied versions grew by the two names.

Floors: vitest linux 722 to 728 and win32 725 to 731 (6 tests), postgres linux 205 to 217 and its pg-named tests 84 to 93 (the measured counts of a run against postgres:18: the 6 tests ran on pglite and pg, plus 3 that the older floor had not been raised for). Suites run: vitest 731 passed on win32, postgres run 217 passed with DATABASE_URL on postgres:18, typecheck, web build, go test.
2026-10-06 02:39:54 +02:00
feder-cr ef64279bf4 shared, engine: tool.called says when a call started a terminal session
Review of the engine block (finding L1, E6/E7). Design 4.5 has the UI show a Dot's tty sessions as "Started a terminal session: python3", but tool.called carried only the first line of the command, so a client could not tell `exec` with `tty` from a plain `exec`. Confirmed on the code (targets.exec_target returned the command and nothing else) and fixed at the origin as a field of the contract, not as a prefix inside `target`: `target` is what the call acted on, a prefix would be a second fact parsed out of a string, and a command can start with any text.

`tool.called` gets an optional `tty: true` (events.ts type and schema, the same shape as `interrupted`; the schema strips unknown keys, so without it the host would have dropped the field silently). The engine: ToolEntry.starts_terminal in the permission table, the one place that states what of a call may be shown (exec: `tty` is true; no other tool starts one), permissions.tool_starts_terminal, ToolIntent.tty stored with the intent at before_execute_tool (dots_tool_intents.tty), written by the outbox into the event, and left out when false. SCHEMA_VERSION 6 to 7 (the engine opens only a file of its own layout; no engine of an older layout has run on a real Dot). Architecture 5.4 lists the field.

Tests that fail before the fix: test_permissions (only exec with tty true starts a terminal; a string, false, another tool or odd arguments do not), test_store (the intent keeps tty, default false, first start kept), test_transcript_outbox (the event carries tty and the target, and leaves the key out otherwise), test_turns (an exec with tty reports tty true and its command, the next one without it reports none), events.test.ts (the host schema keeps tty true and refuses false and any other value). Smoke: the tty exec of the run is reported with tty true and its command, and no other call of the stream says tty.

Floors: pytest linux 2284 to 2289 (measured), vitest linux 721 to 722 and win32 724 to 725. Suites run: engine 2289 passed, vitest (typecheck clean).
2026-10-06 02:37:23 +02:00
feder-cr 3c5e6c3e5b ci: correct the vitest floors W2 raised
W2 raised the vitest floors to 884 (win32) and 881 (linux) on the claim
of 146 added tests; the real gain over the 738 (win32) and 735 (linux)
of W1 is 144, so the win32 run of 882 passed was refused by test-guard.
The floors are now 882 (win32) and 879 (linux; the new tests are
platform independent, so linux keeps its 3 fewer passed tests, the
Windows PowerShell ones it skips). Nothing is lowered below what the
suite really reaches; the engine and playwright floors are unchanged.
2026-10-06 02:27:35 +02:00
feder-cr 75e4c2dfb9 engine: a request without a cost fails the turn closed, also the last one
Review of the engine block (findings L1, spend part). Of the three spend findings, two are fixed and one is rejected with the bound written down.

E3, a missing cost did not always fail closed (fixed). The "no cost" note lived on the TurnSpend instance and was read only by check(), which runs before a request. So when the response with no cost was the last one, the answer was delivered and the task completed with its spend undercounted, and a kill -9 after an unpriced response forgot the note, so the resumed turn carried on as if the response had been metered. Now: (1) the note is a column of the same dots_spend row as the money (`unpriced`), written in the same transaction as the ledger entry (store.note_unpriced / has_unpriced), and it goes where the row goes (the chat's answer takes it); (2) TurnSpend.check reads it from the ledger, no instance state is left; (3) TurnSpend.ensure_priced runs in the turn's commit callback before a final_response is written, because that write is what delivers a chat answer and completes a task (a check after the runner returned would come too late: message.assistant and task.completed are already in the outbox). A turn whose last request had no cost therefore ends as `stopped: OpenRouter reported no cost for a request, ...` and the answer is not delivered, as Q12 of the plan decided. SCHEMA_VERSION 5 to 6 (the engine opens only a file of its own layout; no engine of an older layout has run on a real Dot). Architecture 5.4/8.2 and the module doc say so.

E3, failed streamed requests are counted as free (rejected as a code change, documented). OpenRouter reports the cost in the last chunk of a stream, after the finish_reason chunk. A stream that stalled or was cut after tokens were streamed has no cost figure anywhere in what the provider received, and chat_stream_with_retry hands the meter only the last response of up to 4 attempts, so there is nothing to keep on the error response and no cost to count for the attempts the retries went past. A usage chunk without a finish reason would be a provider that breaks the stream protocol. Counting it would need a billing query per failed attempt, which is a new feature, not a fix. Architecture 8.2 now states the bound: the cap can be exceeded by partial streams, each at most the cost of one full response, 4 attempts a request.

Tests that fail before the fix: test_store (note_unpriced / has_unpriced: kept per session, not cleared by later spend, taken by the chat's answer, not by a task event, there after the file is opened again), test_spend (a restarted turn's check fails on the note; ensure_priced fails on it and not on a priced answer that crossed the cap), test_turns (a final answer with no cost fails a task turn without writing task.completed, and a chat turn without writing message.assistant, and leaves the note in the ledger).

One stand-in corrected, no test weakened: test_main's FakeOpenRouter answered with a usage that had no `cost`, which only passed because the last unpriced answer used to be delivered. It now reports a cost like OpenRouter does (0.0125) and the test asserts that spent_usd, where it asserted 0.0.

Floors: pytest linux 2275 to 2284 (the measured count, 9 tests added). Suites run: engine 2283 passed 1 failed on the full run, the failure being test_browser_manager's timing test of a cancelled launch (asserts the fake MCP's last record after a fixed 0.3 s sleep; it passes alone, 40 passed, and in the run before; not touched here).
2026-10-06 02:26:06 +02:00
feder-cr 28ffb0a403 smoke: the real invisible-playwright-mcp
The Dot's browser had only been run against a stand-in. A new suite of the smoke
(run.sh --suite browser, the browser-smoke job of CI) runs the real
invisible-playwright-mcp and its Firefox, as the golden image builds them and as
the Dot runs them, so the relay as a stdio transport, the profile lock after a
kill -9 and the screenshot path are proven against the real server.

What it does, in one ubuntu:24.04 container: installs the apt packages of
pins.json, builds the browser with builder/build-browser-env.sh on the hashed
mcp-requirements.lock, starts the runtime disk's own dot-desktop script (Xvfb and
XFCE on :0 as dot), runs dot-agentd and the engine under their two users with no
stand-in for the browser, and has the stand-in model drive pages served from the
container. Checks (50): the server and Firefox run as dot with the profile, the
display and none of the engine's variables nor the key, nothing of the browser as
dotengine; navigate, read text and snapshot reach the model; a screenshot reaches
the next request as a real PNG of the page and the engine's database keeps only
the placeholder; a 2 MB screenshot of random pixels crosses the relay whole;
GET /v1/screenshot shows the page in the desktop; the seed file is identical
after close and relaunch and after kill -9 (which leaves a stale profile lock the
next launch lives with); what a page stored in localStorage survives the model's
close and SIGTERM; the key is in no file, process environment or log; the event
stream is gap-free and holds each identity event.

One owner per fact: the build of the browser moves out of provision.sh into
builder/build-browser-env.sh (the way build-engine-env.sh is the engine's), carried
on the builder seed (BROWSER_BUILD in pins.env, an input of the golden digest) and
run by both provision.sh and the smoke. The same script now fetches the GeoIP
database after the engine (owner decision: prefetch in the golden image), so a
Dot's first launch downloads nothing; provision.sh records its release as the
geoip-database component of the manifest. The smoke's shared part (the guest laid
out as install.sh does, the daemons, the key and config push, the event helpers) is
now guest/image-builder/test/smoke/lib.sh, sourced by smoke.sh and
browser/smoke.sh; prepare-engine.sh makes the golden image's users and, for the
browser suite, installs the apt packages and builds the browser. dot-agentd now
starts with its unit's DISPLAY and PATH, and the engine with its unit's DISPLAY.

A new image-builder test pins the owner's rule that the only browser of a Dot is
invisible-playwright-mcp: no other browser, headless browser or browser library
among the apt packages, the MCP lock or the build scripts (with a test that the
check refuses chromium, firefox, selenium, playwright and the like and lets
invisible-playwright through).

The engine smoke's key sweep of process environments read nothing of the other
users' processes (a container's root has no CAP_SYS_PTRACE, so the grep skipped
them without a word); lib.sh's environ_holds now sweeps once as each user, in both
suites.

Deferred to B8: the check that the identity frame route answers a JPEG, because
the route does not exist yet.

Tests: guest-files.test.ts rewrote the two assertions about provision.sh
installing the MCP environment onto build-browser-env.sh (same behavior, the
script owns it now) and added the GeoIP, no-other-browser and script-list ones;
seed.test.ts and golden.test.ts cover the new seed file and digest input;
engine-smoke.test.ts covers lib.sh, the browser suite, the new job and the gate.
No test was removed. Floors: vitest linux 723 -> 734, win32 726 -> 737; pytest,
postgres and go unchanged.

Suites: vitest 737 passed (win32); typecheck ok; pytest 2368 passed; go ok;
engine smoke 206 passed 0 failed 0 skipped; browser smoke 50 passed 0 failed 0
skipped.
2026-10-06 02:21:54 +02:00
feder-cr 808df180f7 web: foundation: stack, tokens, shell, test harnesses
Step W2 of the UI plan. The presentation layer of apps/web moves to
Tailwind CSS 4 with shadcn/ui primitives and lucide, over the data layer that
stays (SDK client, SSE hub, useResource); every signed-in page now sits in one
frame, and the browser tests have a harness.

Stack and tokens
- Tailwind 4 through PostCSS (next build stays on webpack), tw-animate-css,
  radix-ui, class-variance-authority, clsx, tailwind-merge, lucide-react,
  sonner; Playwright as a dev dependency.
- src/app/tokens.css is the one owner of color, radius and typeface, a light
  and a dark set; globals.css maps it into Tailwind (`@theme inline`) and
  clears Tailwind's default palette, so a utility can only be a token.
  test/tokens.test.ts reads the file and checks every text and surface pair
  for WCAG AA and every drawn element (rings, focus) for 3:1.
- Theme: light, dark or the system's, a toggle in the rail, the choice in
  localStorage (a per-viewer convenience), applied by an inline script in the
  head before the first paint. One function (lib/theme.ts applyTheme) is both
  the script and what the toggle runs.
- shadcn/ui primitives copied into components/ui (button, badge, alert,
  dropdown-menu, sheet, separator, skeleton, sonner) and lib/utils.ts (cn),
  each with its Derived-from header; THIRD_PARTY_NOTICES.md has the shadcn
  entry. New tests/repo/notices.test.ts keeps every Derived-from header tied
  to a notices section and to a commit, a license and what changed.

The shell
- (app)/layout.tsx: one live stream and one shared state for every signed-in
  page, and the frame with the Rail (S1): brand, New Dot, Home, Approvals with
  its count, every Dot with its avatar ring, API state and version, stream
  state, theme, sign out. Under 768 px the rail is a sheet behind a menu
  button.
- Dot header (S4) and tab bar in components/dot: avatar, name, goal on one
  line, the state pill, today's spend (GET /usage), the computer pill with its
  power menu (confirms before stopping or rebooting a running task, toasts the
  result), the ERROR banner (the Dot's reason and the computer's last_error)
  and the "interrupted, restarted" banner.
- lib/attention.ts owns what needs the person (waiting approvals, Dots in
  ERROR) and how it is drawn (ring state); the rail badges, the avatar ring,
  the tab title prefix "(3)" and the favicon dot all read it.
- lib/avatar.ts: the gradient and first letter from a hash of the Dot's id.
- The event hooks gained a scope: below DotEventScope they hear only that
  Dot's events, so one stream serves the rail and the Dot's tabs
  (TimelineTab's own filter by Dot is deleted: the scope owns it).
- The old header, MainNav and ApiHealth are gone; globals.css lost the rules
  of the header, navigation, tabs, stream and skip link. What the old screens
  still use is in legacy.css, scoped to `.legacy` and layered below
  Tailwind's components and utilities, so the existing tab bodies render as
  before and a new component is never overridden by an old rule. Each later
  step deletes what its screen used; W11 deletes the file.

Tests and CI
- Unit and component tests (vitest, node and jsdom): attention model, avatar,
  live-state reducer, state pill, theme (including the inline script run on
  its own), tokens, format additions, the shell (rail, live counts, ring,
  unread mark, title and favicon, narrow-screen sheet) and the Dot header
  (state, spend, errors, restart banner, tabs, power menu), over a
  FakeControlPlane in test/support that serves the routes and a live SSE
  stream.
- Playwright harness (apps/web/e2e/harness.ts): the real control plane in
  process (startServer, PGlite, FakeDriver, so a test can drive a Dot's
  computer) and `next start` of the built client against it. Eight specs:
  login, sign out, rail and Dot header, live approval, power menu, theme
  and its no-flash guarantee, 390 px layout, keyboard.
- The web job of tests.yml now builds, installs Chromium, runs the browser
  tests and counts them with test-guard against a new playwright floor (the
  guard reads Playwright's JUnit report; test-guard.test.ts covers its shape).
  The gate still counts 9 jobs. e2e has its own tsconfig, run by `npm run
  typecheck`. Not added to the pre-push hook (it needs a build and a
  browser).
- docs/architecture.md section 9.7 describes the frame, the tokens and the
  harness.

Left out on purpose, not stubbed (the APIs do not exist in this lane yet)
- Inbox: the rail's entry is Approvals (/approvals) with its count; step W6
  makes it the Inbox with ERROR Dots and failed tasks. The attention model has
  no failedTasks24h (W6) and no channelProblem (W12) field until their data
  exists.
- New Dot links to the form where Dots are created today (/#create-dot); W4
  gives it its own page.
- The tab bar lists the seven tabs that exist; each later step swaps an entry
  for its replacement (activity, memory, channels) in the commit that adds the
  page. The computer side panel button (S4) comes with the panel (W5, W8).
- shadcn primitives other than the eight above are added by the step that
  first uses them (an unused file would be dead code).
- The agent state pill is live-only: the stored Dot status covers what
  happened before the page opened, because no route returns the agent state.

Tests removed or rewritten
- apps/web/test/shell.test.tsx: "shows the navigation, the API check and the
  sign-out button" and "says so, and stays signed in, when the server cannot
  end the session" tested AppLayout's header, which the rail replaces. Their
  assertions live on in the rail tests ("shows the API's state and version",
  "says so, and stays signed in ..."); the login-page test is unchanged.
  Nothing of kept behavior is weakened.

Floors (.github/test-floors.json)
- vitest: linux 735 to 881, win32 738 to 884 (146 more tests, net of the 2
  rewritten above).
- playwright: new, 8 on linux and win32.
- Nothing lowered.
2026-10-06 02:16:48 +02:00
feder-cr e90c465058 host: approvals over Telegram
What: when a Dot asks for an approval, every paired owner's Telegram chat gets a
prompt (tool, permission, reason, arguments cut to 300 characters) with Approve
and Reject buttons. Pressing one resolves the approval through
Scheduler.resolveApproval, the one way an approval is answered. Whoever answers
(chat, web, API), approval.resolved edits the prompts to the outcome and takes
the buttons away.

- callback_data is versioned (ap1:y:<id>, ap1:n:<id>), checked to fit Telegram's
  64 bytes when the prompt is made and again when parsed, and parsed strictly.
- The hub checks every press: a paired owner, in their private chat, while the
  binding's approvals setting is on, for an approval of the binding's own Dot
  (the id in a button proves nothing). A second press or one after the web
  answered gets the scheduler's 409 as "It was answered already". Every press is
  answered with a short notice (answerCallbackQuery, best effort).
- Migration 0005 adds channel_prompts (binding, approval, chat, ref; cascades
  with the binding and the approval) so a prompt is edited where it was sent and
  never sent twice to a chat. Prompts are brought up to date at channel start,
  when a person pairs and when approvals is switched on: settled approvals
  (answered while the server was down, or expired with their task) are edited,
  pending ones without a prompt are sent.
- Channel gains sendApproval/editApproval and ChannelSink.approval; the Telegram
  adapter asks for callback_query updates and confirms a press only after the
  hub dealt with it. FakeBotApi serves inline keyboards, editMessageText,
  answerCallbackQuery and callback_query updates; FakeChannel records prompts,
  edits and presses.
- The retry loop for sends is one function now (#retrying) used by text,
  prompts and edits.
- hub.test.ts's linked() helper moved to test/world.ts as linkedFake() so
  hub-approvals.test.ts shares it; no test of kept behavior changed.

Tests: callback and text units, adapter tests (buttons, edits that have nothing
to do, oversized id, press hand-over, forged data, hub failure re-offers the
press), hub tests with the real Scheduler and a fake guest (prompts, approve,
reject, answered elsewhere, second press, wrong clicker, other Dot's id,
approvals off and on, pairing while waiting, restart, expired, send retry and
drop), Telegram end to end through FakeBotApi, repository and cascade tests.

Removed tests: none.

Floors (.github/test-floors.json), raised for 34 added tests: vitest linux
817 -> 851, win32 820 -> 854; postgres min_passed 352 -> 405 and the pg require
148 -> 167. No floor lowered.

Docs: architecture 9.8 (approvals, buttons), tables, README.
2026-10-06 02:10:48 +02:00
feder-cr c9fd83bcaa host: Telegram
A person can now link a Dot to a Telegram bot and talk to it from the chat.
The Telegram adapter is transport only; every policy (who may talk, pairing,
rate limits, idempotency, retries, backoff, status) stays in the hub of the
previous step.

- packages/channels/src/telegram: the adapter on grammY 1.46.0 (pinned). Long
  polling, because the control plane sits behind NAT; only message updates;
  an update is confirmed to Telegram (the offset of the next poll) only after
  the hub dealt with it, and a message the hub could not record makes the
  adapter fail so Telegram offers it again (the hub knows what it already gave
  the Dot by the id "<bot id>:<update id>", which also keeps the ids of two
  bots apart when a Dot's bot is replaced). /start <code> in a private chat is
  a pairing attempt, a bare /start and strangers get no answer. A message
  without text is marked as an attachment. Plain text out, split by the hub at
  4000. A webhook the bot had is deleted on connect. Errors reach the hub in
  words: 401 needs_relink, 409 says another process polls the bot, 403/400/404
  on send are final, 429 carries retry_after. No message made here holds a
  request URL, so the token (which is in every URL) is in none. Retries have
  one owner, the hub: @grammyjs/auto-retry from the design is not used, it
  would retry a second time under the hub's backoff and hide network failures
  that must become the error status.
- FakeBotApi (exported from @invisible-dots/channels/testing): a real HTTP
  server with the Bot API behaviour the adapter depends on (long poll, offset
  confirmation, 409 for a second poller and for a set webhook, 401 for a
  revoked token, 429 retry_after, 403 for a blocked bot, dropped connections,
  redelivery); the real grammY client points at it.
- hub: a ChannelType may check credentials before anything is stored
  (check -> account): wrong token 400 invalid_credentials, unreachable 502
  channel_unreachable (neither carries the token), a bot another Dot uses 409
  account_in_use. setCredentials gives a linked channel a new token and starts
  it again with its people (the way back from needs_relink). A message that
  carries an attachment is answered "not supported yet" to a paired person and
  dropped. database: a binding can be created with its checked account.
- API: GET /api/dots/:id/channels, PUT .../channels/telegram {token} (201 when
  linked, 200 for a new token), PATCH .../channels/:kind {settings?, enabled?}
  (also pauses and resumes), DELETE .../channels/:kind, POST
  .../:kind/pairing, DELETE .../:kind/peers/:peer. The token is never in an
  answer. startServer runs the Telegram type by default; the web proxy already
  forwards every method. SDK: channels, putTelegramChannel, patchChannel,
  removeChannel, pairChannel, removeChannelPeer.
- CLI: channel add telegram --dot X (token from a terminal line or stdin,
  never argv; shares the secret reading with `secret openrouter`), channel
  list [--dot], channel pair telegram --dot X (prints the deep link),
  channel remove telegram --dot X.
- docs/architecture.md (routes, section 9.8 Telegram), README (how to link a
  bot).

Tests: 12 adapter tests against FakeBotApi; 15 hub end-to-end tests with the
real adapter, the real Scheduler, FakeDriver and FakeGuest on PGlite and pg
(pairing through the deep link, answer to the right chat, photo notice, a
stranger's 100 messages write nothing, a revoked person, redelivery and restart
in the middle, a database failure keeps the update, 409 and 429, a blocked bot,
revoked token and new token, replacing the bot, refused tokens, the token in no
log/status/event/row, unlink and Dot deletion); 7 API route tests; 1 SDK, 5
CLI and 1 database test. The hub test world moved to test/world.ts so both
hub suites share it. No test was removed or weakened. One existing hub test
("reports a channel that cannot be made") waited on the status row but asserted
its event, a race seen on PostgreSQL; it now waits on the event.

Floors (.github/test-floors.json), raised for 41 added tests: vitest linux 776
-> 817, win32 779 -> 820; postgres 294 -> 352, its pg requirement 125 -> 148
(measured on PostgreSQL 18.4). Nothing lowered.

Deferred to H5, with the feature that uses it: callback_query updates,
editMessageText and answerCallbackQuery in the adapter and in FakeBotApi.
2026-10-06 01:50:27 +02:00
feder-cr 8d6bdccc9b web: the login page no longer reloads itself; sign out
Step W1 of the next-phase design. Before this, /login rendered the header
with ApiHealth, which called the API at mount; the proxy answered 401 with
x-invisible-dots-login: required (no session on the login page), and
fetchOrLogin then assigned /login?next=%2Flogin without looking at where it
already was: the page reloaded itself forever.

Fixed at the origin, in two places that each own one fact:

- Layout split: the header (navigation, ApiHealth, the new Sign out button)
  moves into the (app) route group, with every page that needs a session;
  /login sits outside it with its own bare layout and calls nothing. The
  root layout keeps only html, body and the skip link.
- fetchOrLogin (lib/api.ts) is now a function of the page it runs from and
  never redirects when that page is /login (onLoginPage). The login page's
  post-sign-in return (pathAfterLogin, lib/session.ts) uses the browser's own
  URL parser, so //host and backslash forms are refused too, and never
  returns to /login itself.
- Sign out: SignOutButton in the header sends DELETE /session (which already
  existed) and loads /login afresh; a refusal is shown and the person stays
  signed in.

Test harness: the root and web vitest configs also pick up *.test.tsx, with
jsx compiled by the test run itself (the app's tsconfig keeps jsx for Next);
a component test chooses jsdom with its own "@vitest-environment jsdom" line,
so no other test pays for a DOM. Dev dependencies of apps/web: jsdom,
@testing-library/react, @testing-library/dom, @testing-library/user-event
(all MIT). Nothing is copied from third-party code in this step.

Tests added (14): apps/web/test/api.test.ts (onLoginPage, fetchOrLogin:
redirects with the page remembered, never from /login, leaves other answers
alone, no page), session.test.ts (signOut, pathAfterLogin), shell.test.tsx
(jsdom: the login page has no header and makes no request, the shell shows
navigation, API check and Sign out, a refused sign-out is reported).
Removed tests: none. The first Playwright spec waits for W2, which adds the
harness; the jsdom test above is the regression test until then.

Floors in .github/test-floors.json: vitest linux 721 to 735, win32 724 to
738 (14 tests added, none lowered).

docs/architecture.md 9.7 updated for the sign-out button and the login page
rule. Suites: npm run typecheck, npx vitest run (738 passed), next build.
2026-10-06 01:35:20 +02:00
feder-cr 0ecc907a8d smoke: the browser seams with a fake MCP server
The engine smoke now runs the browser against a stand-in for invisible-playwright-mcp, so the seams
between the engine, dot-agentd's relay and the MCP process are exercised in CI without Firefox.

smoke.sh installs the engine's own test fake (tests/fakes/fake_mcp_server.py, with the tool list captured
from the pinned server) as INVISIBLE_DOTS_MCP_COMMAND, on the engine's python, and runs as dot through the
relay. No second fake is written: the one owner of what a stand-in answers stays the engine's test fake, so
there is no smoke/fake_mcp.py. New checks: identity create 201 and created event, the directories are dot's;
the server runs as dot and never as dotengine, with the profile, display, session id and home in its
environment and none of the engine's variables nor the key; browser tools reach the server as the real
tools with browser main and show in tool.called with permission and target; a screenshot reaches the next
model request as an image part; the fourth launch with max_open 3 closes the least recently used identity
through browser_close; an action on a closed identity fails with the launch message and starts nothing; a
browser lost under a live server is reopened once; browser.identity.delete asks, parks, survives kill -9
(which ends every server), and every identity is available after the restart; SIGTERM asks an open browser
to close; a host DELETE of an open identity closes it first; the proxy password is in no approval, event,
engine log or dot-agentd log; /health counts identities and open ones. check_offered takes managed_by_dot,
its first list now holds the whole permission table, and three cases cover managed_by_dot false and the
create and delete tools. agent.started is counted eight times (two more restarts).

fake_openrouter.py gains RUN-TOOL <name> <json>. engine-smoke.test.ts gains two tests: the smoke uses the
engine's one fake and fixture, and its every-permission list equals the keys of the permission table, so a
tool added without the smoke fails in vitest too. The README of the smoke says what the browser checks pin.

Suites: smoke 206 passed 0 failed 0 skipped; vitest 726 passed (win32); pytest 2368 passed; go ok; typecheck ok.
Removed tests: none. Floors: vitest linux 721 -> 723, win32 724 -> 726 (two tests added); no floor lowered.
2026-10-06 01:26:41 +02:00
feder-cr 0f692e51ad engine: the LocalComputer fake ends a timed-out command through kill_process_group
The fake signalled the group with a bare os.killpg, which raises
ProcessLookupError when the process has already exited and been reaped
between the timeout and the kill. Under the load of the full suite that
race failed test_find_files_enforces_time_budget with 'No such process'.
kill_process_group is the one owner of ending a command's group and
already tolerates an exited process.
2026-10-06 01:20:16 +02:00
feder-cr bb08879e3e host: the channel hub
A person can now talk to a Dot from a chat, through a hub that runs in the
control plane next to the Scheduler. The Dot never sees a channel: the guest
still receives {text} and no guest route or event type changed. This is the
channel-independent core; the Telegram adapter, the API routes and approvals
over a channel come in the following steps.

- packages/channels (new): the adapter interface (Channel.run, sendText,
  typing; ChannelType makes the adapter and names the secrets a binding
  owns), ChannelHub, pairing codes, the per-binding runner, text splitting,
  backoff, a token bucket, and FakeChannel for tests. The hub owns every
  policy: only a person paired with a one-time code (8 symbols, 10 minutes,
  used once, stored as a SHA-256 hash) may talk, by the channel's stable id;
  a stranger, a group chat or an empty message is dropped before any write;
  a paired person is rate limited and length limited. Inbound messages go to
  Scheduler.sendMessage with an origin and are recorded by the channel's own
  id, so a redelivery (also after a restart) is dropped. The answer is routed
  back by the user.message event its in_reply_to names (not mirrored when the
  message came from the web); answers that answer nothing and task results
  (switchable) go to the owners; typing shows while the Dot thinks. One
  event stream per binding with a cursor: a restart resumes without losing or
  repeating a reply (at least once, documented). Failing adapters restart with
  exponential backoff, a revoked login stops them (needs_relink), sends are
  retried with the channel's retry_after, a refused send is dropped. Every
  change of status is one channel.status event, with the reason cut to 300
  characters and the binding's credentials replaced. A new binding starts
  after the Dot's latest event. Credentials are secrets scoped to the Dot,
  never returned, deleted with the binding.
- database: migration 0004_channels.sql (channel_bindings, channel_peers,
  channel_pairings, channel_inbound, all cascading from dots; an index on
  user.message by message id) and ChannelsRepository;
  EventsRepository.userMessage. events: EventLog.userMessage.
- api: startServer builds the hub after the Scheduler, starts it after
  scheduler.start() and closes it before the Scheduler (channelTypes option,
  none yet by default); RunningServer.channels.
- docs/architecture.md: layout, tables, section 9.8.
- CI: the postgres job also runs packages/channels.

Deferred to H5, with the feature that uses it: the channel_prompts table and
the approvals handling (settings.approvals is stored and returned, nothing
reads it yet). Deferred to H4: the API routes and SDK for channels (a route
for pausing a channel too: Hub.setEnabled exists and is tested), the Telegram
adapter, and the CLI.

Tests: 6 repository tests (both adapters), 1 EventLog test (both adapters),
11 unit tests (text splitting, backoff, pairing codes, rate limiter, the
guest protocol naming no channel), 27 hub tests against the real Scheduler
with FakeDriver and FakeGuest on PGlite and pg, 1 startServer test. No test
was removed or weakened; the migration list in repositories.test.ts gained
0004_channels and the in-memory event store of event-log.test.ts gained
userMessage.

Floors (.github/test-floors.json), raised for 46 added tests: vitest linux
730 -> 776, win32 733 -> 779; postgres 214 -> 294, its pg requirement
88 -> 125 (measured on PostgreSQL 16). Nothing lowered.
2026-10-06 01:09:21 +02:00
feder-cr 79c0cb46f9 engine: browser tools, routes and health
The Dot can now use its browsers. The BrowserManager (B4) becomes the fourth ToolDeps dependency, the browser tools are rows of the permission table, the identity routes answer for real, and main.py stops building an empty MCPProvider (design 2.1, 2.3).

Tools (browser_tools.py, rows of TOOL_PERMISSIONS): browser_identity_list|create|delete|launch|close, browser_navigate, browser_snapshot, browser_read_text, browser_screenshot, browser_click, browser_click_at, browser_type, browser_press_key, browser_select_option, browser_scroll, browser_back|forward|reload, and computer_screenshot (dot-agentd GET /v1/screenshot, Computer.screenshot). A page tool is a PAGE_TOOLS row naming the one invisible-playwright-mcp tool it calls and how its arguments map; the manager adds browser "main", so the model cannot pick the support browser or add an argument. No implicit launch: a page tool on a closed identity says "identity <id> is not open; call browser_identity_launch first", so browser.identity.launch alone decides whether a browser starts. Create and delete exist only while browser.identities.managed_by_dot (ToolEntry.needs_managed_identities, offered_tools(managed_identities=), projection passes it).

Images (images.py, AgentRunSpec.request_attachments in runner.py): a screenshot is shown to the model and never stored. The tool message holds its text and "[screenshot, 1280x720, not stored]"; the bytes go to a per-turn TurnImages (newest 3) that adds one user message after the last tool message of each request. Nothing reaches SQLite, the outbox or an event. click_at's PNG is dropped to a placeholder that says to call browser_screenshot.

Secrets: approval.requested carries browser_identity_create's proxy through redact_proxy (the parked call keeps the real one so the approved call runs as asked); each ToolEntry says which arguments an approval may show (tool_arguments). Targets name the identity and what was acted on (URL, selector, key name, direction, coordinates), never typed text or a character sent as a key.

Routes and lifecycle: GET/POST /browser-identities and GET/DELETE /browser-identities/:id are real (201, 204, 400 invalid, 404, 409 limit, 502 launch_failed|crashed); health reports identities and open from the manager; PUT /config applies max_open and max_identities to the manager (a lower max_open closes the excess, least recently used first); the engine closes every open browser on prepare-sleep and SIGTERM (4 s in all, after the turns ended), and main.py wires the manager. protocol.ts identityPaths loses metadata (no metadata.json). architecture.md 4.1, 4.2, 5.3, 6, 8.3 (the two tables merged), 8.7, 8.8 and UPSTREAM.md follow; tests/repo/architecture-engine.test.ts follows the merged table.

The owner's rule that the only browser of a Dot is invisible-playwright-mcp is pinned by tests: the permission-table test refuses any browser tool not served by that server (identity tools are the manager's, page tools name a tool the pinned 0.70.2 list has, no other row browses by permission, name or class), and tests/test_dependencies.py refuses any other browser, automation, scraping or web-search library, declared or imported. The image-builder test for provision.sh is left to B7, which changes that script.

Tests: added test_browser_tools.py (the tools against the real manager and the fake MCP server, and through real turns), the browsing tests of test_permissions.py, TestBrowserIdentities and the sleep test of test_server.py, TestStoppingWithBrowsersOpen in test_engine.py, a projection test and the dependency test; tests/fakes gained browser_manager.py, Computer.screenshot on the local double, and scripted_provider.call takes its first two parameters positionally only so a tool argument may be called name.

Removed tests, with the reason (the stubs they pinned are gone): test_server.py test_lists_no_browser_identity_and_makes_none_yet is replaced by test_the_identity_routes_name_what_they_cannot_find_and_the_methods_they_refuse; its assertions that POST /browser-identities and DELETE answer 501 not_implemented no longer hold because the routes are real, and are covered by TestBrowserIdentities. test_permissions.py test_the_result_is_sorted now grants every permission of the table instead of five (same check, wider). architecture-engine.test.ts: the split at "Planned for the browser phase" and "documented > used" become "documented == used" because the planned table was merged into the main one.

test_browser_manager.py: the cancelled-launch test waited a fixed 0.3 s for the fake process to be up and failed on a loaded machine; it now waits for the first browser_open call.

Floor: pytest linux 2275 -> 2368 (no floor lowered). Suites: engine 2368 passed, vitest 724 passed, go test ./... ok, typecheck ok.
2026-10-06 00:59:38 +02:00
feder-cr 1149f8a15e shared, host: where a message came from, channel events
A user message can now say which channel chat it came from, and the host
event log knows the two channel events, so the channel hub (H3) and the
UI have one contract to build on.

- shared: MessageOrigin {channel, binding_id, chat_id, external_id},
  its strict schema and parseMessageOrigin; CHANNEL_KINDS and
  CHANNEL_STATUSES; host events channel.status and channel.peer.paired
  (with their data); ConversationMessage.origin; the channel wire types
  of the API (ChannelRecord, settings, peers, pairing answer, put and
  patch requests). No inbound or outbound event changes: the guest still
  receives {text} and never sees a channel.
- events: UserMessageData.origin, stored in the data of the user.message
  event. The event log is the one place that says where a message came
  from; a reply is routed back by it, there is no parallel table.
- scheduler: sendMessage(idOrName, text, origin?) validates the origin
  (400 invalid_request, nothing logged) and stores it; conversation()
  returns it for user messages and leaves out one it cannot read. The
  HTTP route still takes {text} only, so a client cannot claim a channel.
- web: the timeline names the two channel events instead of printing raw
  JSON.
- docs/architecture.md: section 5.4 and the API list.

Deferred to H4, with the routes that serve them: the SDK methods for the
channel routes (a method with no route would only answer 404). The
WhatsApp link and QR types wait for H6, which decides their shape.

No test was removed or weakened.

Floors (.github/test-floors.json, raised for 9 added tests, 4 of them
also run on pg): vitest linux 721 -> 730, win32 724 -> 733; postgres
205 -> 214, its pg requirement 84 -> 88.
2026-10-06 00:41:18 +02:00
feder-cr 020e9a0309 engine: the BrowserManager
Step B4 of the browser phase (design 2.1, 2.3, 5.3). nanobot/dots/browser.py owns the browser identities of a Dot and the invisible-playwright-mcp process of each open one. It is not wired into the engine yet (tools, routes, health and main.py are B5), so nothing calls it but its tests.

What it does. An identity is a row of dots_browser_identities plus browsers/<id>/{profile,mcp}, made and removed as dot through the Computer. An open identity is one MCP process started as dot through dot-agentd relay (cwd and the six variables of section 6 as --env, the engine's own environment never reaches it), served by nanobot's MCP client on a private registry per identity, with browser:"main" added to every call. browser_open is asked with only that role and again with a 2 s backoff doubling to 30 s for up to 15 minutes while the server answers download progress; an error answer, a command that cannot start or a deadline is launch_failed, with the proxy redacted in whatever the server echoes. At max_open a launch closes the least recently used identity; set_limits closes only the excess open ones and a lower max_identities deletes nothing; max_identities is enforced on create for every caller. Launch, close and delete share one lock, calls on one identity another. A browser the server reports gone while its process lives is opened again once and the call repeated once. Close calls browser_close (30 s) before ending the process so Firefox flushes its profile. close_all is for suspend and shutdown. Every browser.identity.* event commits with the row change it describes (created, launched, closed, deleted); the view callers get carries the proxy only redacted.

Decision of the design (Q7): an action on an identity that is not open does not launch it. It fails with not_open and the text "identity <id> is not open; call browser_identity_launch first", so browser.identity.launch alone decides whether a browser starts. A process that ended is a crash: the identity is closed, closed is emitted once, the call fails with crashed, and the next action says not_open. This replaces the old manager's implicit relaunch, which the design's section 6 names as a bypass.

mcp.py (origin): MCPProvider takes on_terminated. By default a server whose session ended is reconnected and the failed call repeated, which for a browser silently starts a new process with no browser open and hides the crash. With on_terminated the provider reports the server's name and reconnects nothing. Existing callers are unchanged.

protocol.py: BROWSERS_DIR, GUEST_DISPLAY and BROWSER_ENV mirror GUEST_PATHS.browsers, GUEST_DISPLAY and the six ENV names of packages/shared; tests/repo/vendored-nanobot.test.ts keeps them equal (the existing parity test gains the three checks).

tests/fakes/fake_mcp_server.py is a stand-in for the real server over real MCP stdio: it serves tests/fixtures/mcp-tools-0.70.2.json, the SDK validates every call against its schemas, it refuses an argument the real tool does not have, it records its start (pid, argv, env, cwd), calls and exit, and its behavior is steered by a control file (download progress, failed open, browser gone once or always, refused close, crash and slow navigation). tests/dots/test_browser_manager.py (40 tests) ports the behaviors of the old manager.test.ts (records, delete order, launch environment and retry, deadline, failed open, LRU, reopen once, setLimits, close all) and adds the not_open rule, the crash and idle death, close order, the proxy never in an error, ids that could leave browsers/, archived identities, a delete waiting for a launch in flight, serialized calls, a cancelled launch and the same-transaction events. A mutation (dropping on_terminated) fails the crash tests.

Docs: architecture section 8 states the manager's rules; its "Not yet" says the manager exists and nothing calls it. Arch 4.1, 4.2, 5.3, 6 and 8.3 follow with the wiring in B5.

Tests: 41 added (40 in test_browser_manager.py, 1 in test_mcp_tool.py), none removed or changed; vitest 724 passed (the parity test has 3 more assertions, same count). Floor: pytest linux 2233 -> 2275 in .github/test-floors.json. Suites: engine 2275 passed, vitest 724 passed, typecheck ok, go test ./... ok, smoke 146 passed 0 failed 0 skipped.
2026-10-05 23:02:54 +02:00
feder-cr f323170cdb engine: relay environment and MCP images at the origin
The browser phase runs invisible-playwright-mcp through nanobot's MCP client and dot-agentd relay (design 2.1, 2.3). Three things the client and the Computer could not do are changed where they live, before the BrowserManager (B4) uses them.

computer.py: relay_argv takes env and emits one --env NAME=VALUE per entry, in the order given (dot-agentd's relay already has the flag); the Computer protocol, AgentdComputer and the LocalComputer test fake follow. env is the program's environment inside the VM, spawn_env stays the environment of the relay process, so no engine variable reaches the program.

mcp.py: MCPServerConfig gains images (default False) and MCPToolWrapper an images constructor flag. With it, a tool result that has an image block is a list of content blocks in the server's order (text blocks and image_url data URLs); without it, or on an error result, an image is still reported by MIME type and its bytes are dropped, so every other caller keeps today's text. A result without an image stays a string. MCPProvider.connect() returns the configured servers that are not connected when it returns (empty when all are live), so the manager can raise launch_failed instead of finding out from the log. The per-server tool_timeout was already honored by each wrapper; a test now pins it (7 s and 120 s side by side).

Tests: 16 added (3 in test_computer.py, 13 in test_mcp_tool.py), none removed or changed. Floor: pytest linux 2217 -> 2233 in .github/test-floors.json. Suites: engine 2233 passed, vitest 724 passed, go test ./... ok, typecheck ok.
2026-10-05 22:33:15 +02:00
feder-cr 936c140aef agentd: the tests that bind every interface run on Linux only
TestListenTCPOnEveryInterfaceIsIPv4Only and TestDefaultBindRequiresTheTokenOverTCP
bind 0.0.0.0. On a Windows host every go test builds a new test binary, so
Windows Firewall asked the user to allow agentd.test on every run of the
suite (pre-push hook included). Only the Linux guest ever binds every
interface, so both tests move unchanged to every_interface_linux_test.go
(build tag linux) and keep running where the behavior lives: CI, the docker
runner and WSL.

Floors: go linux stays 97 (both tests still run there, 97 passed);
go win32 goes from 63 to 61, the two moved tests (62 passed on Windows).
2026-10-05 22:26:22 +02:00
feder-cr ad89aa71bf engine: the browser identity store
Adds dots_browser_identities (id, name, proxy, created_at, last_used_at, archived) to the engine's database with its row functions in store.py: insert_identity, get_identity, list_identities, count_identities, touch_identity, set_identity_archived, delete_identity. Whether an identity is open is never stored: the BrowserManager derives it from live sessions, so a file never claims an open browser for a process that is gone. The proxy is kept as given because every launch needs it; anything shown goes through identity_rules.redact_proxy. SCHEMA_VERSION 4 -> 5 (no migration, as before).

Tests: TestBrowserIdentities in test_store.py (12) plus the table list and the strict-table count (10 -> 11). architecture.md 6 and 8.7 name the table and the proxy storage.

Removed tests: none. Floors: pytest linux 2205 -> 2217 (2217 passed in the engine suite).
2026-10-05 22:22:27 +02:00
feder-cr 0069fb82df shared, engine: identity rules in one behavior, the MCP tool fixture
The browser identity rules (name 1..80, proxy scheme and host, redact_proxy,
slug-plus-six-characters ids, the safe-id pattern) existed only in TypeScript
(packages/shared/src/identity-rules.ts, ids.ts) and were never tested as a
unit. The engine's BrowserManager (next steps) must enforce the same rules for
every caller, so they are now one behavior in two languages:

- packages/shared/test/fixtures/identity-rules.json holds the cases: 41 create
  requests (messages included), 15 proxy redactions, 11 slugs, valid and unsafe
  ids, new-id patterns. packages/shared/test/identity-rules.test.ts runs them
  against the TypeScript.
- nanobot/dots/identity_rules.py is the engine's implementation, and
  tests/dots/test_identity_rules.py runs the same fixture. The edges where
  Python differs from JavaScript are handled and pinned: trim() whitespace,
  length in UTF-16 units, URL parsing (default ports, IPv6, ports out of
  range), and the id pattern matched whole so a trailing newline is refused.
- tests/fixtures/mcp-tools-0.70.2.json is the real invisible-playwright-mcp
  tools/list captured at 0.70.2 (restored from 7f7e88bf), with a test that its
  version equals the pin in guest/image-builder/builder/mcp-requirements.lock,
  that it is named for that version, and that it holds the sixteen tools.
- guest/image-builder/README.md says the fixture exists and that a new MCP
  version needs a new capture in the same change.

No behavior of the TypeScript changed.

Tests moved, not removed: the slugify (2) and newIdentityId/isValidIdentityId
(2) tests of packages/shared/test/ids.test.ts now live as cases of the shared
fixture (every assertion kept, the long-name case now pins the exact slug), so
the cases have one owner.

Floors raised for the added tests: vitest linux 631 -> 721, win32 634 -> 724
(measured 724 on win32); pytest 2091 -> 2205 (measured 2205). None lowered.
2026-10-05 22:13:40 +02:00
feder-cr 6cef4bbb47 engine: exec targets by allowlist, chat spend kept across approvals
Fixes the three findings of the engine review of "shared, engine: tool.called names its target" and "shared, engine: spend reported to the host".

E7, exec target leaked credentials (fixed). redact_command blocklisted the shapes a secret takes in free text, so `curl --proxy-user bob:pw`, `curl -U bob:pw`, `curl -x bob:pw@proxy`, `curl -u a:b`, `mysql -uroot -phunter2`, `sshpass -p x`, `docker login -p x`, `curl -H "PRIVATE-TOKEN: x"` and `?key=x` all reached tool.called unchanged (confirmed by running the old module). It is now an allowlist over a tokenized first line (nanobot/dots/targets.py, whose module doc lists both sides). Shown: the program of each command, plain positional words, long options whose name does not say they hold a credential, flags run together of up to three letters, a URL without its user and password and with its query and fragment values masked, host:port and host:/path. Masked as `***`: the value of every single-letter option (only a plain path stays), a value written against its flag (`-phunter2`), the value of a long option or NAME= named for a credential, a quoted word with spaces, any user:password word, and the word after Bearer or Basic; a quoted header keeps its name (`'Authorization: ***'`). What stays visible is a secret written as a bare positional word or as a value that starts like a path; the module doc and architecture 5.4 say so and the full command stays behind the approval. Tests: every form of the report, a grid of 6 secrets over 12 ways of writing them, the plain commands that must stay readable, and a smoke check with `curl -U user:pass` that also greps the whole event stream for the password. The body of the earlier commit said "14 credential forms masked"; that was a count of regexes, not of forms, and is superseded by this.

Expectations changed because the masking is stricter, none because a feature went away: test_targets `curl -u me --header Bearer tok123 x` now shows `curl -u *** --header *** *** x` (was `-u me`, `Bearer ***`); `mysql --password=hunter2 -u root` and `mysql --password hunter2 -u root` now show `-u ***`; the URL case now masks every query value (`?token=***&page=***`, was `page=2` dropped by the old greedy match); `python -m http.server 8000` left the list of commands shown as they are (the `-m` value is now masked) and `python3 serve.py 8000` plus six more plain commands joined it; smoke "that tool.called names the command that ran" now expects `ps -o ***` where it expected `ps -o user=`.

E3/E4, chat spend lost across an approval or a suspension (fixed). TurnRunner emptied the chat's spend row at the start of every chat turn, including the continuation turn of an approval and a resume, and a parked turn emits no message.assistant, so the spend before the park was in no event, GET /usage undercounted, and each approval restarted the chat's cap. The row is now emptied where the spend is reported: store.append_outbox_spent takes the chat's spend with the message.assistant that reports it, in the same transaction. Every dollar of the chat is therefore reported by exactly one answer however many turns it took (park, approve, restart, sleep), the cap holds from one answer to the next, and a chat turn cut by a restart is no longer missing from the total (architecture 5.4, 8.2 and the /usage paragraph updated). The reviewer's alternative (reset only when a turn opens on new input) would still lose the spend of a parked chat when new input arrives before the decision. Tests that fail before the fix: test_engine (a chat parks, is approved and answers: spent_usd is 0.4375, the sum of both parts, and the next answer starts from nothing; a chat that spent past the cap before the approval fails after it instead of starting again) and test_store (an answer takes the spend, a task event leaves its task's). test_turns `test_the_chat_starts_every_turn_with_nothing_spent` was renamed `..._every_answer_...`: it still checks that a second turn is not capped by the first, and now asserts that the answer took the first turn's spend (ledger 0.0, answers 0.9 and 0.9) where it asserted the ledger kept 0.9.

E7, TOOL_TARGET_MAX counts code points in Python but UTF-16 units in zod (rejected). zod 4.6.5, which packages/shared uses, measures a string in code points: a schema `z.string().max(160)` accepts 160 emoji (320 UTF-16 units) and refuses 161, so Python len() and the schema agree and the host does not drop such an event. Evidence: a node run of the schema over 79, 80, 81, 100, 160 and 161 emoji (accepted, accepted, accepted, accepted, accepted, refused), and the new assertion in packages/shared/test/events.test.ts through parseOutboundEvent. A UTF-16 clip would have cut targets for nothing. No code change; the unit is now written down (protocol.py, events.ts, targets.py clip) and pinned by a test on both sides that share one literal (200 emoji clip to 159 plus an ellipsis, which the schema accepts; 161 emoji are refused).

Floors: pytest linux 1978 to 2091 (the measured count). vitest is unchanged (the events test gained assertions, not a test). Suites run: engine 2091 passed, vitest 634 passed, typecheck, web build, go test, smoke on the tree 146 passed 0 failed 0 skipped.
2026-10-05 22:01:05 +02:00
feder-cr 81e519a3c5 host: a Dot's secrets are deleted with the Dot
DotsRepository.delete removed only the dots row. secrets.scope holds a Dot id or 'global', so no foreign key could cascade it, and a deleted Dot's per-Dot OpenRouter key stayed encrypted in the database forever (channel tokens would have joined it). The delete is now one statement (a data-modifying CTE) that removes the Dot and every secret scoped to it, so no caller can leave the secrets behind and no transaction is needed.

Tests added: a repository test (the Dot's secrets go, the global key and another Dot's keys stay; runs on PGlite and on pg) and a scheduler test (Scheduler.deleteDot removes the per-Dot key, keeps the global one). No test removed or weakened.

Floors raised in .github/test-floors.json: vitest linux 629 to 631, win32 632 to 634, postgres linux 199 to 205 (the measured count of the pg run of packages/database, events, apps/api, apps/scheduler; the old value was below it).

Deferred: secrets already orphaned by earlier deletes are not purged (a cleanup migration would touch the migration lists of three test files and collide with the numbering of H3).
2026-10-05 21:22:13 +02:00
feder-cr b42b5fd977 shared, engine: tool.called names its target
A tool.called event said "exec ok 1.2 s" and nothing about what was done, so an inline tool step in the chat could not say what the Dot ran or touched. It now carries `target`, one redacted line of at most 160 characters: the first line of a command, a path, a search term, an action and a name.

Contract: packages/shared events.ts `tool.called.target?` (zod: non-empty, at most TOOL_TARGET_MAX = 160, one line) and the TOOL_TARGET_MAX constant; protocol.py keeps its copy of the number (it lists type names only, so nothing else needs a mirror) and tests/repo/vendored-nanobot.test.ts keeps the two equal. Architecture 5.4 defines the field and what each tool shows, 8.7 says where it is read from.

Engine: the permission table, which already says what each tool needs, now also says what of a call may be shown. `ToolEntry.target` is a required function per row, implemented in the new nanobot/dots/targets.py: exec the first line of the command (command or cmd) cut at 120 characters, with the credentials it carries masked as `***` (URL user and password, Bearer or Basic, Authorization, API key and cookie headers, NAME=value and --flag value whose name says password, token, key or secret; best effort, documented as such); read_file, list_dir, write_file, edit_file the path; find_files the query, else the glob, else the path; grep the pattern; apply_patch the path or "N files, first <path>"; memory_search the query; memory_get the name; cron the action and the name or job id; exec_session "input to <id>", "terminate <id>" or "output of <id>", never the input; list_exec_sessions nothing. `permissions.tool_target` is the one place that applies a row and cuts to TOOL_TARGET_MAX. DotsTurnHook.before_execute_tool stores it on the call's intent (new nullable `target` column of dots_tool_intents, ToolIntent.target, SCHEMA_VERSION 3 to 4: the engine does not migrate) and _record_tool_result writes it into tool.called. A call that never started has no intent and so no target: a denied call, a tool that was not offered, arguments that did not fit. A call cut by a stop keeps it, so it is reported with what it was doing.

Tests first: test_targets.py (every tool's target, malformed arguments, the 120 and 160 cuts, one line without control characters, no content in any target, 14 credential forms masked, ordinary words about tokens and passwords left alone, only the first line looked at), test_permissions.py (every row has a target function that returns None for no arguments), test_store.py (the intent keeps its target, None by default, the first start of a call wins), test_transcript_outbox.py TestToolTarget (ran, task, failed, interrupted, approved, absent when None or empty, denied has none), test_turns.py TestToolTargets (a real turn through five tools, an exec whose Authorization header and URL password are masked, a failing task call, the intent row is consumed) and the denial test now also asserts no target; shared events.test.ts (kept, at the limit, refused when empty, long, on two lines, not a string); the parity test. Smoke: the t1 exec names its command, write_file names the path and none of the content, an exec with a --token argument is reported masked (145 checks).

Removed tests: none. Existing assertions were not changed; the exact-dictionary assertions on tool.called of calls that never ran still hold, which pins that those carry no target.

Floors: pytest 1887 to 1978 (1978 passed; the floor had been left below the count), vitest linux 628 to 629 and win32 631 to 632 (632 passed). typecheck clean, go test green, apps/web build green, smoke 145 passed 0 failed 0 skipped.

Known limits, documented: exec with tty is named by its command like any exec (the event carries no tty flag, so a client cannot say "terminal session" from it); the masking of a command is best effort.
2026-10-05 21:15:15 +02:00
feder-cr 44a6b8ec6b engine: exec on a pseudo-terminal
exec gains a tty argument for programs that ask questions or need a terminal (REPLs, ssh, sudo prompts). It is the same tool under the same computer.exec permission, so no tool name or permission is added and nothing changes in packages/shared. tty implies a session: the call always runs as a background job that exec_session reads, answers (^D through close_stdin, ^C as the character with code 3) and ends.

ExecTool._spawn, the one spawn of every model command, passes tty to computer.relay_argv and spawn_env (the relay gets --tty and TERM); ExecSessionManager.start forwards it and list_exec_sessions marks terminal sessions. The terminal's output is one stream with escape sequences and carriage returns that waste context and confuse the model, so a tty session's poll text goes through terminal_text (exec_session.py, standard library only, no new dependency): it drops CSI/OSC/charset escapes and control characters, turns CRLF into a newline, lets a lone CR, a backspace and erase-in-line overwrite (a progress bar reads as its last state), and holds an escape sequence cut by the end of a poll until the next one (flushed at the end of the stream, dropped past 4096 characters). Full-screen programs are not rendered, by design. No terminal for the person: that stays out of scope (architecture section 10).

Tests: test_terminal_text.py (the function, including every cut position of a sequence), test_exec_session_tty.py (screen text, raw stream kept without tty, split sequence across polls, flush at exit, input and terminate, list marks tty), test_exec_through_relay.py (record tty True and TERM, tty without yield starts a session, quick tty command, no TERM leak without tty), test_tool_descriptions.py and test_permissions.py (tty is a boolean argument of exec, no new tool name). Smoke: RUN-TTY and TTY-ANSWER stand-in directives; a real relay and pseudo-terminal show the program a tty of 80x24 with TERM, the model answers through exec_session, and the results carry no escape sequence or carriage return. docs/architecture.md (tool table, 8.8, not-yet list) and UPSTREAM.md updated.

Removed tests: none.
Floors: pytest 1853 -> 1887 (34 added tests); vitest, go, postgres unchanged.
2026-10-05 20:49:00 +02:00
feder-cr bd96ba66ba shared, engine: the summary model role
The models map of a Dot config took any role name and the engine ignored every
one of them. The engine asks a model for one job other than the turn itself:
the summary that replaces the old part of a thread that outgrew
limits.context_tokens. So the roles are a closed list, MODEL_ROLES = summary,
owned by packages/shared/src/config.ts; a config naming another role is
refused as: unknown model role "fast" (the roles are: summary). The engine
keeps a copy in nanobot/dots/protocol.py (parity pinned by
tests/repo/vendored-nanobot.test.ts) and refuses the same in the same words.

EngineSettings.models holds the named roles as a tuple (the settings stay
hashable) and model_for(role) answers the named model, else the Dot's own;
asking for a role there is not is an error, not a silent fallback. TurnRunner
builds the summary runtime with model_for("summary") on the same metered
provider, so the summary's real cost counts toward the cap. When that model is
not the turn's, the summary request carries no tool definitions (another model
may not take them); the turn's own model keeps sending them, which keeps its
prompt cache. The window stays limits.context_tokens, so the summary model
needs a window at least that large (architecture 8.6). A config push applies
from the next turn only, as the model does.

Tests: config.test.ts (role accepted, other roles refused with the roles
listed, empty id), test_protocol.py (same refusal), test_projection.py
(model_for, unknown role), test_turns.py (summary request's model and tools
with the role, without it, with the role naming the Dot's own model),
test_engine.py (a push changing the role applies to the next turn only),
vendored-nanobot.test.ts (MODEL_ROLES parity). Smoke: a window of 8000 tokens
and five long messages force a summary; the stand-in's log shows it went to
the role's model with no tool, and the turns to the Dot's own model.

Removed tests: none. Test data changed because free-form role names no longer
exist: the example role fast became summary in config.test.ts (FULL_YAML and
its expectation) and apps/web/test/yaml.test.ts, and vision became summary in
test_protocol.py (accepted config and the empty-id case).

Floors raised: vitest linux 625 to 628, win32 628 to 631 (3 tests); pytest
1846 to 1853 (7 tests). None lowered.

Docs: architecture section 7 (example and the closed role list), 8.6 (the
summary request), and the not-yet list in 8.8 no longer names the models
roles.
2026-10-05 20:36:12 +02:00
feder-cr e5a448741c shared, engine, host: spend reported to the host
The engine knew what a task or a chat turn had spent (the ledger of the cost cap) and told nobody. The host now sees it: the events that end or advance work carry the spend, the host keeps it on the task, and a route sums it for a Dot. This is what the cost pills of the UI read.

Contract: message.assistant, task.progress, task.completed and task.failed carry spent_usd, the spend of their session so far. A task's events carry the task's spend (it only grows, and survives a restart, an approval and a resume); the chat's message.assistant carries the spend of the turn that answered (the chat's ledger row starts again with every turn). packages/shared events.ts: SpentUsd type on the four data shapes, the zod schema accepts a non-negative finite number (optional: events logged before have none; without the key in the schema zod would strip it), USAGE_EVENT_TYPES. protocol.py lists type names only, so it needs no mirror; architecture 5.4 defines the field.

Engine: store.append_outbox_spent is the one place the spend of an event is read (from dots_spend, in the transaction that stores the event, rounded to 8 decimals so float sums show no noise) and the only way these four events are appended: transcript_outbox (answer, completion, progress) and engine.py (task.failed for a task that exhausted its attempts or whose turn failed, the chat's "I could not answer"). No schema change.

Host: migration 0003_task_spend.sql adds tasks.spent_usd (double precision, default 0, >= 0). TasksRepository.recordSpend keeps the highest value heard (a late or repeated event never lowers it), only for a task of the reporting Dot, and also for a task that already ended (a cancelled task the guest keeps working on still spends). applyGuestEvent projects it from task.progress, task.completed and task.failed. EventsRepository.spentUsd sums spent_usd over the guest events of USAGE_EVENT_TYPES (task.completed, task.failed, message.assistant: each carries a whole unit; task.progress is a running value of a task that ends with one of them, so summing it would count the money twice) since a moment. Scheduler.usage and GET /api/dots/:id/usage?since=<ISO 8601> answer { dot_id, since, spent_usd }; a bad since is 400, an unknown Dot 404, a deleted Dot's history stays readable by id like /events. SDK: client.usage(), UsageAnswer and TaskRecord.spent_usd in shared api.ts. Known limit, documented: work that never reported an end (a cancelled task, a chat turn cut by a restart) is not in the usage total; the task's own spent_usd still shows what was heard.

Docs: architecture 5.4 (field and meaning), 8.2, 9.1 (tasks table), 9.6 (route and what it counts). Smoke README.

Tests: test_store.py (append_outbox_spent: the session's spend at that moment, zero, no float noise, refuses other event types), test_transcript_outbox.py TestSpentUsd, test_turns.py (a task's events tick 0.25, 0.75, 0.875; a chat answer reports its turn), test_engine.py (a task resumed after a kill reports the spend of both processes; every chat answer reports its own turn); shared events.test.ts (the schema keeps spent_usd on the four events and refuses a negative, text, null, NaN or infinite value); repositories.test.ts (events.spentUsd by type, source, Dot and since; no float noise; recordSpend only grows, stays inside its Dot, counts after a cancel); scheduler.test.ts (projection from progress and completion, from failure, after a cancel, a guest cannot write another Dot's task); api.test.ts (usage route, since, 400, 404, deleted Dot). Smoke: the events of a task that spent nothing report 0, the task failure and the chat answer report 1.2, and the failure after kill -9 reports the spend of both processes.

Existing engine tests that compared the exact data of these events now include spent_usd (0.0, or the spend the test made); no assertion was dropped or loosened. The migration test lists 0003_task_spend with the other versions.

Removed tests: none. Floors: pytest 1832 to 1846 (1846 passed), vitest linux 607 to 625 and win32 610 to 628 (628 passed), postgres 181 to 199 and pg require 75 to 84 (raised by the 18 and 9 tests added; 201 passed on pg and PGlite against postgres:18.4), smoke 128 to 132 checks (132 passed, 0 failed, 0 skipped). typecheck clean, go test green, apps/web build green.
2026-10-05 20:20:18 +02:00
feder-cr efc311af73 engine: the cost cap
limits.max_cost_per_task_usd was accepted and never enforced: a task or a chat turn could spend without bound. The engine now stops a turn that has spent its cap, and a response that reports no cost fails the turn closed, so the cap never runs blind.

Cost: OpenAICompatProvider._parse_chunks puts the cost the gateway reports in the usage of the final stream chunk (usage.cost, USD) on the new LLMResponse.cost_usd, with a float reader next to the int one. It is on the response, not on LLMUsage, which rebuilds itself field by field and would drop it. A bool, text, NaN, infinite or negative value is no cost. For a request marked is_byok the upstream cost it reports beside the charge is added (may count more than was charged, never less).

Spend: new nanobot/dots/spend.py. TurnRunner._run builds one TurnSpend per turn, bound to the turn's session, and wraps the turn's provider in a MeteredProvider above chat_stream_with_retry, the one entry of every model request (the runner's, its retries, the consolidator's summaries): each response adds its cost to the session's row of the new table dots_spend (session_key, usd) in its own transaction before the runner sees it. A failed request has no cost and counts for nothing. DotsTurnHook.before_iteration checks the spend and raises CostCapReached, which TurnRunner.run turns into TurnOutcome.failed, so the existing end-of-turn path does the rest: task.failed with the text for a task, "I could not answer: ..." for the chat, open calls closed. A task's row is never reset (it survives a restart, an approval and a resume); the chat's row is reset when a chat turn opens. EngineSettings.max_cost_usd comes from project(); a lowered cap applies from the next turn like the step limit. SCHEMA_VERSION 2 to 3 (the engine does not migrate, as before).

Texts: "stopped: the task reached limits.max_cost_per_task_usd (spent 1.0423 USD of 1.00)", "the turn" for the chat; a missing cost: "stopped: OpenRouter reported no cost for a request, so limits.max_cost_per_task_usd cannot be enforced". The check runs before a request, so the cap may be exceeded by the last request and an answer that crosses it is delivered (documented).

Docs: architecture 7 (the field's comment), 8.2 (the rule), 8.5 (every request is streamed, which is how the cost arrives; the "no streaming" drift is gone), 8.8 (dots_spend among the tables, the cap leaves the "not yet" list).

Tests: test_spend.py (the meter, the ledger, a retried request through the real provider counts once, the check and its texts, a missing cost); test_openai_compat_stream.py (cost from a stream in the SDK and plain forms, zero, BYOK, absent, not an amount); test_turns.py TestTheCostCap (two responses of 0.006 against 0.01 stop before the third, the answer that crosses the cap completes, the chat text, a summary request counts, a missing cost fails closed, a task's spend reaches the next turn, an approval continuation stops at once, the chat starts every turn at zero, the cap of the settings); test_engine.py (task.failed with the text and closed calls, the chat answer, the cap holds after the process is killed, a task found running with the cap spent fails before any request); test_store.py (the ledger functions, the file reopened); test_projection.py. The scripted provider stamps a default cost of 0 on responses that carry none (default_cost=None leaves them unpriced). Smoke: fake_openrouter.py learns a COST <usd> line (the cost every response reports) and REPEAT-EXEC (exec after every result); with the cap of 1 a task of 0.6 a response fails with the exact text after two requests, a chat turn answers with the turn's text, and a task killed with kill -9 after its first response stops after one more (two requests in all, so the spend survived); agent.started count 5 to 6.

Not in this commit: spent_usd on the events, the host projection and the usage route (step E4).

Removed tests: none. test_store.py's table count 9 to 10 and the table-name list gain dots_spend (the feature's table, strictness still checked for all). Floors: pytest linux 1783 to 1832 (suite is 1832 passed); vitest 610 passed, typecheck clean, go test green, smoke 128 passed 0 failed 0 skipped.
2026-10-05 19:54:46 +02:00
feder-cr f3f8f75a6f engine: memory.written for the notes the file tools write
A Dot's long-term notes are files under /home/dot/memory, written with the file tools. The host and the UI could not tell when one changed, and memory.written (already in events.ts and protocol.py) was never sent. The engine now sends one memory.written {key} per note a call wrote, key being the path relative to the memory directory.

memory_tools.py, which owns MEMORY_DIR, gets memory_keys_written(tool, arguments, resolve): for write_file, edit_file and apply_patch (not its dry run) the paths that resolve, with the computer's own resolution, to a file inside the memory directory, once each, in order. A relative path, ../ and nested notes count as the tool counts them; the directory itself and anything outside are no note. DotsTurnHook.before_execute_tool computes the keys from the prepared arguments and stores them on the ToolIntent (new column dots_tool_intents.memory_keys_json, SCHEMA_VERSION 1 to 2; the engine does not migrate, as before). _record_tool_result appends the events right after the call's tool.called, in the same transaction, only when the call ran ok: a failed, denied, interrupted or never-run call sends none. Known limit, documented: a note written through exec is not seen.

Contract: no type change (parity with packages/shared is untouched); architecture 5.4 defines the event and its key, 8.6 points to it, 8.8 no longer lists memory.written as not yet done.

Tests: test_memory_tools.py (each tool, nested, relative, ../ out and back, the directory itself, dry run, malformed arguments, tools that write no file), test_store.py (the intent round-trips its keys, first start wins, JSON-special names), test_transcript_outbox.py (ok, task call, atomic with the message, failed, denied, interrupted, never run, approved, no keys), test_turns.py with the real tools on LocalComputer (write, read, edit and patch in one turn in order, a task turn, outside writes, dry run and failing calls, an approved write, a denied write). Smoke: the stand-in learns WRITE-NOTE and FIND-NOTE; a write_file into a nested note gives tool.called then memory.written with the relative key, memory_search finds it, and a ../ write out of the directory gives none.

No test removed. Floor: pytest linux 1723 to 1783 (suite is 1783 passed); vitest 610 passed, typecheck clean.
2026-10-05 19:29:49 +02:00
feder-cr d7aeb59d98 engine: task.progress from the assistant text of a task turn
A task's model often writes a line beside a tool call (what it is about to do). The engine now reports it as task.progress {task_id, text}, so the host and the UI can show the work as it happens instead of only the final summary.

record_transcript_append, the one place that turns a stored transcript message into outbox rows, sends it for an assistant message that is not the final answer, has tool calls and non-blank text, in a task whose status is running. It is written in the message's own transaction, so a restart neither loses nor repeats it, and it precedes the tool.called of its calls. The text is trimmed and cut at PROGRESS_TEXT_MAX (2000) characters, the ellipsis included. The chat, the final answer, a message with no text, and a task that is not running send nothing. No contract change: the type was already in events.ts and protocol.py; architecture 5.4 and 8.7 now define it and 8.8 no longer lists it as not yet done.

Tests: test_transcript_outbox.py (17: once, text parts only, blank and thinking-only, cut and exact limit, order with tool.called, chat, over tasks, queued task, final answer, no calls, atomic with the message), test_turns.py (task order progress, tool.called, tool.called, completed; chat sends none), test_engine.py (a suspend and restart after the text reports it exactly once). Smoke: the stand-in learns SAY-RUN-EXEC <text> :: <cmd> (text beside the call, streamed and not); task t7 reports the line once before its call and completion, and a chat with the same directive sends none (114 passed).

No test removed. Floor: pytest linux 1703 to 1723 (suite is 1723 passed).
2026-10-05 19:12:17 +02:00
feder-cr 8041d041df shared: one permission registry, live permission names only
packages/shared/src/tools.ts held a second tool table (TOOLS, 24 tools with
JSON schemas) next to the engine's own table in nanobot/dots/permissions.py.
Nothing but its own test read it; the engine offers, decides and reports every
tool from its table. Two tables for one fact is how they drift, so the dead one
is deleted: TOOLS, ToolDefinition, JsonSchema, ToolName, TOOL_NAMES, getTool,
offeredTools and redactToolArguments. The password redaction of a proxy URL
stays owned by redactProxy in identity-rules.ts and moves into the engine's
approval events with the browser phase (architecture section 5.4).

The five permission names that no tool of the Dot can ever exercise are deleted
from PERMISSIONS instead of left as switches that do nothing: web.fetch and
web.search (web reading and search are removed from the engine, section 8.8),
subagents (removed), message.send (a Dot has no tool that messages anyone) and
memory.write (notes are files written with files.write, section 8.6). A config
that names one is now refused as an unknown permission. ASK_BY_DEFAULT in
config.ts is browser.identity.delete and automations, and the web namespace is
no longer allowed by default.

PERMISSION_INFO gives every permission a label, a one-sentence description and
a risk (low, medium, high), the words the UI shows when a person decides.

A repository test now keeps every permission of the engine's table, and every
permission named in section 8.3 of the architecture document, a member of
PERMISSIONS, and the removed names out of sections 7 and 8.3.

Behavior of what is offered does not change: the engine's tools and their
permissions are the same, and the host's defaults for the permissions that
remain are the same.

Tests removed with the feature they tested (packages/shared/test/tools.test.ts,
7 tests, all of the deleted table):
- "has the 24 tools of section 8.3 with unique, dot-free names" (TOOLS gone)
- "maps tools to the permissions of section 8.3" (getTool gone; the engine's
  mapping is pinned by tests/dots/test_permissions.py and the architecture test)
- "declares argument schemas that agree with section 8.3" (schemas gone; the
  engine's tools carry their own schemas)
- "marks the tools whose result is an image" (returnsImage gone)
- "offers everything when the Dot manages its identities" and "leaves out create
  and delete when it does not" (offeredTools gone; the engine's offered_tools is
  tested in tests/dots/test_permissions.py and test_projection.py)
- "declares which tools may run again after a crash" (replaySafe gone; the
  engine's crash recovery owns it)
config.test.ts changes: the default-ask set and the runtime check use the
permissions that remain (message.send and subagents are no longer permissions).

Tests added: tools.test.ts 6 (registry names, exact list, removed names,
PERMISSION_INFO covers PERMISSIONS exactly, fields, risks), config.test.ts 5
(each removed name is an unknown permission), architecture-engine.test.ts 1.

Floors (.github/test-floors.json): vitest linux 602 -> 607, win32 605 -> 610.
Nothing lowered.
2026-10-05 18:55:06 +02:00
feder-cr f0e86ad7c6 docs: the engine on the nanobot fork
Architecture, README and notices describe the nanobot engine, its image and
its contract.
2026-10-05 18:07:50 +02:00
feder-cr d8728f1210 smoke: the engine smoke lives in the repository and runs in CI
guest/image-builder/test/smoke/run.sh builds dot-agentd and the engine exactly
as the image does, starts the engine as dotengine in ubuntu:24.04 and checks the
Dot's contract end to end (chat, tasks as dot, offered tools, the key only in
memory, kill -9 and stream resume, approvals across kill -9, interrupted calls,
SIGTERM, startup refusals); it fails on any FAIL or SKIP. A smoke job runs it in
CI. Test floors for every suite.
2026-10-05 18:07:49 +02:00
feder-cr e11752d44a repo: delete the earlier TypeScript engine and agent
guest-runtime/ and guest/invisible-dots-agent/ are replaced by the Python
engine; no host code imported them. Their workspaces, the agent bundle on the
runtime disk, its CI build step and tests/e2e (it drove the old agent's tools)
go with them.
2026-10-05 18:07:49 +02:00
feder-cr 03ff3ad791 guest: the engine's Python environment in the golden image, its source on the runtime disk
The golden image builds /opt/invisible-dots-engine from a hashed lock
(builder/engine-requirements.lock, wheels only) with builder/build-engine-env.sh;
the runtime ISO carries the engine's source, joined to the venv by a .pth file;
the engine refuses to start when the two locks differ. The unit runs
python -I -B -m nanobot as dotengine with NoNewPrivileges; install.sh makes the
state directory.
2026-10-05 18:07:49 +02:00
feder-cr 9cd45eb667 engine: the Dot on nanobot
nanobot cut to its library core (no channels, UI, CLI, subagents, web tools,
other providers, telemetry, generic hooks, or approval systems but the Dot's),
and the Dot's layer in nanobot/dots/: protocol, permission table, store with
transcripts and outbox in one SQLite file, the one gate at the tool execution
boundary (allow, deny, park; one-shot pass after approval; ids per session),
per-message commits, the turn runner over nanobot's AgentRunner, the engine
(chat, tasks, approvals, prepare-sleep, recovery after kill -9), the HTTP API
on agent.sock, cron automations, the OpenRouter provider with the key held in
memory only, and the model's computer through dot-agentd as user dot.

Host: the OpenRouter key format is one rule (packages/shared), refused where
the key enters. CI: an engine job runs pytest; test-guard reads its junit.
2026-10-05 18:07:48 +02:00
feder-cr a0fcfe94ed guest: dot-agentd runs the engine's commands and files as dot
The engine runs as dotengine and never executes the model's commands itself.
dot-agentd gains the relay (dot-agentd relay -- PROGRAM ARGS: the remote
process group dies with the relay), the file routes on agentd.sock (GET/PUT
/v1/files, /v1/files/list) and their tests; the pre-push hook runs gofmt and
go vet as CI does.
2026-10-05 18:07:48 +02:00
feder-cr f4800f7738 engine: import nanobot f75470e7 as published
The engine of a Dot is a hard fork of nanobot (HKUDS/nanobot, MIT), upstream
commit f75470e72f0993dcf92accc81282adaa48b16f56. This commit adds, unchanged,
only the upstream files the engine keeps, and nanobot's LICENSE, so every
later change to them reads as a diff against upstream.
2026-10-05 18:07:48 +02:00
feder-cr 4b390f9076 files test: the built-in Administrator appears as LA in SDDL 2026-10-03 17:19:52 +02:00
feder-cr 1591a993b2 Private files: no account but the owner, checked by SID; SYSTEM and Administrators may stay
A CI runner grants SYSTEM and the local Administrators explicitly on every new
directory, so removing inheritance leaves them, and the test that wanted the
owner alone failed there while passing elsewhere. Those two can open any file
on Windows anyway, as root can on Linux; what the rule protects against is
every other account. The test now reads the ACL as SDDL, by SID rather than by
names that change with the display language, and refuses inherited entries,
an unprotected list and any account but the owner, SYSTEM and Administrators.
2026-10-03 17:15:04 +02:00
feder-cr aa7f92c43a files test: print the whole ACL listing when the owner-only check fails 2026-10-03 17:08:42 +02:00
feder-cr a800c1fb8d One replacement for every atomic file write, waiting while Windows holds a file
A rename over a file fails on Windows with EPERM, EACCES or EBUSY while another
process holds either file for a moment, typically an antivirus scanning a file
that was just written. Eight places renamed a temporary file over its target
and none waited, so a lock record, a manifest, a seed or a secret could fail
to be written at random; the golden image builder's lock did, in a test run.

retryWhileInUse moves from the vm-manager's runner to packages/shared, so the
codes that mean "in use" live in one place, and replaceFile is built on it.
Every atomic replacement goes through replaceFile. On Linux the first try
succeeds and the code path is the same.
2026-10-03 16:56:03 +02:00
feder-cr dee85414db README: invisible_dots, one complete block per system, the Windows restart, what is tested where 2026-10-03 16:50:16 +02:00
feder-cr 0c69fea6cb Document that an exec command dies with its caller; e2e step j checks it
dot-agentd kills a POST /v1/exec command's process group when the request
that started it goes away, which is what makes a cancelled call or a tool cut
at the stop grace stop its command. Section 8.7 said the opposite (that the
command keeps running until its own timeout), and e2e step j asserted that
sentence, so it failed against a guest behaving as designed. The fact now
lives in section 5.2, section 8.7 points at it, and a dot-agentd test pins it.

Step j now checks the real guarantee: the interrupted call appears once, with
interrupted: true, and once the command's time is over no copy of it runs and
its marker file was never written.

Also fixes the e2e guest client, which read the exec answer's body twice.
2026-10-03 16:24:03 +02:00
feder-cr 4cd29d04d4 e2e: read the Dot token as the seed writes it; kill and probe without matching the shell
Three faults in the new steps i and j, found reading them against the guest
before they ran:

- The seed carries the boot config as a JSON string inside the YAML
  user-data, so the quotes around "token" are escaped there; the pattern
  did not allow for that and would never have found the token.
- pkill -f invisible-dots-agent and pgrep -f 'sleep 8' run inside a
  bash -lc whose own command line holds the same text. pkill and pgrep skip
  themselves, not their parent shell: the kill would have hit its own shell
  and the in-flight probe would always have said yes. The patterns are now
  written so they cannot match the line that holds them
  ('invisible-dots-agent[.]mjs', 'sleep [8]').

Step i now also reads the restarted agent's own /health and /state through
its socket (the key pushed again, the same approval waiting) instead of only
the host's approvals table, and checks that the approval note reached the
model, which relays it. Step j waits for the command to finish in the guest
before counting what it wrote.
2026-10-03 15:13:36 +02:00
feder-cr 945d5ce8de engine: placeholders take the largest consumed results, never a small one
Step 1 of the context ceiling replaced consumed tool results oldest first.
On a task that looks a value up first and then reads large outputs, the
first result replaced was the short lookup itself; the large results then
fit under the trigger, so no summary or memory flush ever ran, and the value
was gone: the model answered that its result had been "already processed".
The prototype's compression test lost the vault code this way in one run
out of three.

Placeholders now take the largest consumed results first (the oldest first
among equals), so each one saves the most, and never a result under 1000
tokens (MIN_PLACEHOLDER_TOKENS, one constant): a short result costs little
and is often the very value a later step needs. The ceiling stays a
ceiling: if placeholders on large results are not enough, older images, the
summary with its memory flush and the shrinking of the newest round run as
before.

Tests: the shape of that test (one small fact, ten 30,000-character
results, the fact still sent verbatim and the request under the ceiling)
and largest-first ordering; both fail on the previous code. Architecture
8.6 updated. Vitest floors: linux 748, win32 751.
2026-10-03 13:40:26 +02:00
feder-cr 1a5788f4ec e2e and crash tests: kill the agent during an approval and during a command
Crash tests with real processes (guest-runtime/engine/test/crash): a small
driver builds the engine on a real dot.db with a test-only fault seam and is
killed at a named point (SIGKILL on Linux, TerminateProcess on Windows); a
fresh process starts on the same dot.db and the test checks what ran, the
next request and the outbox. The points: during accept after the inbox
insert; after the model response before the assistant commit; after the
intent before execute; after execute before the result (approved and not,
replay-safe and not); during the summary call; after the summary before its
commit; a replay-safe call killed twice; and a second process started while
the first is alive. Resume is under a second, tool.called is exactly once per
answered call, and the outbox has no gap.

The Linux real-VM e2e (tests/e2e/run.ts) gains two steps that reach
dot-agentd directly (the forwarded port, the proof handshake, the Dot's token
from its seed) to do to a guest what the model may not do to its own
computer: step i ends the agent process while an approval waits, and after
systemd restarts it the same approval is still pending, the approved command
runs once (a counter file); step j ends it during a slow computer_exec, and
after the restart the event log has tool.called interrupted=true and the
command ran once (a marker file). The key-scan steps become k, l and m. The
README's step table and cross-references follow.

Vitest floors rise by the 11 crash tests (linux 746, win32 749).
2026-10-03 13:01:35 +02:00
feder-cr 8cc19eb698 agent: run on the engine; stop grace; bundle notices
The guest agent now runs on guest-runtime/engine. agent-runtime and policy
are deleted: their tests were ported to the engine when it was adapted, so
the vitest floors drop by the 33 duplicates removed here (linux 735, win32
738 with the 4 new tests). task-runtime stays the Dot's task queue.
WORKING_MEMORY_MESSAGES, the old 40-message window, leaves the shared
package with its last user.

Stopping: a prepare-sleep or a SIGTERM used to abort a tool in flight at
once, so a command or a click whose result was not recorded ran again after
the next boot. Now a model request in flight is abandoned, and a tool in
flight gets up to 20 seconds to finish and record its result (systemd's
TimeoutStopSec=30 leaves the rest for the browsers and the database). A tool
cut at the grace keeps its intent: the next entry of its unit reports it as
interrupted, or runs it again if it is replay-safe. A tool that ignores its
abort cannot hold the stop past the grace. A cancel still aborts both at
once.

Notices: the bundle is built with legalComments "none", so the license
texts of the npm packages it carries (BSD, ISC and MIT ones among them)
never reached the runtime disk. The build now writes
dist/THIRD_PARTY_NOTICES.txt from esbuild's metafile: this repository's
license, Open Multi-Agent's when a derived file is bundled, and the license
and notice files of every package an input comes from. No list is written
by hand. The runtime ISO stages it next to the agent and refuses to build
without it.

Tests: stop.test.ts (a tool finishing within the grace records its result;
a call cut at the grace runs once and is reported after the key is pushed
again), notices.test.ts, and the runtime ISO test now expects the notices.
Architecture 2, 4.2, 5.3, 8.4, 8.7 and 11.3 updated.
2026-10-03 12:51:12 +02:00
feder-cr 5ce7a440b3 engine: regression tests for upstream defects
Defects of the upstream engine that the adapted one does not have, kept as
tests because each was found in practice and each is cheap to keep out:

- the task seed carries no task id and no title header (a "# Task: <id>"
  header once induced a tool call named after the id);
- JSON in the assistant's text runs nothing: only the response's
  tool_calls count;
- a one-word task with an ask tool suspends, survives a restart and runs
  once approved;
- the engine cannot be constructed without a store: there is one entry
  point, always durable, and DotRuntime now says so instead of failing
  later on an undefined store.

Running out of steps fails the task (upstream reported success), which the
ported driver suite already asserts. Vitest floors: linux 764, win32 767.
2026-10-03 12:45:14 +02:00
feder-cr 5729d969e6 engine: loop defences and a cost cap
A task could spend without bound: nothing stopped a model that repeats the
same calls, the step limit was the only cap, and a response cut by the
output limit ran its calls with truncated arguments.

- Repeated rounds: a round is the set of (tool, canonical arguments) of one
  response with the results they got. The third identical round in a row
  earns the model a notice; the same again within the unit stops it with
  "stopped: the same tool calls returned the same results 3 rounds in a
  row". Identical calls whose results change are not a loop. The streak and
  the notices are read from the unit's own messages (from its start
  message), so a restart keeps them and an earlier chat turn never counts.
  This replaces upstream's in-memory detector (tool and text repetition over
  a window, off by default): one loop detector.
- Cost cap: limits.max_cost_per_task_usd (default 1.00, 0.01 to 100),
  checked before every model request, the summary and flush calls included,
  against the usage persisted with every response, so it survives a
  restart. A chat turn is capped on its own. A model that reports no cost is
  logged once; the step limit still holds.
- A response cut by the output limit is stored without its tool calls,
  followed by a notice asking for a shorter reply, and counts as a step.

Tests: defences.test.ts (seven, the restart cases through simulated
crashes). Architecture 7, 8.2 and 8.5 updated. Vitest floors: linux 760,
win32 763.
2026-10-03 12:44:00 +02:00
feder-cr 52daca375a engine: the context budget is a ceiling
What a request carried was bounded by the number of messages (the last 40),
not by tokens: a few large tool results made a request of any size. The
summary strategy inherited from upstream was a trigger, not a ceiling: its
requests settled just above the budget, and its own summary call had no
bound at all.

New config field limits.context_tokens (default 32000, 4000 to 1000000).
A request is the system prompt, the unit's start message (the task seed or
the chat turn's user message, always whole), the newest summary and the
thread after it, read from dot.db after that summary, never whole. The
estimate covers the system prompt, the tool definitions, the messages and
1600 tokens per image, at the larger of 1/3 token per character and the
ratio the provider last reported for the model. Building stops at 0.75 of
the budget: consumed tool results become placeholders, oldest first, never
in the newest round; then a summary, cut where no call is separated from its
results so the verbatim tail fits 0.35 of the budget, made in requests of at
most 0.5 of it, stored capped at 0.15 (the mechanical fallback included),
after a memory flush that goes through the gate and only runs if its own
request fits; then the newest round's largest results are shrunk, head and
tail. A summary is also forced after 200 unsummarized messages. Above 0.9
of the budget, nothing is sent: the unit fails with a message naming the
budget. The usage of flush and summary calls counts toward cost, not steps.

agent/compression.ts holds what became of upstream's compression of
consumed results and summary strategy; the runner's old options for them
are gone, and so is the 40-message trim. The request attempts of section
8.7 are now counted per attempt of a step (its flush, summary and request
together), so a chunked summary does not exhaust them.

Tests: context.test.ts (eleven), including a property test over 500
random threads that no request, summary or flush exceeds the ceiling and
that calls stay paired and the start message pinned. Architecture 7, 8.5,
8.6 and 8.7 updated. Vitest floors: linux 754, win32 757.
2026-10-03 12:40:31 +02:00
feder-cr ce9c0d510f engine: a unit that ends answers every call it left open
A unit could end between the assistant commit and the results of its calls
(a failed write, a failure, a cancel) and leave a call without a result.
On the conversation thread that broke every later chat turn: the provider
refuses a request with a call and no result.

The completion, failure and cancel transactions now answer, first, every
call of the newest assistant message that has no result: "Not executed: the
unit ended before this call ran.", or the interrupted result (with its
tool.called) for a call whose intent shows it had started; and they delete
the thread's intents. The request builder does not repair a second time: it
asserts that every call is answered and fails the unit if not.

The first start on this engine, triggered by the absence of a config flag
(not by the migration, so a crash between the two cannot skip it), repairs
what the previous version left: the inbound replay runs first; old approval
rows get the position of their call; open calls in threads other than the
active unit's newest message are answered, or, in the middle of a thread
where no result can follow them, removed from their message as never run
(the store gains replaceMessage for that one repair); and the active unit's
first open call gets an intent unless its approval was pending, rejected or
decided by the replay. A run record without the new fields gets its start
message.

Tests: unit-end.test.ts (six). Architecture 8.1 and 8.7 updated. Vitest
floors: linux 745, win32 748.
2026-10-03 12:30:29 +02:00
feder-cr b92355e19c engine: tool calls run one at a time, record intent first, report interrupted calls
The upstream round ran every call of a response at once and left an
aborted call uncommitted, to run it again after a restart. Three defects
followed: a call after one that needed approval ran before the person
decided; a crash between a tool and its result ran a command, a click or an
approved call a second time; and the approval note was appended before the
result was cut, so a long result lost it.

Now the calls of one response run one at a time, in the model's order, and
a call that needs approval stops the round. Before a call starts, its
intent is committed (thread, assistant message, call index, tool,
permission, decision, attempts); its result, the intent's deletion, the
approval's deletion and tool.called commit together once the call returned,
even if the unit was aborted meanwhile. On every unit entry, an intent whose
call is not executing in this process is a call a crash interrupted: a
replay-safe tool runs once more, anything else (and a replay-safe call that
stopped the agent twice) gets a result saying the outcome is unknown and a
tool.called with interrupted: true and the permission and decision of its
time. Approvals are found by the call's position, not by the provider's id,
which providers reuse across rounds.

Each model request of a step is counted in its own transaction before it is
sent and reset by the assistant commit; the fourth fails the unit, so a
response whose commit kills the process is paid for at most three times. A
request abandoned on purpose does not count.

The registry cuts a result's text once (the loop no longer cuts it again),
and the note follows the cut. The registry's own not-offered check is gone:
the gate is the only one. A rejection reads "Rejected by the user: <note>".

Tests: tool-step.test.ts (eleven, eight of them failing on the previous
engine) with simulated crashes at named points of the commits; the runner
test now checks the order. Architecture 8.3 and 8.4 updated, new 8.7.
Vitest floors: linux 739, win32 742.
2026-10-03 12:26:00 +02:00
feder-cr d30a707f60 engine: an inbound event is recorded and applied in one transaction
accept() wrote the inbox row, applied the event and marked it processed in
three separate statements, and nothing replayed an unprocessed event at
start. A crash between the first two lost an approval decision: the
redelivery was ignored as a duplicate and the Dot waited forever. A
task.created could be lost the same way.

engine/src/dot/inbound.ts now has one apply() for task.created,
approval.received and system.event. accept() runs the inbox insert, apply
and the processed mark in one transaction; what must happen outside the
database (aborting the unit in flight on a cancel, going idle) comes back
as post-commit actions, run only after COMMIT, so a rolled back cancel has
aborted nothing. At start, every unprocessed non-chat inbox row goes
through the same apply, each in its own transaction, which also repairs a
database left by the old code. user.message stays pending until its chat
turn is answered, as before.

A test-only fault seam on the driver's constructor names the points where
a test makes a commit fail; the product never sets it. Tests: accept is all
or nothing; an unprocessed approval.received and task.created are applied
at start; a second decision is ignored; a cancel that rolls back has not
aborted the unit (four fail on the previous driver). Architecture 5.4
states the guarantee. Vitest floors: linux 728, win32 731.
2026-10-03 10:07:21 +02:00
feder-cr 9843fa3548 memory: exclusive lock and migration v2
Nothing stopped a second agent process from opening dot.db and interleaving
its writes with the first one's. DotStore now opens the file in SQLite's
EXCLUSIVE locking mode and takes the write lock at once with an empty
IMMEDIATE transaction: a second opener fails with DotStoreLockedError
("another agent owns <path>"), and the kernel releases the lock the moment
the owner dies, so a restart needs no lease to wait out. No pid, boot id or
per-commit check is needed.

Migration 2 (append-only) adds tool_intents, keyed by (thread, message_id,
call_index) because providers do not guarantee unique call ids across
rounds, and context_summaries; and rebuilds pending_approvals without the
UNIQUE on tool_call_id, with message_id and call_index (NULL on old rows)
and a unique index on the position. The store gains the intent, summary and
approval-position queries the engine needs, and reads a thread after a
given message.

Tests: a second store fails at open; the lock is free as soon as a child
owner process is killed; a version 1 database keeps its approvals; intents
count attempts; summaries bound the read (vitest floors: linux 723, win32
726). Architecture 4.2 states the ownership rule.
2026-10-03 10:04:01 +02:00
feder-cr c38d0b9f5e shared: one truncation function, replay-safety per tool, interrupted calls
Two functions cut tool results with two different markers: capText in the
tools package and truncateToolResult in the model client, and the second
let a result grow past the limit by its own marker. truncateText in the
shared package replaces both: the result never exceeds the limit, the
marker says how much the model does not see, and it keeps the head or, on
request, the head and the tail.

Every tool now declares replaySafe in the shared tool table: running it
twice with the same arguments has the effect of running it once. Reads,
screenshots, memory, identity list/launch/close and files_write (same
content, same path) are replay-safe; commands, navigation, clicks, typing
and identity create/delete are not.

tool.called gains an optional interrupted: true, for a call whose outcome
is unknown because the agent stopped while it ran. The host parses outbound
events with a strict schema that drops unknown keys, so the field has to be
in it to reach the event log.

Architecture 5.4 lists the field; 8.3 gets the replay column. Tests: the
two truncation tests move to the shared package's truncateText tests, plus
the replay-safe table and the parsing of interrupted (vitest floors: linux
718, win32 721).
2026-10-03 06:46:27 +02:00
feder-cr bcb23f9581 engine: run on this repository's client, messages, tools and store
The remaining upstream files now compile and run here, so the engine leaves
the typecheck exclusion:

- the model client is openrouter-client; upstream's provider adapter
  interface is gone;
- one message model: the OpenAI chat shape the client sends and dot.db
  stores, instead of content blocks plus converters;
- no Zod: the tool registry, JSON Schemas and validation are the ones of the
  tools package and the shared tool table; tool/framework.ts is the contract;
- the stores are dot.db, synchronous: the checkpoint is the thread itself,
  one transaction per boundary with its outbox rows; an approval is a
  pending_approvals row decided by one compare-and-set that keeps the note;
  the run record is the unit in flight. The key-value store, its in-memory
  implementation, the run store and utils/abort.ts are deleted;
- everything is ASCII.

The runner keeps upstream's shape (phase derived from what is committed, a
parallel round of tool calls, the optional loop detector, compression of
consumed results and summary strategy). The Dot's own code sits in
engine/src/dot: the state machine moved from agent-runtime on top of the
runner, the system prompt, the request builder and the policy gate as a
pure function. The agent itself still runs on agent-runtime.

Tests: agent-runtime's suite (26), its working-memory tests (4) and the
policy tests (3, now against the gate) run against the engine; one of them
no longer depends on the order in which a parallel round commits its
results. runner.test.ts covers the parallel round, loop detection, the
result markers and the summary. The repository test now also requires ASCII
and imports from node built-ins and this workspace only. Vitest floors rise
by the 39 new tests (linux 716, win32 719).
2026-10-03 06:42:38 +02:00
feder-cr 1d86b77f07 engine: cut what a Dot does not run from the imported files
The imported engine carries a multi-agent framework around the one loop a
Dot needs. This removes streaming, tracing, the run journal, delegation and
teams, the shell and workspace of upstream's built-in tools, structured
output, external backends, provider-specific options, tool grants, the
batch executor, and the sliding-window and rule-based compaction
strategies, with the functions modelled on context-chef (groupIntoTurns,
stripMediaBlocksForSummary).

Seven whole files go too: the four orchestrator files (teams, plans,
routing, retries and parallel scheduling over upstream's agent pool and
scheduler, none of which was imported), the in-memory task graph of
task/queue.ts and task/task.ts (a Dot's task queue stays the one persisted
in dot.db), and agent/agent.ts, the agent object built on the provider
adapters. They cannot run without upstream modules that were never
imported, and the Dot has no use for them.

types.ts keeps only what the remaining files reach. The engine is still
excluded from the typecheck; its adaptation follows. UPSTREAM.md lists the
cuts; the context-chef notice stays because the history keeps that text.
The repository test now also checks that none of it is left in the engine
(vitest floors: linux 677, win32 680).
2026-10-03 06:33:42 +02:00
feder-cr 93b0275da8 engine: mark every imported file as derived from Open Multi-Agent
Each of the 23 files imported from upstream now starts with a three-line
header naming Open Multi-Agent, its MIT license and its copyright holders,
and pointing to guest-runtime/engine/LICENSE and UPSTREAM.md. The header
carries no version or commit: UPSTREAM.md is the one place for those.

tests/repo/vendored-engine.test.ts now also requires the header on every
file under engine/src outside src/dot/, and forbids it inside src/dot/,
which is where this repository's own engine code goes. The notices and
architecture section 11.3 name the header as what marks a derived file.
Vitest floors rise by the new test (linux 676, win32 679).
2026-10-03 06:26:29 +02:00
feder-cr cb5e9def83 engine: import Open Multi-Agent's engine files as published
The Dot's agent engine is derived from Open Multi-Agent (MIT). This commit
imports, byte for byte from the git objects of upstream commit 3563a93
(@open-multi-agent/core 1.21.1), only the 23 source files the engine is
built from, plus upstream's LICENSE. Every blob id matches upstream's and is
listed in guest-runtime/engine/UPSTREAM.md, with the version, the commit and
the tree ids.

The files do not compile against this repository yet, so the root
typecheck excludes guest-runtime/engine/src until they are adapted. The
English gate covers them already.

Attribution: guest-runtime/engine/LICENSE is upstream's file (kept -text in
.gitattributes); THIRD_PARTY_NOTICES.md at the root carries it verbatim,
plus context-chef's MIT notice, because runner.ts as published contains two
functions modelled on @context-chef/core 4.2.1. The README licence line and
architecture section 11.3 point there; section 2 lists the new package.

tests/repo/vendored-engine.test.ts checks the license bytes, the notices,
UPSTREAM.md and that the package has no npm dependency. The vitest floors
rise by its 4 tests (linux 675, win32 678).
2026-10-03 06:25:29 +02:00
feder-cr 7f7e88bfe0 QEMU direct on Linux and Windows, PGlite, no QMP, review fixes, real-VM e2e 2026-10-03 06:13:00 +02:00
feder-cr 43390f6699 wip: invisible_dots first integration (libvirt design, before the QEMU conversion) 2026-10-02 16:31:09 +02:00
feder-cr 2ff9848c1c README: the title as the first element, so nothing pushes it down 2026-09-30 01:24:00 +02:00
968 changed files with 164647 additions and 307 deletions

No files matched your search

+29 -4
View File
@@ -1,5 +1,30 @@
# The pre-push hook is a shell script, and git must never hand it to a POSIX
# shell with CRLF line endings: `/bin/sh^M: bad interpreter` is how a gate stops
# running on a machine where nothing else looks wrong. Git-for-Windows' sh is
# the one that runs it on a Windows clone with core.autocrlf=true.
# Everything text is stored and checked out with LF. Shell scripts, systemd
# units, cloud-init templates and the pre-push hook all end up on Linux, where a
# CRLF turns `#!/bin/sh` into `/bin/sh^M: bad interpreter` and a unit file into
# one systemd silently misreads. Git-for-Windows with core.autocrlf=true would
# otherwise convert them on checkout.
* text=auto eol=lf
.githooks/** text eol=lf
*.sh text eol=lf
*.service text eol=lf
*.timer text eol=lf
*.socket text eol=lf
*.target text eol=lf
*.env text eol=lf
# Guest files the image builder copies verbatim onto the runtime ISO or into
# the cloud-init seed, and the Go sources whose gofmt check rejects a CR.
*.tmpl text eol=lf
*.yaml text eol=lf
*.go text eol=lf
*.sql text eol=lf
guest/image-builder/** text eol=lf
*.png binary
*.jpg binary
*.ico binary
*.iso binary
*.img binary
*.qcow2 binary
*.woff binary
*.woff2 binary
+126 -30
View File
@@ -1,21 +1,22 @@
#!/bin/sh
# Pre-push gate. THE POLICY IS NOT IN THIS FILE - it is `invisible_core.hooks`,
# and this stub exists only to find an interpreter and hand over stdin.
# Pre-push gate. Two halves:
#
# It used to be here, in three repositories, as 743 lines of shell that were 207
# identical lines out of 211 between two of them. Nine of the eleven differences
# were accidental: the core ran no pytest, the core set a variable three times
# that only the other two ever read, the core resolved an interpreter and then
# called bare `python` anyway. Every fix landed in whichever copy was open.
# 1. This repository's own suites, which the shared policy knows nothing
# about: the typecheck and vitest over the TypeScript workspace, and go
# test over dot-agentd, each counted against .github/test-floors.json.
# 2. The shared policy, `invisible_core.hooks` from the pinned invisible-core
# (.github/gates-requirements.txt): English, identity and the other checks
# declared in pyproject.toml under [tool.invisible.hooks]. That pyproject
# is configuration only; nothing here is a Python package.
#
# What this repository wants gated is declared in its own pyproject, under
# [tool.invisible.hooks]. That is the only per-repo part, and it is data.
# CI alone runs the rest of tests.yml: the engine's pytest (Linux only, see
# invisible_engine_dots), the PostgreSQL half of the pg suites, the web
# client's Playwright tests, the two smokes and the claims gate.
#
# Install once with:
# git config core.hooksPath .githooks
# A test in this repo fails in an uninstalled checkout, so nobody has to
# remember. Bypass a known-broken WIP push with `git push --no-verify`; that is
# the only escape hatch, and never for a branch that feeds a release.
# Bypass a known-broken WIP push with `git push --no-verify`; that is the only
# escape hatch, and never for a branch that feeds a release.
# No `set -e`: exit codes here have to be READ and explained, not turned into a
# silent abort. A gate that dies without a message is the worst shape of all.
@@ -23,33 +24,128 @@
# Run from the repository root regardless of where `git push` was invoked.
cd "$(dirname "$0")/.." || exit 1
refuse() {
for line in "$@"; do
echo "[pre-push] $line" >&2
done
exit 1
}
# git runs a hook with GIT_DIR, GIT_INDEX_FILE and friends pointing at this
# repository. A test that creates its own scratch repository would then
# address this one instead. Each suite runs in a subshell without them, and
# with stdin from /dev/null, because stdin is the list of refs being pushed
# and belongs to the policy in step 2.
without_git_env() {
(
for name in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do
unset "$name"
done
"$@"
) </dev/null
}
# 1a. The TypeScript workspace. node_modules must exist: installing is a
# decision for the person, not something a hook does behind their back.
if ! command -v npm >/dev/null 2>&1; then
refuse "REFUSED - npm is not on PATH, so the TypeScript suites cannot run." \
"Install Node 24 and run 'npm ci' once, then push again."
fi
if [ ! -d node_modules ]; then
refuse "REFUSED - node_modules is missing. Run 'npm ci' once, then push again."
fi
# The same three gates as CI's node job: types first (vitest strips them, so
# a type error passes every test), then vitest with its report, then the
# guard that counts what ran against .github/test-floors.json, because a run
# that collected too few tests or skipped some still exits 0.
mkdir -p tmp
echo "[pre-push] npm run typecheck"
without_git_env npm run typecheck
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - npm run typecheck exited with $status. Nothing is pushed past a type error."
fi
echo "[pre-push] vitest"
without_git_env npx vitest run --reporter=default --reporter=json --outputFile=tmp/vitest-report.json
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - vitest exited with $status. Nothing is pushed past a red suite."
fi
without_git_env node .github/scripts/test-guard.mjs tmp/vitest-report.json --suite vitest
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - the vitest run did not reach this host's floor in .github/test-floors.json (see above)."
fi
# 1b. dot-agentd. Without Go its tests cannot run here, and a push that no
# one tested is refused rather than waved through on the strength of CI.
if ! command -v go >/dev/null 2>&1; then
refuse "REFUSED - go is not on PATH, so the dot-agentd tests cannot run." \
"Install Go (1.25 or newer, see guest/dot-agentd/go.mod) and push again."
fi
# The same checks, in the same order, as the go job in tests.yml: a file gofmt
# would rewrite, or code go vet rejects, is refused here instead of in CI.
echo "[pre-push] gofmt and go vet (guest/dot-agentd)"
unformatted=$(cd guest/dot-agentd && gofmt -l .)
if [ -n "$unformatted" ]; then
refuse "REFUSED - gofmt would rewrite these dot-agentd files:" "$unformatted" \
"Run gofmt -w on them and push again."
fi
(cd guest/dot-agentd && without_git_env go vet ./... && without_git_env env GOOS=windows go vet ./...)
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - go vet exited with $status. Nothing is pushed past a vet error."
fi
echo "[pre-push] go test ./... (guest/dot-agentd)"
(cd guest/dot-agentd && without_git_env go test -json -count=1 ./... > ../../tmp/go-test.json)
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - go test exited with $status (its events are in tmp/go-test.json). Nothing is pushed past a red suite."
fi
without_git_env node .github/scripts/test-guard.mjs tmp/go-test.json --suite go
status=$?
if [ "$status" -ne 0 ]; then
refuse "REFUSED - the go test run did not reach this host's floor in .github/test-floors.json (see above)."
fi
# 2. The shared policy. The interpreter is, in order: INVISIBLE_GATE_PYTHON,
# the virtual environment under tmp/ that the command below creates, then a
# python on PATH. The venv layout differs between Windows (Scripts/) and the
# rest (bin/); both are looked for, so no platform check is needed.
python_bin=""
for candidate in "$INVISIBLE_GATE_PYTHON" python3 python py; do
for candidate in "$INVISIBLE_GATE_PYTHON" tmp/gates-venv/bin/python tmp/gates-venv/Scripts/python.exe python3 python py; do
[ -n "$candidate" ] || continue
if command -v "$candidate" >/dev/null 2>&1; then
if [ -x "$candidate" ] || command -v "$candidate" >/dev/null 2>&1; then
python_bin="$candidate"
break
fi
done
if [ -z "$python_bin" ]; then
echo "[pre-push] REFUSED - no python on PATH, so no gate can run." >&2
echo "[pre-push] Nothing must be pushed past a hook that could not check it." >&2
echo "[pre-push] Set INVISIBLE_GATE_PYTHON to an interpreter and push again." >&2
exit 1
refuse "REFUSED - no python on PATH, so the shared gates cannot run." \
"Nothing must be pushed past a hook that could not check it." \
"Set INVISIBLE_GATE_PYTHON to an interpreter and push again."
fi
# `src` first so a checkout gates itself with the policy in the tree being
# pushed. os.getcwd() rather than $PWD on purpose: under git-bash on Windows
# $PWD is /c/... and Python does not resolve that mount.
# exec, so stdin (the refs git is pushing) reaches the policy untouched.
exec "$python_bin" -c 'import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "src"))
pin=$(sed -n 's/^invisible-core==\([0-9.]*\).*/\1/p' .github/gates-requirements.txt)
if [ -z "$pin" ]; then
refuse "REFUSED - .github/gates-requirements.txt does not pin invisible-core exactly."
fi
# exec, so stdin (the refs git is pushing) reaches the policy untouched. The
# installed invisible-core must be exactly the pinned one: CI runs that
# version, and a different policy locally would pass what CI refuses.
exec "$python_bin" -c 'import sys
pin = sys.argv[1]
setup = ("python -m venv tmp/gates-venv, then tmp/gates-venv/bin/python (or "
"tmp\\gates-venv\\Scripts\\python.exe) -m pip install -r .github/gates-requirements.txt")
try:
from importlib.metadata import PackageNotFoundError, version
installed = version("invisible-core")
from invisible_core.hooks import main
except ImportError as exc:
except (ImportError, PackageNotFoundError) as exc:
sys.exit("[pre-push] REFUSED - cannot import invisible_core.hooks (%s).\n"
"[pre-push] The pre-push policy lives in invisible-core. This repo is\n"
"[pre-push] either that package (so src/ is not where it should be) or\n"
"[pre-push] pins it exactly (so install it). Either way, nothing is\n"
"[pre-push] pushed past a hook that could not check it." % exc)
sys.exit(main())'
"[pre-push] Create the gates environment once: %s" % (exc, setup))
if installed != pin:
sys.exit("[pre-push] REFUSED - invisible-core %s is installed, the repository pins %s.\n"
"[pre-push] Reinstall: %s" % (installed, pin, setup))
sys.exit(main())' "$pin"
+61
View File
@@ -0,0 +1,61 @@
name: Setup failure
description: "setup, doctor, the images or the server fail before you can talk to a Dot"
title: "[setup] "
labels: ["bug", "setup"]
body:
- type: markdown
attributes:
value: |
Use this when the host never gets to a working Dot. If a Dot runs but does something wrong, use the bug report.
The guide's [troubleshooting](https://github.com/feder-cr/invisible_dots/blob/main/docs/guide.md#troubleshooting) covers the common failures.
- type: dropdown
id: os
attributes:
label: Host
options:
- Windows 11 (Windows Hypervisor Platform)
- Windows 10
- Ubuntu 24.04 (KVM)
- Other Linux
- Other
validations:
required: true
- type: input
id: commit
attributes:
label: Commit
description: Output of `git rev-parse --short HEAD`.
validations:
required: true
- type: textarea
id: doctor
attributes:
label: invisible-dots doctor
description: The full output of `node apps/cli/dist/invisible-dots.mjs doctor`.
render: text
validations:
required: true
- type: textarea
id: what
attributes:
label: What you ran and what happened
description: The command, and its output verbatim. Remove keys and personal paths.
render: text
validations:
required: true
- type: checkboxes
id: confirm
attributes:
label: Before submitting
options:
- label: Searched existing issues.
required: true
- label: On the latest `main`.
required: true
- label: Removed keys, tokens and personal paths.
required: true
+70
View File
@@ -0,0 +1,70 @@
name: Bug report
description: A Dot, the web UI, the command line, the API or a channel does something wrong
title: "[bug] "
labels: ["bug"]
body:
- type: textarea
id: what
attributes:
label: What happened
description: What you asked or did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to reproduce
description: The message or task you gave the Dot, or the steps in the UI or the command line.
validations:
required: true
- type: dropdown
id: where
attributes:
label: Where
multiple: true
options:
- Web UI
- Command line
- HTTP API
- Telegram
- WhatsApp
- The Dot's computer or browser
validations:
required: true
- type: input
id: commit
attributes:
label: Commit and host
placeholder: "a0bc3a64, Windows 11"
validations:
required: true
- type: input
id: model
attributes:
label: Model
placeholder: z-ai/glm-5.3-flash
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs
description: "`invisible-dots logs <dot> --tail 200 --no-follow`, and the server's output around the failure. Remove keys and personal data."
render: text
validations:
required: false
- type: checkboxes
id: confirm
attributes:
label: Before submitting
options:
- label: Searched existing issues.
required: true
- label: Removed keys, tokens and personal data.
required: true
+28
View File
@@ -0,0 +1,28 @@
name: Feature request
description: Something a Dot, the web UI or the command line should do
title: "[feature] "
labels: ["enhancement"]
body:
- type: textarea
id: need
attributes:
label: What you want to do
description: The task or the situation, in your words. What a Dot should be able to do, not how to build it.
validations:
required: true
- type: textarea
id: today
attributes:
label: What happens today
description: What you tried, and where it stops.
validations:
required: false
- type: textarea
id: idea
attributes:
label: An idea of how
description: Optional.
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security issue
url: https://github.com/feder-cr/invisible_dots/blob/main/SECURITY.md
about: Do not open a public issue; report it privately as SECURITY.md says.
- name: A site detects or blocks the browser
url: https://github.com/feder-cr/invisible_playwright/issues
about: The Dot's browser is invisible_playwright; its issues are the place for that.
+17
View File
@@ -0,0 +1,17 @@
## What changes
<!-- One or two sentences: what is now true, and why. -->
## Related issues
<!-- "Closes #123" -->
## How it was tested
<!-- npm run typecheck, npm test, go test, pytest, the smokes, a real Dot: what you ran. -->
## Checklist
- [ ] One logical change; tests added or updated for any change of behaviour.
- [ ] The pre-push gate passed (`git config core.hooksPath .githooks`).
- [ ] The README or `docs/` updated if what a person sees changed.
+1
View File
@@ -0,0 +1 @@
invisible-core==34.31.0
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
// Puts the golden image `image build --compress` made into the shape of a release (guest/image-builder/src/prebuilt.ts):
// its manifest, the image cut in parts under the 2 GiB a release asset may hold, and release.json naming each part with
// its size and SHA-256. Prints the tag, golden-<inputs digest>.
//
// node .github/scripts/golden-release.mjs <images directory> <output directory>
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { copyFile, mkdir, open, readdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
const PART_BYTES = 1900 * 1024 * 1024;
const [imagesDir, outDir] = process.argv.slice(2);
if (!imagesDir || !outDir) {
console.error("usage: golden-release.mjs <images directory> <output directory>");
process.exit(2);
}
const manifests = (await readdir(imagesDir)).filter((name) => /^golden-.+\.json$/.test(name));
if (manifests.length !== 1) {
console.error(`expected one golden manifest in ${imagesDir}, found ${manifests.length}`);
process.exit(1);
}
const manifestName = manifests[0];
const manifest = JSON.parse(await readFile(join(imagesDir, manifestName), "utf8"));
const image = join(imagesDir, manifest.file);
await mkdir(outDir, { recursive: true });
await copyFile(join(imagesDir, manifestName), join(outDir, manifestName));
const parts = [];
let out;
let hash;
let written = 0;
const whole = createHash("sha256");
async function closePart() {
if (!out) return;
await out.close();
parts[parts.length - 1].size = written;
parts[parts.length - 1].sha256 = hash.digest("hex");
out = undefined;
}
for await (let chunk of createReadStream(image, { highWaterMark: 8 * 1024 * 1024 })) {
whole.update(chunk);
while (chunk.length > 0) {
if (!out) {
const name = `${basename(manifest.file)}.part${String(parts.length).padStart(2, "0")}`;
parts.push({ name, size: 0, sha256: "" });
out = await open(join(outDir, name), "w");
hash = createHash("sha256");
written = 0;
}
const take = chunk.subarray(0, PART_BYTES - written);
await out.write(take);
hash.update(take);
written += take.length;
chunk = chunk.subarray(take.length);
if (written === PART_BYTES) await closePart();
}
}
await closePart();
const sha256 = whole.digest("hex");
if (sha256 !== manifest.sha256) {
console.error(`${image} hashes to ${sha256}, its manifest says ${manifest.sha256}`);
process.exit(1);
}
await writeFile(join(outDir, "release.json"), `${JSON.stringify({ inputs_digest: manifest.inputs_digest, manifest: manifestName, parts }, null, 2)}\n`);
console.log(`golden-${manifest.inputs_digest}`);
+193
View File
@@ -0,0 +1,193 @@
// Reads a test run's report and refuses a run that only looks green.
//
// vitest, `go test` and pytest exit 0 when nothing failed, which is also what they do
// when a whole file stopped being collected, when a suite skipped itself
// because a tool or a database was missing, or when a build constraint left
// a file out. Each of those reads exactly like a pass. So the count of tests
// that really ran is checked against a floor, and every skipped test must be
// one this host is known to skip.
//
// node .github/scripts/test-guard.mjs <report> --suite <name> [--floors <file>]
//
// <report> vitest's JSON report (--reporter=json --outputFile) for the
// vitest suites, the output of `go test -json` for "go", and a
// JUnit XML report (a file ending in .xml: pytest --junitxml for
// the Python engine, Playwright's junit reporter for the web
// client's browser tests).
// --suite the entry of the floors file to apply: vitest, postgres, whatsapp,
// go, pytest or playwright.
// --floors default .github/test-floors.json. The entry is chosen by the
// host this runs on (linux, win32), so CI and the pre-push hook
// read the same numbers from the same file.
//
// A floor entry: min_passed, allow_skipped (a regex over a skipped test's
// full name, "" for none), and require ({ regex: n }: at least n passed
// tests whose full name matches, e.g. the suites parametrized on pg).
//
// Raise the floors when tests are added; lowering one is a decision to write
// down in the commit that does it.
import { readFileSync } from "node:fs";
function fail(message) {
console.error(`test-guard: REFUSED - ${message}`);
process.exit(1);
}
const args = process.argv.slice(2);
const reportPath = args.shift();
const usage = "usage: test-guard.mjs <report> --suite <vitest|postgres|whatsapp|go|pytest|playwright> [--floors <file>]";
if (!reportPath) fail(usage);
let suite;
let floorsPath = ".github/test-floors.json";
while (args.length > 0) {
const flag = args.shift();
const value = args.shift();
if (value === undefined) fail(`${flag} needs a value`);
if (flag === "--suite") suite = value;
else if (flag === "--floors") floorsPath = value;
else fail(`unknown option ${flag}; ${usage}`);
}
if (!suite) fail(usage);
let floors;
try {
floors = JSON.parse(readFileSync(floorsPath, "utf8"));
} catch (error) {
fail(`cannot read ${floorsPath}: ${error.message}`);
}
// The host this runs on picks the floor, because each host runs a different
// set (a test that needs a real Windows PowerShell, a Go file built only on
// unix). This script is a check of the test run, not product code.
const host = process.platform;
const floor = floors[suite]?.[host];
if (!floor) fail(`${floorsPath} has no "${suite}" floor for ${host}`);
const minPassed = floor.min_passed;
if (!Number.isInteger(minPassed) || minPassed <= 0) fail(`${floorsPath} ${suite}.${host}.min_passed must be a positive integer`);
const allowSkipped = floor.allow_skipped ? new RegExp(floor.allow_skipped) : undefined;
const required = Object.entries(floor.require ?? {}).map(([pattern, min]) => ({ pattern: new RegExp(pattern), min }));
let text;
try {
text = readFileSync(reportPath, "utf8");
} catch (error) {
fail(`cannot read ${reportPath}: ${error.message}`);
}
/** Every test of the run as { fullName, status, file }, status passed | failed | skipped. */
function vitestTests() {
let report;
try {
report = JSON.parse(text);
} catch (error) {
fail(`${reportPath} is not vitest's JSON report (${error.message}); run vitest with --reporter=json --outputFile`);
}
const tests = report.testResults.flatMap((file) =>
file.assertionResults.map((test) => ({
fullName: test.fullName,
status: test.status === "passed" || test.status === "failed" ? test.status : "skipped",
file: file.name,
})),
);
// A file that failed to load has no failed test of its own, only an error.
const broken = report.testResults
.filter((file) => file.status === "failed" && file.assertionResults.every((test) => test.status !== "failed"))
.map((file) => `${file.name} failed outside any test: ${(file.message ?? "").split("\n")[0]}`);
return { tests, broken, files: report.testResults.length };
}
/** The text an XML attribute value stands for. */
function xmlText(value) {
return value
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
/** The attributes of one start tag, as an object. */
function xmlAttributes(tag) {
const attributes = {};
for (const match of tag.matchAll(/([\w:.-]+)="([^"]*)"/g)) attributes[match[1]] = xmlText(match[2]);
return attributes;
}
/**
* JUnit XML, the report of pytest --junitxml: one testcase element per test.
* A testcase holding a failure or an error element failed (a module that could
* not be collected is reported as one), one holding a skipped element was
* skipped, and any other passed. The name a skip is allowed by is
* "<classname> <name>".
*/
function junitTests() {
if (!/<testsuites?[\s>]/.test(text)) fail(`${reportPath} is not a JUnit XML report; run pytest with --junitxml or Playwright with the junit reporter`);
const tests = [];
const classes = new Set();
for (const match of text.matchAll(/<testcase\b([^>]*?)(\/?)>/g)) {
const attributes = xmlAttributes(match[1]);
let body = "";
if (match[2] !== "/") {
const end = text.indexOf("</testcase>", match.index);
body = end === -1 ? "" : text.slice(match.index + match[0].length, end);
}
const status = /<(failure|error)\b/.test(body) ? "failed" : /<skipped\b/.test(body) ? "skipped" : "passed";
const classname = attributes.classname ?? "";
classes.add(classname);
tests.push({ fullName: `${classname} ${attributes.name ?? ""}`.trim(), status, file: classname });
}
return { tests, broken: [], files: classes.size };
}
function goTests() {
const tests = [];
const broken = [];
const packages = new Set();
for (const line of text.split("\n")) {
if (!line.startsWith("{")) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event.Package) packages.add(event.Package);
const action = event.Action;
if (action !== "pass" && action !== "fail" && action !== "skip") continue;
const pkg = event.Package.split("/").pop();
// A package without a test name: its own result, which fails on a build error.
if (!event.Test) {
if (action === "fail") broken.push(`package ${event.Package} failed (a build error or a panic outside a test)`);
continue;
}
tests.push({ fullName: `${pkg} ${event.Test}`, status: action === "pass" ? "passed" : action === "fail" ? "failed" : "skipped", file: event.Package });
}
if (packages.size === 0) fail(`${reportPath} holds no go test -json events`);
return { tests, broken, files: packages.size };
}
const { tests, broken, files } = reportPath.endsWith(".xml") ? junitTests() : suite === "go" ? goTests() : vitestTests();
const passed = tests.filter((test) => test.status === "passed");
const failed = tests.filter((test) => test.status === "failed");
const skipped = tests.filter((test) => test.status === "skipped");
console.log(`test-guard: ${suite} on ${host}: ${passed.length} passed, ${failed.length} failed, ${skipped.length} skipped, in ${files} files`);
const problems = [...broken];
if (failed.length > 0) problems.push(`${failed.length} failed tests`);
if (passed.length < minPassed) problems.push(`only ${passed.length} tests passed, the floor is ${minPassed}`);
for (const test of skipped) {
if (!allowSkipped || !allowSkipped.test(test.fullName)) problems.push(`skipped without being allowed to: ${test.fullName} (${test.file})`);
}
for (const { pattern, min } of required) {
const count = passed.filter((test) => pattern.test(test.fullName)).length;
console.log(`test-guard: ${count} passed tests match ${pattern}`);
if (count < min) problems.push(`${count} passed tests match ${pattern}, at least ${min} must`);
}
if (problems.length > 0) {
for (const problem of problems) console.error(`test-guard: ${problem}`);
fail(`${problems.length} problem(s) with this run`);
}
console.log("test-guard: ok");
+25
View File
@@ -0,0 +1,25 @@
{
"//": "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": 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 } }
},
"whatsapp": {
"linux": { "min_passed": 25, "allow_skipped": "", "require": { "\\bpg\\b": 9 } },
"win32": { "min_passed": 16, "allow_skipped": "" }
},
"go": {
"linux": { "min_passed": 120, "allow_skipped": "" },
"win32": { "min_passed": 70, "allow_skipped": "^agentd (TestSystemRoute|TestPowerOffStartsTheCommandAndAnswers202)$" }
},
"pytest": {
"linux": { "min_passed": 2493, "allow_skipped": "" }
},
"playwright": {
"linux": { "min_passed": 88, "allow_skipped": "" },
"win32": { "min_passed": 88, "allow_skipped": "" }
}
}
+76
View File
@@ -0,0 +1,76 @@
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 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:
contents: write
concurrency:
group: golden-image
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: build and publish the golden image
runs-on: ubuntu-latest
timeout-minutes: 180
env:
INVISIBLE_DOTS_HOME: ${{ github.workspace }}/../idots-home
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- uses: actions/setup-go@v6
with:
go-version-file: guest/dot-agentd/go.mod
- name: KVM for the builder VM, and QEMU
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils
- run: npm ci --no-audit --no-fund
- run: npm run build --workspace @invisible-dots/cli
# The runtime ISO, which `image build` makes first, carries dot-agentd.
- working-directory: guest/dot-agentd
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -ldflags="-s -w" -o bin/dot-agentd ./cmd/dot-agentd
- name: the image (downloaded instead when its release exists already)
id: build
run: |
node apps/cli/dist/invisible-dots.mjs image build --compress | tee build.log
tag=$(node .github/scripts/golden-release.mjs "$INVISIBLE_DOTS_HOME/images" "$RUNNER_TEMP/release")
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: publish
run: |
tag='${{ steps.build.outputs.tag }}'
if gh release view "$tag" >/dev/null 2>&1; then
echo "$tag is published already"
exit 0
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, 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)."
+82
View File
@@ -0,0 +1,82 @@
# Publishes docs/wiki/ to the GitHub wiki of this repository on every push to main that changes it.
#
# The wiki is its own git repository (<repo>.wiki.git): flat pages, no front matter, a _Sidebar.md. The render is
# invisible_playwright_mcp's own (scripts/build_wiki.py and scripts/sync_article_pixels.py, MIT, fetched at a fixed
# commit rather than copied): it strips the front matter, rewrites [x](page.md) links to the wiki's form, names
# index.md Home, builds the sidebar from parent/nav_order, and gives every page its view pixel (a release asset of
# the `article-views` release, created on first use). The wiki checkout is emptied of pages before the copy, so a
# page deleted from docs/wiki/ leaves the wiki too.
name: publish-wiki
on:
push:
branches: [main]
paths:
- "docs/wiki/**"
- ".github/workflows/publish-wiki.yml"
workflow_dispatch: {}
permissions:
contents: write
concurrency:
group: publish-wiki
cancel-in-progress: true
env:
# invisible_playwright_mcp's main on 2026-10-09: the converter this wiki is rendered with.
CONVERTER_REPO: feder-cr/invisible_playwright_mcp
CONVERTER_REF: b72e8b1937771149ed06b2ba73ccb9cb32a687f8
jobs:
publish-wiki:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Fetch the converter
run: |
set -euo pipefail
mkdir -p wiki_tools
for script in build_wiki.py sync_article_pixels.py; do
curl -fsSL -o "wiki_tools/$script" \
"https://raw.githubusercontent.com/${CONVERTER_REPO}/${CONVERTER_REF}/scripts/$script"
done
- name: Render docs/wiki/ into wiki pages
run: python wiki_tools/build_wiki.py docs/wiki wiki_build --pixels "${{ github.repository }}"
# Upload before the push: a page published without its asset counts nothing until the next run.
- name: Give every new page its view pixel
env:
GITHUB_TOKEN: ${{ github.token }}
run: python wiki_tools/sync_article_pixels.py --repo "${{ github.repository }}" wiki_build
- name: Check every page carries its own pixel
run: |
python wiki_tools/sync_article_pixels.py --selftest
python wiki_tools/sync_article_pixels.py --repo "${{ github.repository }}" --check wiki_build
- name: Publish to the wiki
env:
WIKI_PUSH_TOKEN: ${{ secrets.WIKI_TOKEN != '' && secrets.WIKI_TOKEN || github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
git clone "https://x-access-token:${WIKI_PUSH_TOKEN}@github.com/${repo}.wiki.git" wiki
find wiki -maxdepth 1 -name '*.md' -delete
cp wiki_build/*.md wiki/
cd wiki
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "wiki already up to date"
else
git commit -m "docs: sync wiki from docs/wiki (${{ github.ref_name }})"
git push origin HEAD:master
fi
+279 -62
View File
@@ -14,16 +14,268 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# `gate` is the ONLY context the branch protection requires: it depends on every
# job, so its name never drifts when the matrix changes. Unlike its siblings it
# accepts nothing but success, because no job here is ever skipped on purpose:
# a skip would mean a job that did not run, which reads exactly like one that
# passed.
defaults:
run:
shell: bash
# `gate` is the ONLY context the branch protection requires: it depends on every
# job, so its name never drifts when the jobs change. It accepts nothing but
# success, because no job here is ever skipped on purpose and none has a paths
# filter: a skip would mean a job that did not run, which reads exactly like
# one that passed.
jobs:
node:
name: typecheck, vitest and the bundle (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# The floors each host must reach live in .github/test-floors.json,
# which the pre-push hook reads too.
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
# Linux's vitest builds the real dot-agentd and drives it with the host's GuestClient
# (apps/vm-manager/test/agentd.linux.test.ts, which vitest.config.ts collects on Linux only).
- if: runner.os == 'Linux'
uses: actions/setup-go@v6
with:
go-version-file: guest/dot-agentd/go.mod
# The module has no dependencies, so there is no go.sum to key a cache on.
cache: false
- run: npm ci --no-audit --no-fund
- run: npm run typecheck
- name: the pre-push hook parses
run: sh -n .githooks/pre-push
- name: vitest
run: npx vitest run --reporter=default --reporter=json --outputFile=vitest-report.json
# vitest's exit code cannot tell "everything passed" from "half of it
# was never collected" or "a suite skipped itself": count what ran.
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node .github/scripts/test-guard.mjs vitest-report.json --suite vitest
- name: bundle (the command, which carries the server)
run: npm run build --workspace @invisible-dots/cli
# The bundled command on a fresh home: doctor must run end to end and
# create nothing. Its exit code is not 0 here (no QEMU, no images), so
# only "it ran and printed every check" is asserted.
- name: the bundled doctor runs and creates nothing
env:
INVISIBLE_DOTS_HOME: ${{ runner.temp }}/idots-home
run: |
set +e
node apps/cli/dist/invisible-dots.mjs doctor > doctor.txt 2>&1
code=$?
set -e
cat doctor.txt
echo "doctor exit code: $code"
grep -q "checks:" doctor.txt
test ! -e "$INVISIBLE_DOTS_HOME"
postgres:
name: the pg adapter against PostgreSQL
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18.4-alpine3.23
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 5s
--health-retries 30
env:
# With DATABASE_URL set, every suite parametrized with testAdapters()
# runs on pg as well as on PGlite.
DATABASE_URL: postgres://postgres:[email protected]:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci --no-audit --no-fund
- name: vitest on both adapters
run: npx vitest run packages/database packages/events packages/channels apps/api apps/scheduler --reporter=default --reporter=json --outputFile=vitest-report.json
- name: refuse a run in which the pg suites did not run
if: always()
run: node .github/scripts/test-guard.mjs vitest-report.json --suite postgres
# The WhatsApp client (Baileys) is an opt-in install: it depends on libsignal, which is GPL-3.0, and the default
# npm install holds nothing GPL. The jobs above never install it. This one does, with the command a person runs
# (npm run whatsapp:install), to prove the adapter against the real library: the types (the real module has the
# shape the adapter writes down) and the tests that need it, on PGlite and on PostgreSQL.
whatsapp-client:
name: the WhatsApp client, opt-in (types and tests on the real library)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18.4-alpine3.23
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 5s
--health-retries 30
env:
DATABASE_URL: postgres://postgres:[email protected]:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci --no-audit --no-fund
- name: install the client, as a person who enables WhatsApp does
run: npm run whatsapp:install
- name: the real module has the shape the adapter writes down
run: npm run typecheck:whatsapp
- name: vitest with the real client
run: npm run test:whatsapp -- --reporter=default --reporter=json --outputFile=vitest-report.json
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node .github/scripts/test-guard.mjs vitest-report.json --suite whatsapp
iso:
name: the Linux kernel reads our ISO images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci --no-audit --no-fund
# The unit tests read images back with our own parser; this is the
# reader a guest really uses. See packages/iso/scripts/kernel-check.ts.
- name: write, mount read-only, compare
run: |
npx tsx packages/iso/scripts/kernel-check.ts write "$RUNNER_TEMP/sample.iso"
blkid -p -o export "$RUNNER_TEMP/sample.iso" | tee blkid.txt
grep -qx "TYPE=iso9660" blkid.txt
grep -qx "LABEL=cidata" blkid.txt
mkdir -p "$RUNNER_TEMP/mnt"
sudo mount -o loop,ro "$RUNNER_TEMP/sample.iso" "$RUNNER_TEMP/mnt"
npx tsx packages/iso/scripts/kernel-check.ts verify "$RUNNER_TEMP/mnt"
sudo umount "$RUNNER_TEMP/mnt"
go:
name: dot-agentd (gofmt, go vet, go test, static build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: guest/dot-agentd
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
with:
go-version-file: guest/dot-agentd/go.mod
# The module has no dependencies, so there is no go.sum to key a cache on.
cache: false
- run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- run: go vet ./...
- run: GOOS=windows go vet ./...
# go test exits 0 when a test skipped itself or a build constraint left
# a file out, so what ran is counted against the same floors file.
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: go test -json -count=1 ./... > go-test.json
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node ../../.github/scripts/test-guard.mjs go-test.json --suite go --floors ../../.github/test-floors.json
- run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -ldflags="-s -w" -o bin/dot-agentd ./cmd/dot-agentd
- run: file bin/dot-agentd | tee /dev/stderr | grep -q "ELF 64-bit LSB executable, x86-64.*statically linked"
engine:
name: the engine (pytest)
runs-on: ubuntu-latest
defaults:
run:
working-directory: invisible_engine_dots
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install -e ".[dev]"
- run: pytest -q --junitxml=pytest-report.xml
# pytest exits 0 when a module stopped being collected or a test skipped
# itself, so what ran is counted against the same floors file.
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node ../.github/scripts/test-guard.mjs pytest-report.xml --suite pytest --floors ../.github/test-floors.json
smoke:
name: the engine smoke (dot-agentd and the engine under their two users)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Builds dot-agentd in golang:1.26.8, the engine's environment in ubuntu:24.04
# the way provision.sh builds it, and runs the Dot. It exits non-zero on a
# failed or a skipped check and prints "SMOKE: N passed, F failed, S skipped".
- name: run the smoke on this checkout
run: bash guest/image-builder/test/smoke/run.sh
browser-smoke:
name: the browser smoke (the real invisible-playwright-mcp and its Firefox, as the Dot runs them)
runs-on: ubuntu-latest
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 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.
- name: run the browser smoke on this checkout
run: bash guest/image-builder/test/smoke/run.sh --suite browser
web:
name: the web client builds and passes its browser tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci --no-audit --no-fund
- name: next build
run: npm run build --workspace @invisible-dots/web
- name: install the browser the tests drive
run: npx playwright install --with-deps chromium
# Each worker starts the real control plane (over a fake VM layer) and the built web client; see
# apps/web/e2e/harness.ts. The JUnit report lands in apps/web/playwright-report.xml.
- name: browser tests
run: npm run test:e2e --workspace @invisible-dots/web
# Playwright exits 0 when a spec file stopped being collected or a test skipped itself, so what ran is
# counted against the same floors file.
- name: refuse a run with too few tests or an unexpected skip
if: always()
run: node .github/scripts/test-guard.mjs apps/web/playwright-report.xml --suite playwright
- name: keep the traces of a failed run
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: apps/web/test-results
if-no-files-found: ignore
english:
name: the repository is English
runs-on: ubuntu-latest
@@ -32,10 +284,12 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: install the pinned package, which carries the gate
# The gate ships in the pinned invisible-core; its settings are read
# from pyproject.toml ([tool.invisible.english]).
- name: install the pinned gates
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -r .github/gates-requirements.txt
- name: check the prose
run: |
python -m invisible_core.english --selftest
@@ -49,71 +303,34 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: install the pinned package, which carries the gate
- name: install the pinned gates
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -r .github/gates-requirements.txt
- name: check the claims
run: |
python -m invisible_core.claims --selftest
python -m invisible_core.claims
unit:
name: pytest (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
# Every version `requires-python = ">=3.11"` promises and the pinned
# package declares, on ubuntu; Windows on 3.12.
python: ["3.11", "3.12", "3.13"]
exclude:
- { os: windows-latest, python: "3.11" }
- { os: windows-latest, python: "3.13" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- name: install the package and its dev extras
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: run pytest
run: |
set -o pipefail # or tee would swallow pytest's exit code
pytest tests/ -v --tb=short -rs | tee tests-report.txt
# pytest's exit code cannot tell "everything passed" from "the selection
# dropped everything". The suite is four tests, so all four must run.
- name: refuse a run in which the suite deselected itself
run: |
python - <<'PY'
import pathlib, re, sys
text = pathlib.Path("tests-report.txt").read_text(encoding="utf-8", errors="ignore")
m = re.search(r"(\d+) passed", text)
passed = int(m.group(1)) if m else 0
if passed < 4:
print(f"only {passed} test(s) ran (expected 4)", file=sys.stderr)
sys.exit(1)
print(f"{passed} tests ran")
PY
gate:
needs: [unit, english, claims]
needs: [node, postgres, whatsapp-client, iso, go, engine, smoke, browser-smoke, web, english, claims]
if: always()
runs-on: ubuntu-latest
steps:
- name: refuse unless every job succeeded
env:
RESULTS: ${{ toJSON(needs) }}
run: |
echo "unit: ${{ needs.unit.result }}"
echo "english: ${{ needs.english.result }}"
echo "claims: ${{ needs.claims.result }}"
for result in "${{ needs.unit.result }}" "${{ needs.english.result }}" "${{ needs.claims.result }}"; do
if [ "$result" != "success" ]; then
echo "a job did not succeed: $result"
exit 1
fi
done
echo "ok"
echo "$RESULTS"
node -e '
const needs = JSON.parse(process.env.RESULTS);
const names = Object.keys(needs);
if (names.length !== 11) { console.error(`expected 11 jobs, got ${names.length}`); process.exit(1); }
let ok = true;
for (const name of names) {
console.log(`${name}: ${needs[name].result}`);
if (needs[name].result !== "success") ok = false;
}
if (!ok) { console.error("a job did not succeed"); process.exit(1); }
console.log("ok");
'
+33 -6
View File
@@ -1,8 +1,35 @@
__pycache__/
*.py[cod]
.venv/
build/
node_modules/
.next/
dist/
*.egg-info/
.pytest_cache/
coverage/
*.tsbuildinfo
*.log
.env
.env.*
# Go build output (guest/dot-agentd/bin/dot-agentd)
bin/
# Images written by local checks and builds; the real ones live in
# INVISIBLE_DOTS_HOME, never in the tree.
*.iso
*.qcow2
*.img
# Scratch output of local checks, and the virtual environment the pre-push
# hook creates there for the English and claims gates
/tmp/
# vitest's JSON report, as CI and the pre-push hook write it
vitest-report.json
# The benchmark's tasks: tests/bench/run.sh writes them with author.py and author_long.py before every run
/tests/bench/tasks/
/tests/bench/tasks-long/
# Editor and Python leftovers of the gates
__pycache__/
.venv/
# The README animation's frames: docs/hero/build.mjs writes them on every run, from the pages and photos beside it
/docs/hero/frames/
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
fund=false
+33
View File
@@ -0,0 +1,33 @@
# Code of Conduct
This project follows the [Contributor Covenant, v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## Our Pledge
We pledge to make participation in our community a harassment-free experience for everyone.
## Standards
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
Examples of unacceptable behavior:
- The use of sexualized language or imagery
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
## Enforcement
Instances of unacceptable behavior may be reported by contacting the maintainer at **[email protected]**. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
The maintainer is obligated to maintain confidentiality with regard to the reporter of an incident.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
+41
View File
@@ -0,0 +1,41 @@
# Contributing to invisible_dots
Issues and pull requests are welcome.
## Quick links
- **Setup or `doctor` fails?** Open a [setup report](https://github.com/feder-cr/invisible_dots/issues/new?template=01-setup.yml).
- **A Dot, the web UI or the command line misbehaves?** Open a [bug report](https://github.com/feder-cr/invisible_dots/issues/new?template=02-bug.yml).
- **Idea?** Open a [feature request](https://github.com/feder-cr/invisible_dots/issues/new?template=03-feature.yml).
- **Security issue?** Do **not** open a public issue: see [SECURITY.md](SECURITY.md).
- **A site detects or blocks the browser?** That belongs to
[invisible_playwright](https://github.com/feder-cr/invisible_playwright/issues), the Dot's browser.
## Development
Everything is in [the guide's development section](docs/guide.md#development-and-tests). In short:
```sh
npm ci
npm run typecheck
npm test
git config core.hooksPath .githooks # the pre-push gate: typecheck, vitest, go test, prose checks
```
The engine (`invisible_engine_dots/`, `pytest`, Linux only) and dot-agentd (`guest/dot-agentd/`, `go test ./...`)
have their own suites; [docs/architecture.md](docs/architecture.md) explains every component.
## Pull requests
1. Branch from `main`; one logical change per PR.
2. Add or update the tests for any change of behaviour. A test that stops running fails CI: the counts are held
to `.github/test-floors.json`.
3. Write the commit message as the repository does: `area: what is now true`, for example
`scheduler: a task the claim skipped is claimed when the row is free`.
4. Update the README or `docs/` when what a person sees changes. The README promises nothing it cannot
support, and CI checks it.
5. Every check must be green before merge.
## License
By contributing, you agree that your contributions are licensed under the MIT License ([LICENSE](LICENSE)).
+80 -43
View File
@@ -1,67 +1,104 @@
<div align="center">
# dots
**Every AI agent is a model and a browser.<br>You can swap the model with one flag. The browser is what the website sees.**
<picture>
<source media="(max-width: 374px) and (prefers-color-scheme: dark)" srcset="docs/images/hero-small-dark.gif">
<source media="(max-width: 374px)" srcset="docs/images/hero-small-light.gif">
<source media="(max-width: 1239px) and (prefers-color-scheme: dark)" srcset="docs/images/hero-mobile-dark.gif">
<source media="(max-width: 1239px)" srcset="docs/images/hero-mobile-light.gif">
<source media="(prefers-color-scheme: dark)" srcset="docs/images/hero-dark.gif">
<img alt="invisible_dots, an open-source, self-hosted alternative to OpenAI Dots, Meta Muse, Grok Bot, Manus Cue and Claude Cowork. An animation of what a Dot does: asked once in its chat to save a shop's invoices every month and send the total to Telegram, it works on a computer of its own (a desktop, a terminal, files and a Firefox that keeps the shop's login), writes the steps down as a skill, asks before it adds the monthly automation, and then sends the total every month; several Dots, one computer each." src="docs/images/hero-light.gif" width="100%">
</picture>
</div>
---
Open-source, self-hosted alternative to OpenAI Dots, Grok Bot. Built to be undetectable by anti-bot systems.
Windows, in PowerShell:
## Quickstart
You need Node 24+, Go 1.25+, Git and hardware virtualization (x86-64), plus an OpenRouter key.
**Windows** (PowerShell):
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
uvx --from git+https://github.com/feder-cr/dots dots --openrouter-key sk-or-...
winget install -e --id OpenJS.NodeJS.LTS; winget install -e --id GoLang.Go; winget install -e --id Git.Git
git clone https://github.com/feder-cr/invisible_dots; cd invisible_dots
npm ci; npm run build --workspace @invisible-dots/cli
node apps/cli/dist/invisible-dots.mjs setup --all
node apps/cli/dist/invisible-dots.mjs server
```
Linux:
**Linux** (Ubuntu 24.04):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uvx --from git+https://github.com/feder-cr/dots dots --openrouter-key sk-or-...
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs git && sudo snap install go --classic
git clone https://github.com/feder-cr/invisible_dots && cd invisible_dots
npm ci && npm run build --workspace @invisible-dots/cli
node apps/cli/dist/invisible-dots.mjs setup --all
node apps/cli/dist/invisible-dots.mjs server
```
Then open **http://127.0.0.1:8765**. The conversation on the left, the browser on the right, live.
`setup --all` installs QEMU and turns on the accelerator (KVM, or the Windows Hypervisor Platform), then builds or
downloads the Dot's images; run it again after a restart and it carries on. Then open **http://127.0.0.2:3000**,
paste your OpenRouter key and create your first Dot. Every step and its failure modes:
[the guide's quickstart](docs/guide.md#quickstart).
## It all comes down to the browser
## What to ask a Dot
When a web agent fails, the model is rarely why. The page never loaded, a
challenge appeared, the login expired, the click did not land. All of that
happens in the browser, before the model gets to think.
Anything that needs a computer and a person's judgement, for as long as it takes:
So dots is built around one:
> Open a browser identity called research, go to https://example.com and tell me the page's title and its first
> sentence. Leave the browser open.
- **A real Firefox engine, patched in C++.** The fingerprint is decided inside
the engine, not painted over with JavaScript that a page can inspect.
- **One identity per seed.** Screen, fonts, GPU, timezone and language agree
with each other, and `--seed` gives back the same person on every run.
- **Nothing for a page to find.** No WebDriver flag, no DevTools protocol, no
automation globals in the page.
- **A person's hands.** The pointer travels to what it clicks and keys are
pressed one at a time, so every event the page receives is a trusted one.
- **A browser that remembers.** `--profile-dir` keeps logins and cookies from
one run to the next.
- **Where it connects from is who it is.** With `--proxy`, the timezone and the
language follow the exit.
> Every morning, check one-way fares from Milan to Lisbon for the next two weeks, write them to
> ~/workspace/fares.csv and tell me the cheapest day.
The model is any model on OpenRouter, and `--model` changes it.
> Log in to the shop with the shopping identity, download this month's invoices to ~/documents, and remember where
> the invoices page is for next time.
## What to ask it
## What a Dot has
> Go to `<paste the URL>`. One way, Milan to Lisbon, economy, one adult. Check
> every date from the 12th to the 16th of next month and read the cheapest fare
> for each day. If a date has no availability, say so. Do not guess a number.
| | |
|---|---|
| **[A computer of its own](docs/guide.md#what-a-dot-can-do)** | A hardware-accelerated VM with a Linux desktop and a persistent disk. It sleeps when idle and wakes for the next message, task or automation. |
| **[A browser that is not blocked](docs/guide.md#the-browser)** | [invisible_playwright_mcp](https://github.com/feder-cr/invisible_playwright_mcp): Firefox patched in C++, the fingerprint set inside the engine. Each identity keeps its own cookies and logins. |
| **[Memory and skills](docs/guide.md#what-a-dot-can-do)** | It writes its own notes and how-tos in its home folder, as Claude Code does, and reads them on the next task. |
| **[Your MCP servers](docs/guide.md#mcp-servers)** | Any MCP server you declare, a program on its computer or a URL, as in Claude Code. Its keys stay out of the config. |
| **[Permissions you set](docs/guide.md#approvals)** | Every tool belongs to a permission: allow, ask or deny. An ask waits in your Inbox, survives a restart and runs the call once. |
| **[Tasks and automations](docs/guide.md#the-web-ui)** | A queue with priorities and start times, and its own recurring jobs, for which its computer is started on time. |
| **[Many ways to reach it](docs/guide.md#talk-to-it-from-your-phone)** | Web UI, command line, HTTP API with live events, Telegram, and WhatsApp as an opt-in. |
| **[Nothing lost on a crash](docs/guide.md#how-it-works)** | A restart or a `kill -9` keeps every message and task you saw accepted, and a tool call cut short is never run twice. |
## The same browser, in your own assistant
## Documentation
Claude Code, Codex, Gemini CLI or any MCP client:
[invisible_playwright_mcp](https://github.com/feder-cr/invisible_playwright_mcp)
gives them this browser as a server. `dots` is its interface, and `dots --help`
lists every option.
- [Guide](docs/guide.md): install, the web UI, the command line, the browser, channels, configuration, updating
- [Architecture](docs/architecture.md): every component and why it is there
- [Troubleshooting](docs/guide.md#troubleshooting) and [development and tests](docs/guide.md#development-and-tests)
## Related projects
**The pieces of this one.** A Dot's browser is
[invisible_playwright_mcp](https://github.com/feder-cr/invisible_playwright_mcp), on
[invisible_playwright](https://github.com/feder-cr/invisible_playwright) and
[invisible_core](https://github.com/feder-cr/invisible_core); the engine is a fork of
[nanobot](https://github.com/HKUDS/nanobot).
## License
MIT, see [LICENSE](LICENSE). Third-party components and their licenses: [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## Disclaimer
This project is provided as-is, with no warranties. Use it at your own risk and in compliance with the laws of your
jurisdiction. A Dot acts with your accounts and from your connection: respect the terms of the sites it visits and
their robots.txt.
---
Not affiliated with OpenAI. MIT licensed.
<p align="center">
<a href="https://github.com/feder-cr/invisible_dots/actions/workflows/tests.yml"><img alt="tests" src="https://github.com/feder-cr/invisible_dots/actions/workflows/tests.yml/badge.svg"></a>
<a href="LICENSE"><img alt="license: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="status: alpha" src="https://img.shields.io/badge/status-alpha-orange">
<img alt="hosts: Linux and Windows" src="https://img.shields.io/badge/hosts-Linux%20%7C%20Windows-lightgrey">
</p>
<p align="center">
Built by <a href="https://it.linkedin.com/in/federico-elia-5199951b6">Federico Elia</a>
</p>
+40
View File
@@ -0,0 +1,40 @@
# Security Policy
## Supported versions
Only `main` receives fixes: nothing is packaged yet, and you build from this repository.
## Reporting a vulnerability
Do not report security issues in public issues, discussions or pull requests.
Send a report to `[email protected]` with the subject prefix `[security][invisible_dots]`, with:
- what the issue is and what it affects
- the steps to reproduce
- the commit (`git rev-parse --short HEAD`), the host OS and `invisible-dots doctor --json`
- a fix, if you have one
What a Dot is and is not protected against is written down in
[the guide's security model](docs/guide.md#security-model-and-known-limits): a way around one of those guarantees
is in scope.
## Scope
In scope:
- The control plane: API, scheduler, VM manager, database, web client and command line
- The guest: dot-agentd, the engine and the separation between them and the Dot's user
- The images and how they are built and downloaded
Out of scope:
- The Dot's browser: report to [invisible_playwright_mcp](https://github.com/feder-cr/invisible_playwright_mcp) or
[invisible_playwright](https://github.com/feder-cr/invisible_playwright)
- QEMU, Firefox and other upstream projects: report to them directly
Not security issues:
- A site detecting or blocking the browser: open a regular issue in
[invisible_playwright](https://github.com/feder-cr/invisible_playwright/issues)
- What a model chooses to do with permissions you set to allow
+634
View File
@@ -0,0 +1,634 @@
# Third-party notices
The MIT license in `LICENSE` covers this repository, except:
- everything under `invisible_engine_dots/`, a hard fork of nanobot, which
is under `invisible_engine_dots/LICENSE` and the nested notice listed in
its section below.
That fork, the text this repository's history holds through the earlier
TypeScript engine, and the web client's files that derive from other projects
(each starts with a `Derived from` comment naming its source, and the project
has a section below) come with the notices below. The default `npm install`
holds nothing under the GPL
(the golden image's guest operating system is Ubuntu, which carries GPL software
under its own licenses, downloaded from Ubuntu's archive by whoever builds the
image: docs/architecture.md section 11.3); the
one GPL-3.0 dependency, `libsignal` of the optional WhatsApp client, is left out
of the default install and is described in the section "The opt-in WhatsApp
client and `libsignal` (GPL-3.0)".
## Open Multi-Agent
The earlier TypeScript engine (`guest-runtime/engine/`) was derived in part
from it. That engine is deleted; its files and its `UPSTREAM.md` (source,
version and commit) are in this repository's history, before the commit
"repo: delete the earlier TypeScript engine and agent", and this notice stays
for as long as that history does.
```text
MIT License
Copyright (c) Shenzhen YuanASI Technology Co., Ltd. and open-multi-agent contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## context-chef
`@context-chef/core` 4.2.1. Open Multi-Agent's `agent/runner.ts`, as imported
into this repository's history, contained `groupIntoTurns` and
`stripMediaBlocksForSummary`, modelled on this library (the deleted engine's
`UPSTREAM.md` records the details).
```text
MIT License
Copyright (c) 2025 MyPrototypeWhat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Context compaction (OpenHands, OpenAI Codex, opencode)
How the engine compacts a request that reaches its model's context window
(`invisible_engine_dots/nanobot/agent/context_governance.py`, `memory.py` and
`templates/agent/consolidator_archive.md`) takes from three projects:
- the headings of the summary (USER_CONTEXT, TASK_TRACKING, COMPLETED, PENDING,
CURRENT_STATE, CODE_STATE, TESTS, CHANGES, DEPS, VERSION_CONTROL_STATUS) from
the summarizing prompt of OpenHands' software-agent-sdk
(`openhands-sdk/openhands/sdk/context/condenser/prompts/summarizing_system.j2`);
- the handoff framing of the summary and of the text that introduces it, the
person's latest messages kept as they wrote them up to 20000 tokens, and
dropping the oldest messages when the summary's own request does not fit,
from OpenAI Codex (`codex-rs/prompts/templates/compact/`,
`codex-rs/core/src/compact.rs`), under the Apache License 2.0
(https://www.apache.org/licenses/LICENSE-2.0), with this notice:
```text
OpenAI Codex
Copyright 2025 OpenAI
```
- the room kept for the answer (20000 tokens) and the thresholds for clearing old
tool results (the newest 40000 tokens kept, 20000 freed at least) from
opencode (`packages/opencode/src/session/overflow.ts` and `compaction.ts`).
```text
MIT License
Copyright (c) 2026 OpenHands contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
```text
MIT License
Copyright (c) 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## nanobot
`invisible_engine_dots/` is imported from nanobot
(https://github.com/HKUDS/nanobot, commit
`f75470e72f0993dcf92accc81282adaa48b16f56`) and modified for invisible_dots.
It is a hard fork: upstream changes are not tracked or merged. Source, commit,
what the import left out and what was removed since:
`invisible_engine_dots/UPSTREAM.md`. Its
`LICENSE`, byte for byte:
```text
MIT License
Copyright (c) 2025-present Xubin Ren and the nanobot contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
The fork carries one further notice, which stays next to the code it covers:
- `invisible_engine_dots/THIRD_PARTY_NOTICES.md`: says that no third-party
component is vendored in the fork.
Its Python dependencies are not part of this repository: the golden image's
build installs them from PyPI as the wheels their publishers released, pinned
by hash in `guest/image-builder/builder/engine-requirements.lock`, each under
its own license. The runtime disk carries the fork's own source with its
`LICENSE` and `UPSTREAM.md` (`/opt/invisible-dots/engine/` in the guest).
Two files of the web client derive from nanobot's web UI instead (the
`webui/` of the upstream repository at commit 9dc0aba, under the same license
as above): `apps/web/src/components/chat/activity-step.tsx` and
`apps/web/src/components/channels/qr-connect.tsx`. The first comment of each
says what was changed.
## hev-socks5-tunnel (in the golden image)
The golden image carries the static binary `hev-socks5-tunnel` 2.18.0
(https://github.com/heiher/hev-socks5-tunnel), pinned by URL and SHA-256 in
`guest/image-builder/pins.json` and downloaded when the image is built. It
routes a Dot's VM through its VM proxy. It is MIT-licensed:
> Copyright (c) 2022 hev
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of
> this software and associated documentation files (the "Software"), to deal in
> the Software without restriction, including without limitation the rights to
> use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
> of the Software, and to permit persons to whom the Software is furnished to do
> so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
## shadcn/ui
The primitives in `apps/web/src/components/ui/` and `apps/web/src/lib/utils.ts`
were generated from shadcn/ui (https://github.com/shadcn-ui/ui, commit
0e3abd65) and changed; each file's first comment says what changed.
```text
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OpenDots (CopilotKit)
`apps/web/src/lib/time.ts`, `apps/web/src/lib/use-visible-interval.ts`,
`packages/shared/src/labels.ts` and
`apps/web/src/components/tasks/task-status.tsx` derive from OpenDots by CopilotKit
(https://github.com/CopilotKit/OpenDots, commit 88f2a08); each file's first
comment says what was changed.
```text
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## AI Elements (Vercel)
`apps/web/src/components/chat/conversation.tsx` and
`apps/web/src/components/chat/tool-status.tsx` derive from AI Elements by Vercel
(https://github.com/vercel/ai-elements, commit 6a9d5b1), licensed under the Apache
License, Version 2.0, which is given once, below. Each file's first comment states
what was changed, as the license asks.
```text
Copyright 2023 Vercel, Inc.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2022 Joe Bell
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
## open-cowork
`apps/web/src/components/computer/screen-view.tsx` derives from open-cowork by
Coasty (https://github.com/coasty-ai/open-cowork, commit fbbc671); its first comment
says what was changed. No asset of that project is used.
```text
MIT License
Copyright (c) 2026 Coasty / open-cowork contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## assistant-ui
`apps/web/src/components/elements/approval-card.tsx` and
`apps/web/src/lib/range.ts` derive from assistant-ui
(https://github.com/assistant-ui/assistant-ui, commit 0bdf050); each file's first
comment says what was changed.
```text
MIT License
Copyright (c) 2026 AgentbaseAI Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OpenDots (Shashankss1205)
`apps/web/src/components/approvals/diff-preview.tsx` and
`apps/web/src/lib/diff.ts` derive from OpenDots by Shashank Shekhar Singh
(https://github.com/Shashankss1205/OpenDots, commit bb8db95), which includes source
previously distributed under the name Spots (Copyright (c) 2026 Spots contributors,
also MIT); each file's first comment says what was changed. No other project of the
same name is meant: the CopilotKit one has its own section above.
```text
MIT License
Copyright (c) 2026 Shashank Shekhar Singh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## The opt-in WhatsApp client and `libsignal` (GPL-3.0)
The default `npm install` holds nothing under the GPL. The WhatsApp adapter
(`packages/channels/src/whatsapp-baileys/`) uses Baileys (MIT), a client of the
WhatsApp Web protocol, which depends on the npm package `libsignal`, under the
GNU General Public License, version 3, and so does not fit this repository's MIT
license. They are therefore not part of the default install:
- No workspace declares Baileys, as a dependency of any kind, and the root
`package-lock.json` holds none of it or of what only it needs, so `npm ci`
installs no GPL package (`tests/repo/default-install-licenses.test.ts` scans
the lock file for licenses).
- The client lives apart, in `optional/whatsapp/`, with a `package.json` that
pins Baileys to one exact release and a `package-lock.json` with the
integrity hash of every package. A person who turns WhatsApp on installs it
with one command from the repository root: `npm run whatsapp:install` (a
clean install of that lock file, without install scripts). Installing it is
that person's choice, and what they then hold, Baileys and `libsignal`,
is under those licenses.
- The adapter loads the client from that folder, by path, only when a
connection opens and only if it is there. Otherwise it says how to enable it:
run `npm run whatsapp:install`, then start the server with
`INVISIBLE_DOTS_WHATSAPP=1`.
- The command bundle that `apps/cli/scripts/build.mjs` makes does not hold it
(the bundle has no import of it), so no build of this repository is a GPL
work. Whoever distributes a build together with an installed
`optional/whatsapp/node_modules`, which is GPL-3.0 code, has to take that into
account.
## LGPL image libraries that Next.js may install
The web client's framework, Next.js, lists `sharp` (an image library,
Apache-2.0) as an optional dependency, and `sharp` lists prebuilt binaries for
each platform, `@img/sharp-*` and `@img/sharp-libvips-*`, which carry libvips
under the LGPL-3.0 (`LGPL-3.0-or-later`, or `Apache-2.0 AND LGPL-3.0-or-later`
for the Windows builds). `npm ci` installs the ones that fit the host, as
optional packages. They are LGPL, not GPL, and unmodified; whoever distributes
an installation together with its `node_modules` has to meet the LGPL for them.
`tests/repo/default-install-licenses.test.ts` allows no LGPL package in the lock
file other than these.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@invisible-dots/api",
"version": "0.0.0",
"private": true,
"description": "Control-plane HTTP API and SSE stream, and startServer: the control plane composed as one process (invisible-dots server).",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@invisible-dots/shared": "0.0.0",
"fastify": "^5.12.5",
"@invisible-dots/channels": "0.0.0",
"@invisible-dots/database": "0.0.0",
"@invisible-dots/events": "0.0.0",
"@invisible-dots/image-builder": "0.0.0",
"@invisible-dots/scheduler": "0.0.0",
"@invisible-dots/sdk": "0.0.0",
"@invisible-dots/vm-manager": "0.0.0"
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Process settings of the control plane: the listen address, the API token
* and the master key (architecture sections 3.2 and 9.6). Both secrets live
* in INVISIBLE_DOTS_HOME/config and are created at the first start, so a new
* host needs no installer step before `invisible-dots server`.
*/
import { randomBytes } from "node:crypto";
import { MASTER_KEY_BYTES } from "@invisible-dots/database";
import { DEFAULT_LISTEN, ENV, readApiToken, readSecretFile, writeSecretFile, type HostPaths } from "@invisible-dots/shared";
/** A generated token: 32 random bytes as hex, so it survives copy and paste and any shell quoting. */
const GENERATED_TOKEN_BYTES = 32;
export interface ListenAddress {
host: string;
port: number;
}
/** What differs between the settings parseListen reads. */
export interface ListenSetting {
/** The environment variable the value came from, named in the errors. */
variable: string;
/** The address shown as the example in the error: the setting's own default. */
example: string;
/** Whether port 0 (the system picks one) is refused, for a server whose address the person has to be told. */
fixedPort: boolean;
}
export const API_LISTEN: ListenSetting = { variable: ENV.LISTEN, example: DEFAULT_LISTEN, fixedPort: false };
/** Parse `host:port`, `[v6]:port` or a bare port (which binds 127.0.0.1). */
export function parseListen(value: string = DEFAULT_LISTEN, setting: ListenSetting = API_LISTEN): ListenAddress {
const text = value.trim();
let host = "127.0.0.1";
let portText = text;
const v6 = /^\[([^\]]+)\]:(\d+)$/.exec(text);
if (v6) {
host = v6[1]!;
portText = v6[2]!;
} else if (text.includes(":")) {
const at = text.lastIndexOf(":");
host = text.slice(0, at);
portText = text.slice(at + 1);
}
const port = Number(portText);
if (!host || !/^\d+$/.test(portText) || port < 0 || port > 65535) {
throw new Error(`${setting.variable} must look like "${setting.example}", got "${value}"`);
}
if (setting.fixedPort && port === 0) throw new Error(`${setting.variable} needs a fixed port, got "${value}"`);
return { host, port };
}
export interface LoadedSecret<T> {
value: T;
/** Where it came from: a file path or an environment variable name. */
origin: string;
/** True when this call generated it and wrote the file. */
created: boolean;
}
/**
* The API token as readApiToken reads it (INVISIBLE_DOTS_TOKEN, else the
* first line of `config/api.token`); the file is generated (0600) when it
* does not exist yet.
*/
export async function loadOrCreateApiToken(
paths: HostPaths,
env: Record<string, string | undefined> = process.env,
): Promise<LoadedSecret<string>> {
const existing = await readApiToken(env, paths);
if (existing) return { ...existing, created: false };
const path = paths.apiTokenPath;
const token = randomBytes(GENERATED_TOKEN_BYTES).toString("hex");
await writeSecretFile(path, `${token}\n`);
return { value: token, origin: path, created: true };
}
/**
* The master key that encrypts secrets in the database: `config/master.key`,
* 32 raw bytes (64 hex characters are accepted too, so a key restored by
* hand works). When the file does not exist a new key is generated but NOT
* written: `created` tells the caller to check first that the database
* holds nothing encrypted under a key that was lost, and only then to call
* `saveMasterKey`. Writing it earlier would turn a missing key into a wrong
* one that the next start accepts without a word.
*/
export async function loadOrGenerateMasterKey(paths: HostPaths): Promise<LoadedSecret<Buffer>> {
const path = paths.masterKeyPath;
const raw = await readSecretFile(path);
if (raw === undefined) return { value: randomBytes(MASTER_KEY_BYTES), origin: path, created: true };
if (raw.length === MASTER_KEY_BYTES) return { value: raw, origin: path, created: false };
const hex = raw.toString("utf8").trim();
if (new RegExp(`^[0-9a-fA-F]{${MASTER_KEY_BYTES * 2}}$`).test(hex)) return { value: Buffer.from(hex, "hex"), origin: path, created: false };
throw new Error(`${path} must hold ${MASTER_KEY_BYTES} bytes (or ${MASTER_KEY_BYTES * 2} hexadecimal characters), found ${raw.length} bytes`);
}
/** Write a key from `loadOrGenerateMasterKey` (0600, atomically). */
export async function saveMasterKey(paths: HostPaths, key: Uint8Array): Promise<void> {
await writeSecretFile(paths.masterKeyPath, key);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Opening the control plane's database at start: the adapter the
* environment names (PGlite in INVISIBLE_DOTS_HOME/db, or DATABASE_URL),
* migrated, and checked against a master key that was lost.
*/
import { Database, databaseTarget, describeDatabaseTarget } from "@invisible-dots/database";
import { errorMessage, type Logger } from "@invisible-dots/shared";
export interface OpenDatabaseOptions {
env: Record<string, string | undefined>;
masterKey: Uint8Array;
logger: Logger;
}
/** Open and migrate; a failure names the database and is never left half open. */
export async function openControlPlaneDatabase(options: OpenDatabaseOptions): Promise<Database> {
const target = databaseTarget(options.env);
const where = describeDatabaseTarget(target);
// Both adapters already name the database they could not open (pg without
// its password), so the error is passed on as it is.
const db = await Database.open({ target, masterKey: options.masterKey });
try {
// Before anything reads or writes: a second server on the same database
// (another INVISIBLE_DOTS_HOME with the same DATABASE_URL) would take
// over the first one's Dots.
if (!(await db.holdInstanceLock())) {
throw new Error(`another invisible-dots server already uses the ${where}; stop it first (one server per database)`);
}
} catch (error) {
await db.close().catch(() => {});
throw error;
}
try {
const migrated = await db.migrate({ log: (line) => options.logger.info(line) });
options.logger.info("database ready", {
database: where,
applied: migrated.applied.length,
total: migrated.applied.length + migrated.alreadyApplied.length,
});
} catch (error) {
await db.close().catch(() => {});
throw new Error(`cannot migrate the ${where}: ${errorMessage(error)}`, { cause: error });
}
return db;
}
/**
* Refuse a newly generated master key for a database that already holds
* values encrypted under another one: the key file was lost or deleted,
* and going on would leave every Dot token and secret unreadable.
*/
export async function assertNothingEncrypted(db: Database, masterKeyPath: string): Promise<void> {
if (await db.holdsEncryptedValues()) {
throw new Error(
`${masterKeyPath} does not exist, but the database holds Dot tokens or secrets encrypted under a master key: ` +
`restore the original master.key (a new one cannot read them), then start again`,
);
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* The doctor rows only the control plane's side can read: the images a Dot
* would get now. The rest of the report is the vm-manager's `runDoctor`
* (architecture section 11.1); `startServer` serves it as `GET /api/doctor`
* and `invisible-dots doctor` runs it from the command line, both over this
* one function.
*/
import { stat } from "node:fs/promises";
import { basename } from "node:path";
import { manifestPathFor, verifyImage, type ImageCheck } from "@invisible-dots/image-builder";
import type { DoctorCheck, HostPaths } from "@invisible-dots/shared";
import { latestImage } from "./vm-driver.js";
/** The size and modification time of a file, or "absent": what a verdict on it depends on. */
async function fingerprint(path: string): Promise<string> {
const info = await stat(path).catch(() => undefined);
return info ? `${info.size}:${info.mtimeMs}` : "absent";
}
/**
* The doctor's image rows for one host. `verifyImage` re-hashes a whole
* multi-GB image, and the onboarding checklist asks again and again, so a
* verdict is kept for as long as the image and its manifest keep the same
* size and modification time: the hash reruns only when a file changes. The
* command line runs it once per command and loses nothing.
*/
export function createImageChecks(paths: HostPaths, verify: (image: string) => Promise<ImageCheck> = verifyImage): () => Promise<DoctorCheck[]> {
const verdicts = new Map<string, { fingerprint: string; check: ImageCheck }>();
async function verified(image: string): Promise<ImageCheck> {
const current = `${await fingerprint(image)}|${await fingerprint(manifestPathFor(image))}`;
const known = verdicts.get(image);
if (known?.fingerprint === current) return known.check;
const check = await verify(image);
verdicts.set(image, { fingerprint: current, check });
return check;
}
/** The image a Dot would get now (the newest one, as the control plane picks it), checked against its manifest. */
async function imageCheck(id: "golden-image" | "runtime-image"): Promise<DoctorCheck> {
const golden = id === "golden-image";
const label = golden ? "golden image" : "runtime ISO";
let image: string;
try {
image = await latestImage(paths.imagesDir, golden ? "golden" : "runtime");
} catch {
return { id, label, status: "missing", detail: `none in ${paths.imagesDir}`, fix: "invisible-dots image build" };
}
const verdict = await verified(image);
if (verdict.ok) return { id, label, status: "ok", detail: `${basename(image)} matches its manifest` };
return { id, label, status: "failed", detail: verdict.reason, fix: verdict.fix };
}
return async () => [await imageCheck("golden-image"), await imageCheck("runtime-image")];
}
+22
View File
@@ -0,0 +1,22 @@
/**
* How a file of a Dot's computer is served by `GET /api/dots/:id/files`. The bytes are the Dot's own, written
* by a model that may have read hostile text, and the web server answers from the same origin as the page, so a
* file is never given a type a browser would run: images are images, text (source and markup included) is
* `text/plain`, and everything else is a download.
*/
import { fileType } from "@invisible-dots/shared";
export interface FileServing {
contentType: string;
/** The `Content-Disposition` header: `inline` for what a page may show, `attachment` for the rest. */
disposition: string;
}
/** The type and disposition for the file at `path` (a guest path). */
export function serveFile(path: string): FileServing {
const name = path.slice(path.lastIndexOf("/") + 1);
const { kind, contentType } = fileType(name);
// The plain filename is for old clients (printable ASCII, no quote, backslash or percent); filename* carries the real one.
const plain = name.replace(/[^\x20-\x7e]|["\\%]/g, "_");
return { contentType, disposition: `${kind === "other" ? "attachment" : "inline"}; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(name)}` };
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./config.js";
export * from "./database.js";
export * from "./doctor.js";
export * from "./lock.js";
export * from "./server.js";
export * from "./start.js";
export * from "./vm-driver.js";
+22
View File
@@ -0,0 +1,22 @@
/**
* One control plane per INVISIBLE_DOTS_HOME. Two servers on the same home
* would both open the embedded PGlite data directory, which has no locking
* of its own and is corrupted by a second writer, and both would start and
* stop the same QEMU processes. A lock file holding the owner's pid stops
* the second one before it touches anything; the lock itself is the shared
* pid lock of packages/shared, which also serves the image builds.
*/
import { acquirePidLock, ensurePrivateDir, type HostPaths, type PidLock, type ProcessPresence } from "@invisible-dots/shared";
export type ServerLock = PidLock;
/** Take the server lock of `paths.home`, or fail naming the pid that holds it and the file to remove. */
export async function acquireServerLock(paths: HostPaths, presence?: (pid: number) => ProcessPresence): Promise<ServerLock> {
// The whole data directory is this user's alone, on Windows too (files.ts).
await ensurePrivateDir(paths.home);
return acquirePidLock(paths.serverLockPath, {
what: "invisible-dots server",
stopHint: `stop it first (it serves ${paths.home})`,
...(presence ? { presence } : {}),
});
}
+516
View File
@@ -0,0 +1,516 @@
/**
* The control-plane HTTP API of architecture section 9.6. Every route needs
* the bearer token; every error is `{ error, message }`. The handlers are
* thin: the work happens in the Scheduler.
*/
import { createHash, timingSafeEqual } from "node:crypto";
import { TELEGRAM_TOKEN_SECRET, type ChannelHub } from "@invisible-dots/channels";
import { StreamOverflowError } from "@invisible-dots/events";
import { type Scheduler } from "@invisible-dots/scheduler";
import { STREAM_ERROR_EVENT } from "@invisible-dots/sdk";
import type {
ApprovalsAnswer,
ChannelPairingAnswer,
ChannelRecord,
ChannelsAnswer,
EveryChannelAnswer,
DoctorAnswer,
DotsAnswer,
EventsAnswer,
FilesListAnswer,
HealthResponse,
HostFacts,
IdentitiesAnswer,
MessagesAnswer,
TasksAnswer,
UsageAnswer,
} from "@invisible-dots/sdk/types";
import { APPROVAL_LIST_LIMIT, APPROVAL_STATUSES, type ApprovalStatus, CHANNEL_KINDS, type ChannelKind, ControlPlaneError, CONVERSATION_LIST_LIMIT, type DoctorCheck, errorMessage, LIST_ORDERS, type ListOrder, type Logger, MAX_EVENT_PAGE, type McpSecretsAnswer, silentLogger, type SkillListAnswer, TASK_LIST_LIMIT, type ToolListAnswer } from "@invisible-dots/shared";
import { doctorAnswer } from "@invisible-dots/vm-manager";
import { serveFile } from "./file-types.js";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import apiPackage from "../package.json" with { type: "json" };
/** The version `GET /api/health` reports: this package's, as the CLI's `--version` reports its own. */
export const API_VERSION: string = apiPackage.version;
/** A comment line on idle SSE connections, so proxies and clients see the connection is alive. */
export const SSE_HEARTBEAT_MS = 15_000;
export interface ServerOptions {
scheduler: Scheduler;
channels: ChannelHub;
/** Runs the host checks of architecture section 11.1 on the machine this server runs on; `startServer` binds the real ones. */
doctor(): Promise<DoctorCheck[]>;
/** What `GET /api/health` says about where the state lives; `startServer` reads it from the paths and the database it opened. */
host: HostFacts;
token: string;
logger?: Logger;
heartbeatMs?: number;
}
type Params = { id: string; identityId: string; kind: string; peer: string };
type Body = Record<string, unknown> | undefined;
function digest(value: string): Buffer {
return createHash("sha256").update(value, "utf8").digest();
}
function bad(message: string): ControlPlaneError {
return new ControlPlaneError(400, "invalid_request", message);
}
/** A non-negative integer query parameter, or undefined when absent. */
function intParam(value: unknown, name: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
if (value === undefined || value === "") return undefined;
if (typeof value !== "string" || !/^\d+$/.test(value) || Number(value) > max) {
throw bad(`${name} must be a non-negative integer${max < Number.MAX_SAFE_INTEGER ? ` up to ${max}` : ""}`);
}
return Number(value);
}
/** The `order` of a list route, which only an `order=desc` list can be paged on (`before`) from. */
function orderParam(order: unknown, before: unknown): ListOrder | undefined {
if (order !== undefined && !(LIST_ORDERS as readonly unknown[]).includes(order)) throw bad(`order must be one of ${LIST_ORDERS.join(", ")}`);
if (before !== undefined && order !== "desc") throw bad("before pages a list in order=desc");
return order as ListOrder | undefined;
}
/** An ISO 8601 date-time query parameter, or undefined when absent. */
function sinceParam(value: unknown): Date | undefined {
if (value === undefined || value === "") return undefined;
const parsed = typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value) ? new Date(value) : undefined;
if (parsed === undefined || Number.isNaN(parsed.getTime())) {
throw bad("since must be an ISO 8601 timestamp such as 2026-10-05T00:00:00Z");
}
return parsed;
}
function channelKind(value: string): ChannelKind {
if (!(CHANNEL_KINDS as readonly string[]).includes(value)) throw bad(`no "${value}" channel: the channels are ${CHANNEL_KINDS.join(", ")}`);
return value as ChannelKind;
}
function bodyOf(request: FastifyRequest): Record<string, unknown> {
const body = request.body as Body;
if (body === undefined || body === null) return {};
if (typeof body !== "object" || Array.isArray(body)) throw bad("the body must be a JSON object");
return body;
}
export function buildServer(options: ServerOptions): FastifyInstance {
const { scheduler, channels } = options;
const log = options.logger ?? silentLogger;
const expected = digest(options.token);
const heartbeatMs = options.heartbeatMs ?? SSE_HEARTBEAT_MS;
const streams = new Set<AbortController>();
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024, return503OnClosing: true });
app.addHook("onRequest", async (request, reply) => {
const header = request.headers.authorization ?? "";
const match = /^Bearer\s+(.+)$/i.exec(header);
// Comparing digests keeps the comparison constant-time whatever the length of the guess.
if (!match || !timingSafeEqual(digest(match[1]!.trim()), expected)) {
return reply
.code(401)
.header("www-authenticate", 'Bearer realm="invisible-dots"')
.send({ error: "unauthorized", message: "a valid Authorization: Bearer <api token> header is required" });
}
});
app.setNotFoundHandler((request, reply) => {
void reply.code(404).send({ error: "not_found", message: `no route ${request.method} ${request.url.split("?")[0]}` });
});
app.setErrorHandler((error: unknown, request, reply) => {
if (error instanceof ControlPlaneError) {
const body: Record<string, unknown> = { error: error.code, message: error.message };
if (error.details !== undefined) body.details = error.details;
// A busy browser is the answer a polling page expects, not a failure.
if (error.status >= 500 && error.code !== "busy") log.error("request failed", { method: request.method, url: request.url, error: error.message });
return reply.code(error.status).send(body);
}
const e = error as { statusCode?: number; code?: string; message?: string };
if (typeof e.statusCode === "number" && e.statusCode >= 400 && e.statusCode < 500) {
const code = e.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" ? "unsupported_media_type" : "invalid_request";
return reply.code(e.statusCode).send({ error: code, message: e.message ?? "invalid request" });
}
log.error("unhandled error", { method: request.method, url: request.url, error: errorMessage(error) });
return reply.code(500).send({ error: "internal", message: errorMessage(error) });
});
/**
* Answer with a server-sent event stream, the one way every stream of this API starts: headers, a heartbeat comment
* so proxies and clients see the connection is alive, and an abort when the client goes or the server closes. `body`
* writes the frames and returns when there are no more.
*/
const eventStream = async (reply: FastifyReply, controller: AbortController, body: (raw: FastifyReply["raw"]) => Promise<void>): Promise<void> => {
reply.hijack();
const raw = reply.raw;
raw.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-accel-buffering": "no",
});
raw.write(": connected\n\n");
streams.add(controller);
const heartbeat = setInterval(() => raw.write(": ping\n\n"), heartbeatMs);
heartbeat.unref();
raw.on("close", () => controller.abort());
try {
await body(raw);
} finally {
clearInterval(heartbeat);
streams.delete(controller);
raw.end();
}
};
// Before the server stops listening and waits for its connections: a stream only ends when it is told to, so a hook that
// ran after that wait (`onClose`) would wait for the person to close the tab.
app.addHook("preClose", async () => {
for (const controller of streams) controller.abort();
});
app.get("/api/health", async (): Promise<HealthResponse> => {
const { database, openrouter_configured } = await scheduler.health();
return { status: "ok", database, version: API_VERSION, openrouter_configured, ...options.host };
});
// The host report `invisible-dots doctor` prints: what is missing for a Dot to run, and the command that fixes it. It runs QEMU's accelerator probe, so it takes a moment:
// one run at a time, and a request that arrives while it runs shares its answer (the onboarding checklist re-checks and polls).
let doctorRun: Promise<DoctorAnswer> | undefined;
app.get("/api/doctor", (): Promise<DoctorAnswer> => {
doctorRun ??= options.doctor().then(doctorAnswer).finally(() => {
doctorRun = undefined;
});
return doctorRun;
});
// Dots
app.post("/api/dots", async (request, reply) => {
const dot = await scheduler.createDot(bodyOf(request).config);
return reply.code(201).send(dot);
});
app.get("/api/dots", async (): Promise<DotsAnswer> => ({ dots: await scheduler.listDots() }));
app.get<{ Params: Params }>("/api/dots/:id", async (request) => scheduler.requireDot(request.params.id));
app.patch<{ Params: Params }>("/api/dots/:id", async (request) => {
const { config, expected_config_version } = bodyOf(request);
return scheduler.updateDot(request.params.id, config, expected_config_version);
});
app.delete<{ Params: Params }>("/api/dots/:id", async (request, reply) => {
return reply.code(202).send(await scheduler.deleteDot(request.params.id));
});
// Messages and tasks
app.post<{ Params: Params }>("/api/dots/:id/messages", async (request, reply) => {
return reply.code(202).send(await scheduler.sendMessage(request.params.id, bodyOf(request).text));
});
// The conversation pages like the event log: oldest first by default, `order=desc` the newest, `before` (the
// event id of the oldest message of the previous page) the ones older than that.
app.get<{ Params: Params; Querystring: { limit?: string; order?: unknown; before?: string } }>(
"/api/dots/:id/messages",
async (request): Promise<MessagesAnswer> => {
const limit = intParam(request.query.limit, "limit", CONVERSATION_LIST_LIMIT);
if (limit === 0) throw bad(`limit must be between 1 and ${CONVERSATION_LIST_LIMIT}`);
const before = intParam(request.query.before, "before");
const order = orderParam(request.query.order, before);
return { messages: await scheduler.conversation(request.params.id, { limit, order, before }) };
},
);
app.post<{ Params: Params }>("/api/dots/:id/tasks", async (request, reply) => {
const body = bodyOf(request);
const task = await scheduler.createTask(request.params.id, {
description: body.description as string,
priority: body.priority as number | undefined,
scheduled_at: body.scheduled_at as string | undefined,
});
return reply.code(201).send(task);
});
// The newest tasks first; `before` (the id of the last task of the previous page) goes on, older.
app.get<{ Params: Params; Querystring: { limit?: unknown; before?: unknown } }>("/api/dots/:id/tasks", async (request): Promise<TasksAnswer> => {
const limit = intParam(request.query.limit, "limit", TASK_LIST_LIMIT);
if (limit === 0) throw bad(`limit must be between 1 and ${TASK_LIST_LIMIT}`);
const { before } = request.query;
if (before !== undefined && (typeof before !== "string" || before === "")) throw bad("before must be the id of a task");
return { tasks: await scheduler.listTasks(request.params.id, { limit, before }) };
});
// The tasks of every Dot that ended in a state after a time, the latest end first (the web's failed tasks of the day).
app.get<{ Querystring: { status?: unknown; finished_after?: unknown } }>("/api/tasks", async (request): Promise<TasksAnswer> => ({
tasks: await scheduler.listEndedTasks(request.query.status, request.query.finished_after),
}));
app.get<{ Params: Params }>("/api/tasks/:id", async (request) => scheduler.getTask(request.params.id));
app.post<{ Params: Params }>("/api/tasks/:id/cancel", async (request) => scheduler.cancelTask(request.params.id));
// Computer
app.get<{ Params: Params }>("/api/dots/:id/computer", async (request) => scheduler.computer(request.params.id));
const lifecycleRoute = (action: "start" | "stop" | "reboot") =>
app.post<{ Params: Params }>(`/api/dots/:id/computer/${action}`, async (request, reply) => {
const id = request.params.id;
const answer =
action === "start"
? await scheduler.startComputer(id)
: action === "stop"
? await scheduler.stopComputer(id)
: await scheduler.rebootComputer(id);
return reply.code(202).send(answer);
});
lifecycleRoute("start");
lifecycleRoute("stop");
lifecycleRoute("reboot");
app.get<{ Params: Params }>("/api/dots/:id/computer/screenshot", async (request, reply) => {
const png = await scheduler.screenshot(request.params.id);
return reply.code(200).header("content-type", "image/png").header("cache-control", "no-store").send(Buffer.from(png));
});
// Browser identities
app.get<{ Params: Params }>(
"/api/dots/:id/browser-identities",
async (request): Promise<IdentitiesAnswer> => ({ identities: await scheduler.listIdentities(request.params.id) }),
);
app.post<{ Params: Params }>("/api/dots/:id/browser-identities", async (request, reply) => {
const body = bodyOf(request);
const identity = await scheduler.createIdentity(request.params.id, {
name: body.name as string,
proxy: body.proxy as string | undefined,
});
return reply.code(201).send(identity);
});
app.get<{ Params: Params }>("/api/dots/:id/browser-identities/:identityId", async (request) =>
scheduler.getIdentity(request.params.id, request.params.identityId),
);
app.delete<{ Params: Params }>("/api/dots/:id/browser-identities/:identityId", async (request, reply) => {
await scheduler.deleteIdentity(request.params.id, request.params.identityId);
return reply.code(204).send();
});
app.get<{ Params: Params }>("/api/dots/:id/browser-identities/:identityId/frame", async (request, reply) => {
const jpeg = await scheduler.identityFrame(request.params.id, request.params.identityId);
return reply.code(200).header("content-type", "image/jpeg").header("cache-control", "no-store").send(Buffer.from(jpeg));
});
app.post<{ Params: Params }>("/api/dots/:id/browser-identities/:identityId/close", async (request, reply) => {
await scheduler.closeIdentity(request.params.id, request.params.identityId);
return reply.code(204).send();
});
// The tools (its table) and the skills are the engine's, so they need the computer running
app.get<{ Params: Params }>(
"/api/dots/:id/tools",
async (request): Promise<ToolListAnswer> => scheduler.listTools(request.params.id),
);
app.get<{ Params: Params }>(
"/api/dots/:id/skills",
async (request): Promise<SkillListAnswer> => ({ skills: await scheduler.listSkills(request.params.id) }),
);
// Channels (the hub never returns a credential, and no route here echoes one)
app.get<{ Params: Params }>(
"/api/dots/:id/channels",
async (request): Promise<ChannelsAnswer> => ({ channels: await channels.list(request.params.id), available: channels.kinds }),
);
// Every channel of every Dot, each with its Dot's id (the web's shell watches the ones that need the person).
app.get("/api/channels", async (): Promise<EveryChannelAnswer> => ({ channels: await channels.listEvery() }));
// Link the Dot to a Telegram bot, or give the linked one a new token (a revoked token is the only way back from needs_relink).
app.put<{ Params: Params }>("/api/dots/:id/channels/telegram", async (request, reply): Promise<ChannelRecord> => {
const token = bodyOf(request).token;
if (typeof token !== "string" || token.trim() === "") throw bad("token must be the bot token from @BotFather");
const credentials = { [TELEGRAM_TOKEN_SECRET]: token.trim() };
const linked = (await channels.list(request.params.id)).some((channel) => channel.kind === "telegram");
if (linked) return channels.setCredentials(request.params.id, "telegram", credentials);
return reply.code(201).send(await channels.add(request.params.id, "telegram", { credentials }));
});
// Link WhatsApp: the channel starts and shows a code; the person scans it (the stream below) with the phone that holds the number.
app.post<{ Params: Params }>("/api/dots/:id/channels/whatsapp/link", async (request, reply): Promise<ChannelRecord> => {
return reply.code(202).send(await channels.link(request.params.id, "whatsapp"));
});
// The codes to scan and how the link ends, as server-sent events of `ChannelLinkFrame`. The code is a way into the account for as long as it is shown: it is not stored and the reply is never cached.
app.get<{ Params: Params }>("/api/dots/:id/channels/whatsapp/qr", async (request, reply) => {
const controller = new AbortController();
const frames = await channels.watchLink(request.params.id, "whatsapp", controller.signal);
await eventStream(reply, controller, async (raw) => {
for await (const frame of frames) raw.write(`data: ${JSON.stringify(frame)}\n\n`);
});
});
app.patch<{ Params: Params }>("/api/dots/:id/channels/:kind", async (request): Promise<ChannelRecord> => {
const kind = channelKind(request.params.kind);
const { settings, enabled } = bodyOf(request);
if (settings === undefined && enabled === undefined) throw bad("give settings, enabled, or both");
if (enabled !== undefined && typeof enabled !== "boolean") throw bad("enabled must be true or false");
let record: ChannelRecord | undefined;
if (settings !== undefined) record = await channels.setSettings(request.params.id, kind, settings);
if (enabled !== undefined) record = await channels.setEnabled(request.params.id, kind, enabled);
return record!;
});
app.delete<{ Params: Params }>("/api/dots/:id/channels/:kind", async (request, reply) => {
await channels.remove(request.params.id, channelKind(request.params.kind));
return reply.code(204).send();
});
app.post<{ Params: Params }>("/api/dots/:id/channels/:kind/pairing", async (request, reply): Promise<ChannelPairingAnswer> => {
return reply.code(201).send(await channels.pair(request.params.id, channelKind(request.params.kind)));
});
app.delete<{ Params: Params }>("/api/dots/:id/channels/:kind/peers/:peer", async (request, reply) => {
await channels.removePeer(request.params.id, channelKind(request.params.kind), request.params.peer);
return reply.code(204).send();
});
// Approvals
app.get<{ Querystring: { status?: unknown; limit?: unknown; order?: unknown; before?: unknown; dot_id?: unknown } }>("/api/approvals", async (request): Promise<ApprovalsAnswer> => {
const { status, order, before, dot_id: dotId } = request.query;
if (dotId !== undefined && (typeof dotId !== "string" || dotId === "")) throw bad("dot_id must be the id or the name of one Dot");
if (status !== undefined && typeof status !== "string") throw bad("status must be one list, comma-separated");
const statuses = status?.split(",").map((one) => one.trim());
if (statuses?.some((one) => !(APPROVAL_STATUSES as readonly string[]).includes(one))) {
throw bad(`status must be one or more of ${APPROVAL_STATUSES.join(", ")}`);
}
const limit = intParam(request.query.limit, "limit", APPROVAL_LIST_LIMIT);
if (limit === 0) throw bad(`limit must be between 1 and ${APPROVAL_LIST_LIMIT}`);
if (before !== undefined && (typeof before !== "string" || before === "")) throw bad("before must be the id of an approval");
return {
approvals: await scheduler.listApprovals(statuses as ApprovalStatus[] | undefined, { limit, order: orderParam(order, before), before, dot: dotId }),
};
});
for (const decision of ["approve", "reject"] as const) {
app.post<{ Params: Params }>(`/api/approvals/:id/${decision}`, async (request) => {
const { note, always } = bodyOf(request);
return scheduler.resolveApproval(request.params.id, decision, { note, always });
});
}
// Events and usage
app.get<{ Params: Params; Querystring: { since?: string } }>(
"/api/dots/:id/usage",
async (request): Promise<UsageAnswer> => scheduler.usage(request.params.id, sinceParam(request.query.since)),
);
app.get<{ Params: Params; Querystring: { after?: string; before?: string; limit?: string; types?: unknown; tools?: unknown; task_id?: unknown; order?: unknown } }>(
"/api/dots/:id/events",
async (request): Promise<EventsAnswer> => {
const after = intParam(request.query.after, "after");
const before = intParam(request.query.before, "before");
const limit = intParam(request.query.limit, "limit", MAX_EVENT_PAGE);
const { types, tools, task_id: taskId, order } = request.query;
if (types !== undefined && typeof types !== "string") throw bad("types must be one comma-separated list");
if (tools !== undefined && typeof tools !== "string") throw bad("tools must be one comma-separated list");
if (taskId !== undefined && typeof taskId !== "string") throw bad("task_id must be a single value");
const listOrder = orderParam(order, request.query.before);
const list = (value: string | undefined) => value?.split(",").map((one) => one.trim()).filter(Boolean);
return {
events: await scheduler.listEvents(request.params.id, {
after,
before,
limit,
types: list(types),
tools: list(tools),
taskId,
order: listOrder,
}),
};
},
);
// Files of the Dot's computer, under /home/dot, read-only; they need the computer running (409 computer_stopped).
app.get<{ Params: Params; Querystring: { path?: unknown } }>(
"/api/dots/:id/files/list",
async (request): Promise<FilesListAnswer> => scheduler.listFiles(request.params.id, request.query.path as string | undefined),
);
app.get<{ Params: Params; Querystring: { path?: unknown } }>("/api/dots/:id/files", async (request, reply) => {
const { path, content } = await scheduler.readFile(request.params.id, request.query.path);
const { contentType, disposition } = serveFile(path);
return reply
.code(200)
.header("content-type", contentType)
.header("content-disposition", disposition)
.header("x-content-type-options", "nosniff")
.header("content-security-policy", "default-src 'none'; sandbox")
.header("cache-control", "no-store")
.send(Buffer.from(content));
});
app.get<{ Querystring: { dot_id?: string; after?: string } }>("/api/stream", async (request, reply) => {
const dotId = request.query.dot_id ? await scheduler.historyDotId(request.query.dot_id) : undefined;
// EventSource sends the last id it saw when it reconnects; it wins over the query.
const lastEventId = request.headers["last-event-id"];
const after = intParam(typeof lastEventId === "string" ? lastEventId : request.query.after, "after");
const controller = new AbortController();
await eventStream(reply, controller, async (raw) => {
try {
for await (const event of scheduler.events.stream(dotId === undefined ? {} : { dotId }, { after, signal: controller.signal })) {
raw.write(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`);
}
} catch (error) {
// The client reconnects with its last id; tell it why the server hung up.
const code = error instanceof StreamOverflowError ? "stream_overflow" : "stream_error";
raw.write(`event: ${STREAM_ERROR_EVENT}\ndata: ${JSON.stringify({ error: code, message: errorMessage(error) })}\n\n`);
log.warn("event stream closed with an error", { error: errorMessage(error) });
}
});
});
// Secrets
app.put("/api/secrets/openrouter", async (request) => {
const body = bodyOf(request);
return scheduler.setOpenRouterKey(body.value, body.dot_id);
});
// A Dot's VM proxy: write-only, the answers say only whether there is one.
app.get<{ Params: { id: string } }>("/api/dots/:id/proxy", async (request) => scheduler.vmProxy(request.params.id));
app.put<{ Params: { id: string } }>("/api/dots/:id/proxy", async (request) => scheduler.setVmProxy(request.params.id, bodyOf(request).value));
app.delete<{ Params: { id: string } }>("/api/dots/:id/proxy", async (request) => scheduler.setVmProxy(request.params.id, null));
// The secrets of a Dot's MCP servers: write-only, the answers say only which are set.
type McpSecretParams = { id: string; server: string; name: string };
app.get<{ Params: Params }>("/api/dots/:id/mcp-secrets", async (request): Promise<McpSecretsAnswer> => scheduler.mcpSecrets(request.params.id));
app.put<{ Params: McpSecretParams }>(
"/api/dots/:id/mcp-secrets/:server/:name",
async (request): Promise<McpSecretsAnswer> => // A PUT sets: a null value is no value (a clear is the DELETE).
scheduler.setMcpSecret(request.params.id, request.params.server, request.params.name, bodyOf(request).value ?? undefined),
);
app.delete<{ Params: McpSecretParams }>(
"/api/dots/:id/mcp-secrets/:server/:name",
async (request): Promise<McpSecretsAnswer> => scheduler.setMcpSecret(request.params.id, request.params.server, request.params.name, null),
);
return app;
}
export type { FastifyInstance, FastifyReply };
+217
View File
@@ -0,0 +1,217 @@
/**
* The control plane as one process (architecture section 2): the database,
* the vm-manager, the scheduler and the HTTP API, composed in the order a
* first start needs. `invisible-dots server` calls `startServer` and waits
* with `untilStopSignal`; tests call `startServer` and close the handle
* themselves.
*/
import { fileURLToPath } from "node:url";
import {
ChannelHub,
TelegramChannelType,
WhatsAppChannelType,
whatsappClientDir,
whatsappClientProblem,
type ChannelType,
} from "@invisible-dots/channels";
import type { Database } from "@invisible-dots/database";
import type { HostFacts } from "@invisible-dots/sdk/types";
import { Scheduler, type ComputerDriver, type SchedulerOptions } from "@invisible-dots/scheduler";
import { ensureDir, ENV, errorMessage, hostPaths, type HostPaths, type Logger, prefixedStderrLogger } from "@invisible-dots/shared";
import { hostDoctorDeps, openRouterCheck, runDoctor, type DoctorDeps } from "@invisible-dots/vm-manager";
import { loadOrCreateApiToken, loadOrGenerateMasterKey, parseListen, saveMasterKey, type ListenAddress } from "./config.js";
import { assertNothingEncrypted, openControlPlaneDatabase } from "./database.js";
import { createImageChecks } from "./doctor.js";
import { acquireServerLock } from "./lock.js";
import { API_VERSION, buildServer, type FastifyInstance } from "./server.js";
import { createVmDriver } from "./vm-driver.js";
export interface StartServerOptions {
/** Where every setting is read from; default `process.env`. */
env?: Record<string, string | undefined>;
/** Overrides INVISIBLE_DOTS_LISTEN; port 0 picks a free port (tests). */
listen?: string;
logger?: Logger;
/** The VM layer; default the real one over QEMU. Tests pass a fake. */
driver?: ComputerDriver;
/** Replaces single dependencies of the host report (QEMU discovery, the runner, the disk, the images); the OpenRouter row always asks the Scheduler. Tests pass fakes. */
doctor?: Partial<DoctorDeps>;
/** Passed through to the Scheduler (timers, lifecycle and dispatcher tuning). */
scheduler?: Omit<SchedulerOptions, "db" | "driver" | "logger">;
/** The kinds of messaging channel this server can run; default Telegram, and WhatsApp when INVISIBLE_DOTS_WHATSAPP=1. Tests pass fakes. */
channelTypes?: readonly ChannelType[];
/** Where the opt-in WhatsApp client is installed; default `optional/whatsapp` of the repository. Tests point it at a folder of their own. */
whatsappClientDir?: string;
}
/** The logger of the `invisible-dots` process: lines on stderr prefixed with its name, debug lines when INVISIBLE_DOTS_DEBUG=1. */
export function serverLogger(env: Record<string, string | undefined> = process.env): Logger {
return prefixedStderrLogger("invisible-dots", env.INVISIBLE_DOTS_DEBUG === "1");
}
export interface RunningServer {
/** The base URL clients use, e.g. http://127.0.0.1:8787. */
url: string;
address: ListenAddress;
token: string;
paths: HostPaths;
/** "pglite" or "pg", as the database adapter reports it. */
database: string;
/** Secrets this start created because they did not exist yet. */
created: { apiToken: boolean; masterKey: boolean };
app: FastifyInstance;
scheduler: Scheduler;
/** The messaging channels of the Dots: pairing, and the messages between a chat and its Dot. */
channels: ChannelHub;
db: Database;
/** Stop the API, the channels, the scheduler and the database. VMs keep running. Idempotent. */
close(): Promise<void>;
}
/**
* Where the opt-in WhatsApp client is installed, under the repository root. apps/api/src/start.ts and the command
* bundle apps/cli/dist/invisible-dots.mjs sit at the same depth, so the root is the same for both.
*/
const DEFAULT_CLIENT_DIR = whatsappClientDir(fileURLToPath(new URL("../../../", import.meta.url)));
/**
* The channels a server runs when it is not told otherwise. WhatsApp is opt-in twice: its adapter is an unofficial
* client that WhatsApp can answer with a ban of the linked account, so a server that was not asked for it neither
* loads nor offers it; and its client library is not part of the default install (it is GPL-3.0 through libsignal),
* so it is installed by the person who turns WhatsApp on.
*/
export function defaultChannelTypes(env: Record<string, string | undefined>, clientDir: string = DEFAULT_CLIENT_DIR): ChannelType[] {
return [new TelegramChannelType(), ...(env[ENV.WHATSAPP] === "1" ? [new WhatsAppChannelType({ baileys: { clientDir } })] : [])];
}
/**
* Start the control plane and resolve once the API listens. Every resource
* taken before a failure is released again, so a failed start leaves no
* lock, open database or listening socket behind.
*/
export async function startServer(options: StartServerOptions = {}): Promise<RunningServer> {
const env = options.env ?? process.env;
const debug = env.INVISIBLE_DOTS_DEBUG === "1";
const logger = options.logger ?? serverLogger(env);
const paths = hostPaths(env);
const listen = parseListen(options.listen ?? (env[ENV.LISTEN] || undefined));
const cleanup: (() => Promise<unknown>)[] = [];
const unwind = async () => {
for (const step of cleanup.splice(0).reverse()) {
await step().catch((error) => logger.error("cleanup step failed", { error: errorMessage(error) }));
}
};
try {
const lock = await acquireServerLock(paths);
cleanup.push(() => lock.release());
await ensureDir(paths.logsDir, 0o700);
const [token, masterKey] = await Promise.all([loadOrCreateApiToken(paths, env), loadOrGenerateMasterKey(paths)]);
if (token.created) logger.info("created the API token", { path: token.origin });
const db = await openControlPlaneDatabase({ env, masterKey: masterKey.value, logger });
cleanup.push(() => db.close());
if (masterKey.created) {
// Only an empty database may get a new key; see loadOrGenerateMasterKey.
await assertNothingEncrypted(db, masterKey.origin);
await saveMasterKey(paths, masterKey.value);
logger.info("created the master key", { path: masterKey.origin });
}
const driver = options.driver ?? createVmDriver({ env, paths, logger: prefixedStderrLogger("vm-manager", debug) });
const scheduler = new Scheduler({
...options.scheduler,
db,
driver,
logger: options.logger ?? prefixedStderrLogger("scheduler", debug),
});
cleanup.push(() => scheduler.close());
const clientDir = options.whatsappClientDir ?? DEFAULT_CLIENT_DIR;
const clientProblem = !options.channelTypes && env[ENV.WHATSAPP] === "1" ? whatsappClientProblem(clientDir) : null;
if (clientProblem) logger.warn(`${ENV.WHATSAPP}=1 is set. ${clientProblem.message}`);
// Next to the Scheduler, using only what it offers; registered after it, so it stops first.
const channels = new ChannelHub({
db,
host: scheduler,
types: options.channelTypes ?? defaultChannelTypes(env, clientDir),
logger: options.logger ?? prefixedStderrLogger("channels", debug),
});
cleanup.push(() => channels.close());
const doctorDeps: DoctorDeps = {
...hostDoctorDeps({
env,
home: paths.home,
images: createImageChecks(paths),
openRouterKey: async () => openRouterCheck((await scheduler.health()).openrouter_configured),
}),
...options.doctor,
};
const facts: HostFacts = { database_kind: db.kind, data_dir: paths.home, logs_dir: paths.logsDir };
const app = buildServer({ scheduler, channels, doctor: () => runDoctor(doctorDeps), host: facts, token: token.value, logger });
cleanup.push(() => app.close());
await scheduler.start();
await channels.start();
await app.listen({ host: listen.host, port: listen.port });
const bound = app.server.address();
const address: ListenAddress = typeof bound === "object" && bound ? { host: listen.host, port: bound.port } : listen;
const host = address.host.includes(":") ? `[${address.host}]` : address.host;
const url = `http://${host}:${address.port}`;
logger.info(`invisible-dots server ${API_VERSION} listening`, { url, home: paths.home, database: db.kind });
let closing: Promise<void> | null = null;
return {
url,
address,
token: token.value,
paths,
database: db.kind,
created: { apiToken: token.created, masterKey: masterKey.created },
app,
scheduler,
channels,
db,
close: () => (closing ??= unwind()),
};
} catch (error) {
await unwind();
throw error;
}
}
/**
* Every way the person or the system asks a foreground server to stop:
* Ctrl+C, a service manager, a closed terminal (SIGHUP; Node raises it on
* Windows too when the console window is closed, a few seconds before
* Windows ends the process) and Ctrl+Break on Windows (SIGBREAK). All take
* the same clean path, which closes the embedded database and releases the
* lock. They are event names Node accepts on both hosts; one that a host
* never raises simply never fires.
*/
export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const;
/**
* Resolve once `close` has finished after the first stop signal; a second
* signal exits at once. `signals` is the process, or a stand-in in tests.
*/
export function untilStopSignal(close: () => Promise<void>, logger: Logger, signals: NodeJS.EventEmitter = process): Promise<void> {
return new Promise<void>((resolve) => {
let stopping = false;
const onSignal = (signal: NodeJS.Signals) => {
if (stopping) {
logger.warn(`second ${signal}, exiting without waiting`);
process.exit(1);
}
stopping = true;
logger.info(`${signal} received, shutting down (VMs keep running)`);
void close().finally(() => {
for (const name of STOP_SIGNALS) signals.off(name, onSignal);
logger.info("stopped");
resolve();
});
};
for (const name of STOP_SIGNALS) signals.on(name, onSignal);
});
}
+137
View File
@@ -0,0 +1,137 @@
/**
* The real ComputerDriver: an adapter over vm-manager's VmManager (QEMU
* started directly, sections 3.4 and 3.5). It adds the one decision
* VmManager leaves to its caller, which images a computer uses:
*
* - a new computer gets the newest `golden-<version>.qcow2`; its overlay
* stays on that golden image for its whole life (a backing file never
* changes under a disk);
* - every start uses the newest `runtime-<version>.iso`, so a new version of
* our code reaches a Dot with a restart (section 3.3).
*/
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { type ComputerDriver, type ComputerSpecInput, type ComputerState, type CreatedComputer, type GuestApi, type GuestEndpoint, type StartedComputer, type WaitForHealthOptions } from "@invisible-dots/scheduler";
import { type HealthAnswer, type HostPaths, type ImageKind, imageLabel, imageVersionOf, type Logger, pathExists } from "@invisible-dots/shared";
import { VmManager, type StartVmResult, type VmSpec } from "@invisible-dots/vm-manager";
/** Compare versions such as "2026.10.1" and "2026.9.3" numerically, part by part. */
export function compareVersions(a: string, b: string): number {
return a.localeCompare(b, "en", { numeric: true, sensitivity: "base" });
}
/** The image of that kind in `dir` with the highest version, as an absolute path. */
export async function latestImage(dir: string, kind: ImageKind): Promise<string> {
const label = imageLabel(kind);
let names: string[];
try {
names = await readdir(dir);
} catch (error) {
throw new Error(`cannot list the images directory ${dir}: ${(error as Error).message}`, { cause: error });
}
const candidates = names
.map((name) => ({ name, version: imageVersionOf(kind, name) }))
.filter((c): c is { name: string; version: string } => c.version !== undefined)
.sort((x, y) => compareVersions(x.version, y.version));
const newest = candidates.at(-1);
if (!newest) throw new Error(`no ${label} in ${dir}: build one with "invisible-dots image build"`);
return join(dir, newest.name);
}
export const latestGoldenImage = (imagesDir: string) => latestImage(imagesDir, "golden");
export const latestRuntimeImage = (imagesDir: string) => latestImage(imagesDir, "runtime");
export class VmManagerDriver implements ComputerDriver {
readonly #vm: VmManager;
readonly #imagesDir: string;
constructor(vm: VmManager, options: { imagesDir?: string } = {}) {
this.#vm = vm;
this.#imagesDir = options.imagesDir ?? vm.paths.imagesDir;
}
latestGolden(): Promise<string> {
return latestGoldenImage(this.#imagesDir);
}
latestRuntime(): Promise<string> {
return latestRuntimeImage(this.#imagesDir);
}
#spec(input: ComputerSpecInput, goldenImage: string, runtimeImage: string): VmSpec {
return {
dotId: input.dotId,
token: input.token,
...(input.proxy ? { proxy: input.proxy } : {}),
goldenImage,
runtimeImage,
cpus: input.resources.cpus,
memoryMiB: input.resources.memoryMiB,
diskBytes: input.resources.diskBytes,
};
}
#started(result: StartVmResult, runtimeImage: string): StartedComputer {
return { guestPort: result.guestPort, pid: result.pid, runtimeImage, alreadyRunning: result.alreadyRunning };
}
async create(input: ComputerSpecInput): Promise<CreatedComputer> {
const [golden, runtime] = await Promise.all([this.latestGolden(), this.latestRuntime()]);
await this.#vm.create(this.#spec(input, golden, runtime));
return { goldenImage: golden, runtimeImage: runtime };
}
async start(input: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer> {
const runtime = await this.latestRuntime();
const spec = this.#spec(input, input.goldenImage, runtime);
// A bigger computer.disk from a PATCH: the overlay can only grow while the VM is off.
if ((await this.#vm.state(input.dotId)).state === "STOPPED") await this.#vm.resize(spec);
return this.#started(await this.#vm.start(spec), runtime);
}
waitForHealth(endpoint: GuestEndpoint, token: string, options: WaitForHealthOptions): Promise<HealthAnswer> {
return this.#vm.waitForGuestHealth(endpoint.dotId, endpoint.port, token, {
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
requestTimeoutMs: options.requestTimeoutMs,
});
}
async stop(dotId: string, token: string): Promise<{ forced: boolean }> {
const { forced } = await this.#vm.stop(dotId, token);
return { forced };
}
async reboot(input: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer> {
const runtime = await this.latestRuntime();
return this.#started(await this.#vm.reboot(this.#spec(input, input.goldenImage, runtime)), runtime);
}
destroy(dotId: string): Promise<void> {
return this.#vm.destroy(dotId);
}
async state(dotId: string): Promise<ComputerState> {
const [info, exists] = await Promise.all([this.#vm.state(dotId), pathExists(this.#vm.paths.diskPath(dotId))]);
return {
exists,
state: info.state,
pid: info.pid,
guestPort: info.guestPort,
detail: info.detail ?? null,
};
}
guest(endpoint: GuestEndpoint, token: string): GuestApi {
return this.#vm.guestClient(endpoint.port, token);
}
/** VmManager holds no connection between calls, so there is nothing to release. */
async close(): Promise<void> {}
}
/** The driver `invisible-dots server` uses: VmManager over this host's QEMU and INVISIBLE_DOTS_HOME. */
export function createVmDriver(options: { env: Record<string, string | undefined>; paths: HostPaths; logger: Logger }): VmManagerDriver {
return new VmManagerDriver(new VmManager({ env: options.env, paths: options.paths, logger: options.logger }));
}
+779
View File
@@ -0,0 +1,779 @@
import type { AddressInfo } from "node:net";
import { ChannelHub } from "@invisible-dots/channels";
import { FakeChannelType } from "@invisible-dots/channels/testing";
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import { Scheduler } from "@invisible-dots/scheduler";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "@invisible-dots/scheduler/testing";
import { ApiError, InvisibleDotsClient } from "@invisible-dots/sdk";
import { MAX_EVENT_PAGE, OPENROUTER_KEY_RULE, type DoctorCheck, type StoredEvent } from "@invisible-dots/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { API_VERSION, buildServer, type FastifyInstance } from "../src/index.js";
import { hostFacts } from "./host-facts.js";
const TOKEN = "test-token-0123456789abcdef";
/** What the host report answers on this fake host: one row that is not ok, with the command that fixes it. */
const REPORT: DoctorCheck[] = [
{ id: "node", label: "Node", status: "ok", detail: "24.19.0" },
{ id: "qemu", label: "QEMU", status: "missing", detail: "qemu-system-x86_64 not found", fix: "invisible-dots setup" },
];
const yaml = (name: string, idle = "15m") =>
`name: ${name}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: ${idle}\n`;
describe.each(testAdapters())("control-plane API (%s)", (kind) => {
let t: TestDatabase;
let db: Database;
let app: FastifyInstance;
let scheduler: Scheduler;
let channels: ChannelHub;
let driver: FakeDriver;
let clock: ManualClock;
let base: string;
let api: InvisibleDotsClient;
beforeAll(async () => {
t = await createTestDatabase(kind);
db = t.db;
// A Dot reaches READY only with a key to push (lifecycle #push): every test that readies one needs it, so it is
// stored here and not by whichever test happened to run first.
await db.secrets.put("global", "openrouter_api_key", "sk-or-test");
driver = new FakeDriver();
clock = new ManualClock();
scheduler = new Scheduler({
db,
driver,
clock,
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10 },
dispatcher: { retryDelayMs: 0 },
});
channels = new ChannelHub({ db, host: scheduler, types: [new FakeChannelType()] });
app = buildServer({ scheduler, channels, doctor: async () => REPORT, host: hostFacts(kind), token: TOKEN, heartbeatMs: 50 });
await app.listen({ host: "127.0.0.1", port: 0 });
base = `http://127.0.0.1:${(app.server.address() as AddressInfo).port}`;
api = new InvisibleDotsClient({ baseUrl: base, token: TOKEN });
});
afterAll(async () => {
await app?.close();
await channels?.close();
await scheduler?.close();
await t?.drop();
});
async function readyDot(name: string, idle?: string) {
const dot = await api.createDot(yaml(name, idle));
await waitUntilSettledReady(scheduler, driver, dot.id, name);
return dot;
}
it("refuses requests without the right bearer token with 401 JSON", async () => {
const none = await fetch(`${base}/api/dots`);
expect(none.status).toBe(401);
expect(await none.json()).toMatchObject({ error: "unauthorized" });
const wrong = await fetch(`${base}/api/health`, { headers: { authorization: "Bearer nope" } });
expect(wrong.status).toBe(401);
const stream = await fetch(`${base}/api/stream`, { headers: { authorization: "Basic abc" } });
expect(stream.status).toBe(401);
await expect(new InvisibleDotsClient({ baseUrl: base, token: "wrong-token-xxxxxxxx" }).health()).rejects.toMatchObject({
status: 401,
code: "unauthorized",
});
});
it("doctor: the host report with its verdict, only with the token", async () => {
expect((await fetch(`${base}/api/doctor`)).status).toBe(401);
expect(await api.doctor()).toEqual({ ok: false, checks: REPORT });
});
it("health, unknown routes and malformed bodies answer {error, message}", async () => {
expect(await api.health()).toEqual({ status: "ok", database: "ok", version: API_VERSION, openrouter_configured: true, ...hostFacts(kind) });
const missing = await fetch(`${base}/api/nope`, { headers: { authorization: `Bearer ${TOKEN}` } });
expect(missing.status).toBe(404);
expect(await missing.json()).toMatchObject({ error: "not_found" });
const broken = await fetch(`${base}/api/dots`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: "{not json",
});
expect(broken.status).toBe(400);
expect(await broken.json()).toMatchObject({ error: "invalid_request" });
});
it("usage: the spend the guest reported, since a moment, from the event log", async () => {
const dot = await readyDot("usage-one");
const other = await readyDot("usage-two");
expect(await api.usage(dot.id)).toEqual({ dot_id: dot.id, since: null, spent_usd: 0 });
const guest = driver.guestOf(dot.id);
guest.onInbound = () => {};
const task = await api.createTask(dot.id, { description: "costly" });
await waitFor(async () => (await db.tasks.runs(task.id))[0]?.delivered_at, "delivered");
guest.emit("task.progress", { task_id: task.id, text: "step", spent_usd: 0.25 });
guest.emit("task.completed", { task_id: task.id, summary: "done", spent_usd: 0.5 });
guest.emit("message.assistant", { text: "hello", spent_usd: 0.125 });
driver.guestOf(other.id).emit("message.assistant", { text: "not mine", spent_usd: 9 });
await waitFor(async () => (await api.getTask(task.id)).status === "COMPLETED", "completed");
await waitFor(async () => (await api.usage(dot.id)).spent_usd === 0.625, "chat answer stored");
expect(await api.usage(dot.name)).toEqual({ dot_id: dot.id, since: null, spent_usd: 0.625 });
expect((await api.getTask(task.id)).spent_usd).toBe(0.5);
expect((await api.listTasks(dot.id))[0]?.spent_usd).toBe(0.5);
const future = new Date(Date.now() + 3_600_000).toISOString();
expect(await api.usage(dot.id, { since: future })).toEqual({ dot_id: dot.id, since: future, spent_usd: 0 });
const past = new Date(Date.now() - 3_600_000).toISOString();
expect((await api.usage(dot.id, { since: past })).spent_usd).toBe(0.625);
});
it("usage: a bad since is 400, an unknown Dot 404, and a deleted Dot's history stays readable by id", async () => {
const dot = await readyDot("usage-three");
await expect(api.usage(dot.id, { since: "yesterday" })).rejects.toMatchObject({ status: 400, code: "invalid_request" });
await expect(api.usage(dot.id, { since: "2026-13-45T00:00:00Z" })).rejects.toMatchObject({ status: 400 });
await expect(api.usage("no-such-dot")).rejects.toMatchObject({ status: 404 });
driver.guestOf(dot.id).emit("message.assistant", { text: "paid", spent_usd: 0.5 });
await waitFor(async () => (await api.usage(dot.id)).spent_usd === 0.5, "spend stored");
await api.deleteDot(dot.id);
await waitFor(async () => (await db.dots.get(dot.id)) === null, "deleted");
expect(await api.usage(dot.id)).toEqual({ dot_id: dot.id, since: null, spent_usd: 0.5 });
});
it("create validates the config: 400 with the issues, 409 for a taken name", async () => {
const invalid = await api.createDot({ name: "Bad Name", model: { provider: "openrouter", id: "m" } }).catch((e) => e);
expect(invalid).toBeInstanceOf(ApiError);
expect(invalid).toMatchObject({ status: 400, code: "invalid_config" });
expect((invalid as ApiError).details).toEqual([expect.objectContaining({ path: "name" })]);
await readyDot("taken-name");
await expect(api.createDot(yaml("taken-name"))).rejects.toMatchObject({ status: 409, code: "name_taken" });
});
it("create -> READY -> task -> guest events -> COMPLETED, visible in tasks, events and the stream", async () => {
const controller = new AbortController();
const seen: StoredEvent[] = [];
const streaming = (async () => {
for await (const event of api.stream({ signal: controller.signal })) {
seen.push(event);
if (event.type === "task.completed") break;
}
})();
const dot = await readyDot("flow-dot");
expect((await api.listDots()).map((d) => d.name)).toContain("flow-dot");
expect(await api.getDot("flow-dot")).toMatchObject({ id: dot.id, computer_state: "RUNNING" });
const computer = await api.computer(dot.id);
expect(computer).toMatchObject({ state: "RUNNING", ready: true });
const task = await api.createTask("flow-dot", { description: "find the cheapest day", priority: 1 });
expect(task.status).toBe("PENDING");
await waitFor(async () => (await api.getTask(task.id)).status === "COMPLETED", "task COMPLETED");
expect((await api.listTasks(dot.id)).map((x) => x.id)).toContain(task.id);
await streaming;
controller.abort();
expect(seen.map((e) => e.type)).toEqual(expect.arrayContaining(["dot.created", "computer.started", "task.created", "task.completed"]));
const events = await api.events(dot.id);
expect(events.find((e) => e.type === "task.completed")?.data).toMatchObject({ task_id: task.id });
const after = events[1]!.id;
expect((await api.events(dot.id, { after, limit: 2 })).map((e) => e.id)).toEqual(events.slice(2, 4).map((e) => e.id));
await expect(api.events(dot.id, { limit: MAX_EVENT_PAGE + 1 })).rejects.toMatchObject({ status: 400 });
await expect(api.cancelTask(task.id)).rejects.toMatchObject({ status: 409, code: "task_finished" });
});
it("events: filtered by type names and by task, together and with after and limit", async () => {
const dot = await readyDot("event-filters");
const guest = driver.guestOf(dot.id);
const one = await api.createTask(dot.id, { description: "one" });
const two = await api.createTask(dot.id, { description: "two" });
// Two whole tasks, one after the other: 0.6 s on PGlite, 2.2 s on a PostgreSQL reached across WSL, past the
// default 5 s when that machine is loaded. The wait bounds liveness only.
await waitFor(async () => (await api.getTask(two.id)).status === "COMPLETED", "second task done", 15_000);
const call = { tool: "exec", permission: "exec.run", decision: "allow", ok: true, duration_ms: 5 } as const;
guest.emit("tool.called", { task_id: one.id, ...call, target: "ls" });
guest.emit("tool.called", { ...call, target: "date" });
guest.emit("browser.identity.created", { identity_id: "bi_shop", name: "shop" });
await waitFor(async () => (await api.events(dot.id, { types: ["browser.identity.created"] })).length === 1, "events stored");
const types = (events: StoredEvent[]) => events.map((e) => e.type);
expect(types(await api.events(dot.id, { types: ["tool.called"] }))).toEqual(["tool.called", "tool.called"]);
expect(types(await api.events(dot.id, { types: ["tool.called", "browser.identity.created"] }))).toEqual(["tool.called", "tool.called", "browser.identity.created"]);
// A task's events: the host's own and the guest's, in id order; the chat's tool call belongs to no task.
const ofOne = await api.events(dot.id, { taskId: one.id });
expect(types(ofOne)).toEqual(expect.arrayContaining(["task.created", "task.started", "task.completed", "tool.called"]));
expect(ofOne.every((e) => e.data.task_id === one.id)).toBe(true);
expect(ofOne.map((e) => e.id)).toEqual([...ofOne.map((e) => e.id)].sort((a, b) => a - b));
expect(ofOne.filter((e) => e.type === "tool.called").map((e) => e.data.target)).toEqual(["ls"]);
expect((await api.events(dot.id, { taskId: two.id, types: ["tool.called"] })).length).toBe(0);
expect(types(await api.events(dot.id, { taskId: one.id, types: ["tool.called"] }))).toEqual(["tool.called"]);
expect((await api.events(dot.id, { taskId: one.id, limit: 1 })).length).toBe(1);
expect((await api.events(dot.id, { taskId: one.id, after: ofOne.at(-1)!.id })).length).toBe(0);
expect(await api.events(dot.id, { taskId: "task_nobody" })).toEqual([]);
// The raw query: an empty types is no filter, a type nobody emits is refused, so is a repeated or empty parameter.
const get = async (query: string) => {
const response = await fetch(`${base}/api/dots/${dot.id}/events?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } });
return { status: response.status, body: (await response.json()) as { events?: StoredEvent[]; error?: string; message?: string } };
};
expect((await get("types=")).body.events!.length).toBeGreaterThan(5);
expect(await get("types=tool.called,tool.calls")).toMatchObject({ status: 400, body: { error: "invalid_request", message: "unknown event type: tool.calls" } });
expect((await get("types=tool.called&types=browser.identity.created")).status).toBe(400);
// The Dot keeps its notes itself: a note written is no event, and asking for one is asking for a type nobody emits.
expect(await get("types=memory.written")).toMatchObject({ status: 400, body: { message: "unknown event type: memory.written" } });
expect((await get("task_id=a&task_id=b")).status).toBe(400);
expect((await get("task_id=")).status).toBe(400);
// Newest first, and a tool filter that narrows tool.called only: the newest browser call is found past the calls of other tools.
const browser = { ...call, tool: "browser_navigate", target: "x-1: https://example.com/" };
guest.emit("tool.called", browser);
guest.emit("tool.called", { ...call, target: "pwd" });
guest.emit("tool.called", { ...call, target: "whoami" });
await waitFor(async () => (await api.events(dot.id, { types: ["tool.called"] })).length === 5, "more events stored");
const newest = await api.events(dot.id, { types: ["tool.called"], order: "desc", limit: 2 });
expect(newest.map((e) => e.data.target)).toEqual(["whoami", "pwd"]);
const browserCalls = await api.events(dot.id, { types: ["tool.called"], tools: ["browser_navigate", "browser_click"], order: "desc", limit: 1 });
expect(browserCalls.map((e) => e.data.target)).toEqual(["x-1: https://example.com/"]);
expect((await api.events(dot.id, { types: ["tool.called", "browser.identity.created"], tools: ["browser_click"] })).map((e) => e.type)).toEqual(["browser.identity.created"]);
// `before` goes on, older, from the oldest event of a newest-first page, and only there.
const calls = await api.events(dot.id, { types: ["tool.called"], order: "desc", limit: 2 });
expect((await api.events(dot.id, { types: ["tool.called"], order: "desc", limit: 2, before: calls.at(-1)!.id })).map((e) => e.data.target)).toEqual(["x-1: https://example.com/", "date"]);
expect(await get(`before=${calls.at(-1)!.id}`)).toMatchObject({ status: 400, body: { message: "before pages a list in order=desc" } });
expect((await get("order=desc&before=-1")).status).toBe(400);
expect((await get("order=sideways")).status).toBe(400);
expect((await get("order=desc&order=asc")).status).toBe(400);
expect((await get("tools=a&tools=b")).status).toBe(400);
await expect(api.events("no-such-dot", { types: ["tool.called"] })).rejects.toMatchObject({ status: 404 });
});
it("files: list and read what is under /home/dot, as the guest answers, and nothing else", async () => {
const dot = await readyDot("file-reader");
const guest = driver.guestOf(dot.id);
const note = "# Fares\n\nCheapest day is Tuesday.\n";
guest.putFile("/home/dot/memory/fares.md", note, new Date("2026-10-01T10:00:00Z"));
guest.putFile("/home/dot/memory/trips/rome.md", "Rome", new Date("2026-10-02T10:00:00Z"));
guest.putFile("/home/dot/workspace/shot.png", Uint8Array.from([0x89, 0x50, 0x4e, 0x47]));
guest.putFile("/home/dot/notes.txt", "hi");
guest.putFile("/etc/passwd", "root:x:0:0");
expect(await api.listFiles(dot.id, "/home/dot/memory")).toEqual({
path: "/home/dot/memory",
entries: [
{ name: "fares.md", type: "file", size: note.length, mtime: "2026-10-01T10:00:00.000Z" },
{ name: "trips", type: "dir", size: 0, mtime: "2026-10-02T10:00:00.000Z" },
],
});
// Home by default, and relative or ~ paths mean the same as the absolute one.
const home = await api.listFiles(dot.id);
expect(home.path).toBe("/home/dot");
expect(home.entries.map((e) => e.name)).toEqual(["memory", "notes.txt", "workspace"]);
expect(await api.listFiles(dot.id, "~/memory")).toEqual(await api.listFiles(dot.id, "memory/"));
expect(new TextDecoder().decode(await api.readFile(dot.id, "/home/dot/memory/fares.md"))).toBe(note);
expect(new TextDecoder().decode(await api.readFile(dot.id, "memory/trips/rome.md"))).toBe("Rome");
expect([...(await api.readFile(dot.id, "workspace/shot.png"))]).toEqual([0x89, 0x50, 0x4e, 0x47]);
// Outside home, or with a .. segment: 400 before the guest is asked, whatever the guest holds.
const callsBefore = guest.calls.length;
for (const path of ["/etc/passwd", "/home", "/home/dotter/x", "../../etc/passwd", "memory/../../../etc/passwd", "/home/dot/memory/.."]) {
await expect(api.readFile(dot.id, path), path).rejects.toMatchObject({ status: 400, code: "invalid_path" });
await expect(api.listFiles(dot.id, path), path).rejects.toMatchObject({ status: 400, code: "invalid_path" });
}
expect(guest.calls.length).toBe(callsBefore);
const raw = (route: string, query: string) => fetch(`${base}/api/dots/${dot.id}/${route}${query}`, { headers: { authorization: `Bearer ${TOKEN}` } });
expect((await raw("files", "")).status).toBe(400);
expect((await raw("files", "?path=")).status).toBe(400);
expect((await raw("files", "?path=a&path=b")).status).toBe(400);
expect((await raw("files/list", "?path=")).status).toBe(400);
expect((await fetch(`${base}/api/dots/${dot.id}/files?path=notes.txt`)).status).toBe(401);
// The guest's own refusals pass through with their code.
await expect(api.readFile(dot.id, "memory/missing.md")).rejects.toMatchObject({ status: 404, code: "not_found" });
await expect(api.listFiles(dot.id, "memory/missing")).rejects.toMatchObject({ status: 404, code: "not_found" });
await expect(api.readFile(dot.id, "memory")).rejects.toMatchObject({ status: 400, code: "is_a_directory" });
await expect(api.listFiles(dot.id, "notes.txt")).rejects.toMatchObject({ status: 400, code: "not_a_directory" });
// A link under home that leads out of it passes the path rule and is the guest's 403, with nothing of the target in the answer.
guest.putFile("/proc/4242/environ", "PROXY_PASSWORD=hunter2");
guest.link("/home/dot/environ", "/proc/4242/environ");
const leak = await raw("files", `?path=${encodeURIComponent("environ")}`);
expect(leak.status).toBe(403);
expect(await leak.json()).toMatchObject({ error: "outside_home" });
await expect(api.readFile(dot.id, "environ")).rejects.toMatchObject({ status: 403, code: "outside_home" });
});
it("files: a read says what it is, so the page never runs a Dot's file; a large one is refused", async () => {
const dot = await readyDot("file-types");
const guest = driver.guestOf(dot.id);
guest.putFile("/home/dot/page.html", "<script>alert(1)</script>");
guest.putFile("/home/dot/pic.png", Uint8Array.from([1, 2, 3]));
guest.putFile("/home/dot/data.bin", Uint8Array.from([4, 5]));
guest.putFile("/home/dot/é \"q\".txt", "x");
guest.putFile("/home/dot/big.bin", new Uint8Array(16 * 1024 * 1024 + 1));
guest.putFile("/home/dot/limit.bin", new Uint8Array(16 * 1024 * 1024));
const read = (path: string) => fetch(`${base}/api/dots/${dot.id}/files?path=${encodeURIComponent(path)}`, { headers: { authorization: `Bearer ${TOKEN}` } });
const html = await read("page.html");
expect(html.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(html.headers.get("content-disposition")).toMatch(/^inline; filename="page\.html"/);
expect(html.headers.get("x-content-type-options")).toBe("nosniff");
expect(html.headers.get("content-security-policy")).toBe("default-src 'none'; sandbox");
expect(html.headers.get("cache-control")).toBe("no-store");
expect(await html.text()).toBe("<script>alert(1)</script>");
expect((await read("pic.png")).headers.get("content-type")).toBe("image/png");
const bin = await read("data.bin");
expect(bin.headers.get("content-type")).toBe("application/octet-stream");
expect(bin.headers.get("content-disposition")).toMatch(/^attachment; filename="data\.bin"/);
expect((await read("é \"q\".txt")).headers.get("content-disposition")).toBe(
"inline; filename=\"_ _q_.txt\"; filename*=UTF-8''%C3%A9%20%22q%22.txt",
);
expect((await read("limit.bin")).status).toBe(200);
const big = await read("big.bin");
expect(big.status).toBe(413);
expect(await big.json()).toMatchObject({ error: "file_too_large" });
await expect(api.readFile(dot.id, "big.bin")).rejects.toMatchObject({ status: 413, code: "file_too_large" });
});
it("files need a running computer (409 computer_stopped) and a Dot that exists", async () => {
const dot = await readyDot("file-sleeper");
await api.stopComputer(dot.id);
await scheduler.settle();
await expect(api.listFiles(dot.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await expect(api.readFile(dot.id, "notes.txt")).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await api.startComputer(dot.id);
await waitFor(async () => (await api.computer(dot.id)).ready, "started again");
await expect(api.listFiles("no-such-dot")).rejects.toMatchObject({ status: 404 });
await expect(api.readFile("no-such-dot", "a")).rejects.toMatchObject({ status: 404 });
});
it("a tool table that follows the permissions, and no automations route: the jobs are the Dot's own", async () => {
const dot = await readyDot("automations");
expect((await fetch(`${base}/api/dots/${dot.id}/automations`, { headers: { authorization: `Bearer ${TOKEN}` } })).status).toBe(404);
expect((await fetch(`${base}/api/dots/${dot.id}/tools`)).status).toBe(401);
// Tools: the table, and what the model is offered follows the permissions the config pushed.
const { tools: none, mcp_servers } = await api.listTools(dot.id);
expect(mcp_servers).toEqual([]);
expect(none.map((t) => t.name)).toEqual([
"exec",
"read_file",
"write_file",
"cron",
"browser_identity_list",
"browser_identity_create",
]);
// The pushed config has the defaults filled in, none of them deny: every tool is offered.
expect(none.every((t) => t.offered)).toBe(true);
await api.updateDot(dot.id, `${yaml("automations")}permissions:
computer.exec: allow
files.read: ask
files.write: deny
automations: deny
`);
await waitFor(async () => (await api.listTools(dot.id)).tools.some((t) => !t.offered), "permissions pushed");
expect(Object.fromEntries((await api.listTools(dot.id)).tools.map((t) => [t.name, t.offered]))).toEqual({
exec: true,
read_file: true,
write_file: false,
cron: false,
browser_identity_list: true,
browser_identity_create: true,
});
});
it("the tools need a running computer (409 computer_stopped) and a Dot that exists", async () => {
const dot = await readyDot("automation-sleeper");
await api.stopComputer(dot.id);
await scheduler.settle();
await expect(api.listTools(dot.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await expect(api.listSkills(dot.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await api.startComputer(dot.id);
await waitFor(async () => (await api.computer(dot.id)).ready, "started again");
await expect(api.listTools("no-such-dot")).rejects.toMatchObject({ status: 404 });
await expect(api.listSkills("no-such-dot")).rejects.toMatchObject({ status: 404 });
});
it("the skills are the engine's, the built-in ones and the Dot's own, each with its whole file", async () => {
const dot = await readyDot("skilled");
const guest = driver.guestOf(dot.id);
const own = { name: "shop-login", description: "Log in to the shop.", source: "dot" as const, path: "/home/dot/skills/shop-login/SKILL.md", content: "---\nname: shop-login\n---\n" };
guest.skills = [...guest.skills, own];
const skills = await api.listSkills(dot.id);
expect(skills.map((s) => [s.name, s.source])).toEqual([["invisible-playwright", "builtin"], ["shop-login", "dot"]]);
expect(skills[1]).toEqual(own);
expect((await fetch(`${base}/api/dots/${dot.id}/skills`)).status).toBe(401);
});
it("the stream filters by Dot and replays after an id without duplicates", async () => {
const a = await readyDot("stream-a");
const b = await readyDot("stream-b");
const history = await api.events(a.id);
const controller = new AbortController();
const got: StoredEvent[] = [];
const reading = (async () => {
for await (const event of api.stream({ dotId: "stream-a", after: history[0]!.id, signal: controller.signal })) {
got.push(event);
if (event.type === "browser.identity.created") break;
}
})();
driver.guestOf(b.id).emit("browser.identity.created", { identity_id: "bi_b", name: "b-only" });
driver.guestOf(a.id).emit("browser.identity.created", { identity_id: "bi_a", name: "a-only" });
await reading;
controller.abort();
expect(got.every((e) => e.dot_id === a.id)).toBe(true);
expect(got.map((e) => e.id)).toEqual([...new Set(got.map((e) => e.id))]);
expect(got[0]?.id).toBe(history[1]?.id);
expect(got.at(-1)?.data).toMatchObject({ name: "a-only" });
});
it("approval flow over HTTP", async () => {
const dot = await readyDot("asks-first");
const guest = driver.guestOf(dot.id);
const original = guest.onInbound;
guest.onInbound = (event, g) => {
if (event.type === "task.created") g.requestApproval(event.data.task_id);
else return original(event, g);
};
const task = await api.createTask(dot.id, { description: "delete an identity" });
const pending = await waitFor(
async () => (await api.listApprovals("pending")).find((a) => a.dot_id === dot.id),
"approval pending",
);
expect(pending.task_id).toBe(task.id);
await waitFor(async () => (await api.getTask(task.id)).status === "WAITING_APPROVAL", "task waiting");
const rejected = await api.reject(pending.id, { note: "keep it" });
expect(rejected).toMatchObject({ status: "rejected", note: "keep it" });
await waitFor(async () => (await api.getTask(task.id)).status === "COMPLETED", "task resumed and completed");
await expect(api.approve(pending.id)).rejects.toMatchObject({ status: 409, code: "already_resolved" });
await expect(api.approve("apr_missing")).rejects.toMatchObject({ status: 404 });
const raw = await fetch(`${base}/api/approvals?status=maybe`, { headers: { authorization: `Bearer ${TOKEN}` } });
expect(raw.status).toBe(400);
});
it("approvals: several statuses at once, newest answer first, a limit that keeps the newest and a cursor", async () => {
const dot = await readyDot("asks-often");
const guest = driver.guestOf(dot.id);
const ids = [guest.requestApproval(undefined), guest.requestApproval(undefined), guest.requestApproval(undefined)];
await waitFor(async () => (await api.listApprovals("pending")).filter((a) => a.dot_id === dot.id).length === 3, "three pending");
await api.reject(ids[1]!, {});
await api.approve(ids[0]!, {});
const mine = async (...args: Parameters<typeof api.listApprovals>) => (await api.listApprovals(...args)).filter((a) => a.dot_id === dot.id).map((a) => a.id);
// Answered, the last answered first (ids[0] after ids[1]); the one still pending is not among them.
expect(await mine(["approved", "rejected", "expired"], { order: "desc" })).toEqual([ids[0], ids[1]]);
expect(await mine(["pending", "rejected"])).toEqual([ids[1], ids[2]]);
const newestOne = await api.listApprovals(["approved", "rejected", "expired"], { order: "desc", limit: 1 });
expect(newestOne.map((a) => a.id)).toEqual([ids[0]]);
expect((await api.listApprovals(["approved", "rejected", "expired"], { order: "desc", limit: 1, before: ids[0]! })).map((a) => a.id)).toEqual([ids[1]]);
// Of one Dot, chosen in the database: by id or by name, and a Dot that does not exist has none.
const other = await readyDot("asks-rarely");
const otherAsk = driver.guestOf(other.id).requestApproval(undefined);
await waitFor(async () => (await api.listApprovals("pending", { dot: other.id })).length === 1, "the other Dot's approval");
expect((await api.listApprovals("pending", { dot: other.id })).map((a) => a.id)).toEqual([otherAsk]);
expect((await api.listApprovals("pending", { dot: "asks-rarely" })).map((a) => a.id)).toEqual([otherAsk]);
expect((await api.listApprovals(["approved", "rejected"], { dot: dot.id, order: "desc" })).map((a) => a.id)).toEqual([ids[0], ids[1]]);
expect(await api.listApprovals("pending", { dot: "dot_nobody" })).toEqual([]);
const get = async (query: string) => (await fetch(`${base}/api/approvals?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status;
expect(await get("dot_id=")).toBe(400);
expect(await get(`dot_id=${dot.id}&dot_id=${other.id}`)).toBe(400);
expect(await get("status=approved,maybe")).toBe(400);
expect(await get("status=")).toBe(400);
expect(await get("status=approved&status=rejected")).toBe(400);
expect(await get("order=newest")).toBe(400);
expect(await get("limit=0")).toBe(400);
expect(await get("limit=501")).toBe(400);
expect(await get(`order=desc&before=${ids[0]}&limit=1`)).toBe(200);
expect(await get(`before=${ids[0]}`)).toBe(400);
expect(await get("order=desc&before=")).toBe(400);
});
it("approve with always over HTTP sets the permission in the Dot's config and pushes it", async () => {
const dot = await readyDot("always-http");
const guest = driver.guestOf(dot.id);
const approvalId = guest.requestApproval(undefined);
await waitFor(async () => (await api.listApprovals("pending")).some((a) => a.id === approvalId), "approval pending");
const post = (id: string, decision: string, body: unknown) =>
fetch(`${base}/api/approvals/${id}/${decision}`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
expect((await post(approvalId, "approve", { always: "yes" })).status).toBe(400);
expect((await post(approvalId, "approve", { always: false })).status).toBe(400);
expect((await post(approvalId, "reject", { always: true })).status).toBe(400);
expect((await api.listApprovals("pending")).some((a) => a.id === approvalId)).toBe(true);
expect((await api.getDot(dot.id)).config.permissions).toEqual({});
expect(await api.approve(approvalId, { note: "yes", always: true })).toMatchObject({ status: "approved", note: "yes" });
expect((await api.getDot(dot.id)).config.permissions["browser.identity.delete"]).toBe("allow");
expect(guest.config?.permissions["browser.identity.delete"]).toBe("allow");
const resolved = (await api.events(dot.id, { types: ["approval.resolved"] })).at(-1);
expect(resolved?.data).toEqual({ approval_id: approvalId, decision: "approve", note: "yes", always: true });
await expect(api.approve(approvalId, { always: true })).rejects.toMatchObject({ status: 409, code: "already_resolved" });
});
it("messages: delivered to a READY Dot and readable as a conversation", async () => {
const dot = await readyDot("talker");
const sent = await api.sendMessage("talker", "what did you find?");
expect(sent.delivery).toBe("delivered");
await waitFor(async () => (await api.messages(dot.id)).length === 2, "reply");
expect((await api.messages(dot.id)).map((m) => m.role)).toEqual(["user", "assistant"]);
await expect(api.sendMessage(dot.id, " ")).rejects.toMatchObject({ status: 400 });
});
it("tasks: the newest first, a page at a time, and a cursor that goes on through the rest", async () => {
const dot = await readyDot("many-tasks");
const other = await readyDot("other-tasks");
const made: string[] = [];
for (const description of ["one", "two", "three", "four", "five"]) {
made.push((await api.createTask(dot.id, { description })).id);
await new Promise((resolve) => setTimeout(resolve, 5));
}
const foreign = (await api.createTask(other.id, { description: "not mine" })).id;
const newestFirst = [...made].reverse();
expect((await api.listTasks(dot.id)).map((t) => t.id)).toEqual(newestFirst);
const first = await api.listTasks(dot.id, { limit: 2 });
expect(first.map((t) => t.id)).toEqual(newestFirst.slice(0, 2));
const second = await api.listTasks(dot.id, { limit: 2, before: first.at(-1)!.id });
expect(second.map((t) => t.id)).toEqual(newestFirst.slice(2, 4));
expect((await api.listTasks(dot.id, { limit: 2, before: second.at(-1)!.id })).map((t) => t.id)).toEqual(newestFirst.slice(4));
expect(await api.listTasks(dot.id, { before: made[0]! })).toEqual([]);
// Another Dot's task is no cursor here, and nothing of it is listed.
expect(await api.listTasks(dot.id, { before: foreign })).toEqual([]);
const get = async (query: string) => (await fetch(`${base}/api/dots/${dot.id}/tasks?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status;
expect(await get("limit=0")).toBe(400);
expect(await get("limit=201")).toBe(400);
expect(await get("limit=x")).toBe(400);
expect(await get("before=")).toBe(400);
expect(await get("limit=200&before=nobody")).toBe(200);
});
it("every Dot in one read: the tasks that failed since a time, latest first, and every channel with its Dot", async () => {
const a = await readyDot("every-a");
const b = await readyDot("every-b");
const since = new Date(Date.now() - 1);
const failed = async (dotId: string, description: string) => {
const guest = driver.guestOf(dotId);
guest.onInbound = () => {};
const task = await api.createTask(dotId, { description });
await waitFor(async () => (await db.tasks.runs(task.id))[0]?.delivered_at, "delivered");
guest.emit("task.failed", { task_id: task.id, error: "boom" });
await waitFor(async () => (await api.getTask(task.id)).status === "FAILED", "failed");
return task.id;
};
const first = await failed(a.id, "first");
const second = await failed(b.id, "second");
const ours = (tasks: { id: string }[]) => tasks.map((t) => t.id).filter((id) => id === first || id === second);
expect(ours(await api.endedTasks("FAILED", since))).toEqual([second, first]);
expect(ours(await api.endedTasks("COMPLETED", since))).toEqual([]);
expect(await api.endedTasks("FAILED", new Date(Date.now() + 60_000))).toEqual([]);
const get = async (query: string) => (await fetch(`${base}/api/tasks?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status;
expect(await get("status=RUNNING&finished_after=2026-01-01T00:00:00Z")).toBe(400);
expect(await get("status=FAILED&finished_after=yesterday")).toBe(400);
expect(await get("status=FAILED")).toBe(400);
await channels.add(a.id, "telegram");
await channels.add(b.id, "telegram");
const every = (await api.everyChannel()).filter((c) => c.dot_id === a.id || c.dot_id === b.id);
expect(every.map((c) => [c.dot_id, c.kind])).toEqual([
[a.id, "telegram"],
[b.id, "telegram"],
]);
expect(JSON.stringify(every)).not.toMatch(/token/i);
// The channels run until they are removed: this test stops what it started, so no later test or teardown inherits them.
await channels.remove(a.id, "telegram");
await channels.remove(b.id, "telegram");
expect((await api.everyChannel()).filter((c) => c.dot_id === a.id || c.dot_id === b.id)).toEqual([]);
});
it("messages: the newest page of a long conversation, and a cursor that goes back through the rest", async () => {
const dot = await readyDot("chatty");
for (const [index, word] of ["one", "two", "three"].entries()) {
await api.sendMessage(dot.id, word);
await waitFor(async () => (await api.messages(dot.id)).length === 2 * (index + 1), `reply to ${word}`);
}
const all = await api.messages(dot.id);
expect(all.length).toBe(6);
// The default is the oldest first, so a limit keeps the oldest: only `order=desc` reaches the newest.
expect((await api.messages(dot.id, { limit: 2 })).map((m) => m.event_id)).toEqual(all.slice(0, 2).map((m) => m.event_id));
const newest = await api.messages(dot.id, { order: "desc", limit: 2 });
expect(newest.map((m) => m.event_id)).toEqual(all.slice(4).map((m) => m.event_id).reverse());
const older = await api.messages(dot.id, { order: "desc", limit: 3, before: newest.at(-1)!.event_id });
expect(older.map((m) => m.event_id)).toEqual(all.slice(1, 4).map((m) => m.event_id).reverse());
expect(await api.messages(dot.id, { order: "desc", before: all[0]!.event_id })).toEqual([]);
const get = async (query: string) => (await fetch(`${base}/api/dots/${dot.id}/messages?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status;
expect(await get("limit=0")).toBe(400);
expect(await get("limit=501")).toBe(400);
expect(await get("order=newest")).toBe(400);
expect(await get(`before=${all[0]!.event_id}`)).toBe(400);
expect(await get("order=desc&before=-1")).toBe(400);
expect(await get(`order=desc&before=${all[0]!.event_id}&limit=1`)).toBe(200);
});
it("messages: the HTTP route cannot claim a channel origin", async () => {
const dot = await readyDot("impostor");
const origin = { channel: "telegram", binding_id: "chb_1", chat_id: "1", external_id: "2" };
const raw = await fetch(`${base}/api/dots/${dot.id}/messages`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: JSON.stringify({ text: "I am Telegram", origin }),
});
expect(raw.status).toBe(202);
await waitFor(async () => (await api.messages(dot.id)).length === 2, "reply");
const [user] = await api.messages(dot.id);
expect(user).toMatchObject({ role: "user", text: "I am Telegram" });
expect("origin" in user!).toBe(false);
});
it("browser identities and screenshots need a running computer (409 computer_stopped)", async () => {
const dot = await readyDot("browsing");
const identity = await api.createIdentity(dot.id, { name: "Main account" });
expect(identity.name).toBe("Main account");
expect((await api.listIdentities(dot.id)).map((i) => i.id)).toEqual([identity.id]);
expect((await api.getIdentity(dot.id, identity.id)).id).toBe(identity.id);
await expect(api.getIdentity(dot.id, "nobody-abc123")).rejects.toMatchObject({ status: 404, code: "not_found" });
const png = await api.screenshot(dot.id);
expect([...png.slice(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]);
await api.deleteIdentity(dot.id, identity.id);
expect(await api.stopComputer(dot.id)).toEqual({ accepted: true });
await scheduler.settle();
expect((await api.computer(dot.id)).state).toBe("STOPPED");
expect((await api.getDot(dot.id)).status).toBe("IDLE");
await expect(api.listIdentities(dot.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await expect(api.createIdentity(dot.id, { name: "x" })).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await expect(api.screenshot(dot.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await api.startComputer(dot.id);
await waitFor(async () => (await api.computer(dot.id)).ready, "started again");
});
it("an identity needs no proxy: a name alone, a null or a blank proxy make one with none, and a proxy is an explicit option", async () => {
const dot = await readyDot("no-proxy-needed");
const create = async (body: Record<string, unknown>) => {
const response = await fetch(`${base}/api/dots/${dot.id}/browser-identities`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: response.status, identity: (await response.json()) as Record<string, unknown> };
};
for (const body of [{ name: "Plain" }, { name: "Null", proxy: null }, { name: "Blank", proxy: " " }]) {
const { status, identity } = await create(body);
expect(status, JSON.stringify(body)).toBe(201);
expect(identity, JSON.stringify(body)).toMatchObject({ hasProxy: false });
}
const sdk = await api.createIdentity(dot.id, { name: "Through the SDK" });
expect(sdk.hasProxy).toBe(false);
expect((await api.listIdentities(dot.id)).some((i) => i.hasProxy || "proxy" in i)).toBe(false);
// The proxy is a secret: it is passed on as written, and the identity says only that it has one.
const own = await api.createIdentity(dot.id, { name: "Own exit", proxy: "socks5://user:[email protected]:1080" });
expect(own.hasProxy).toBe(true);
expect(own).not.toHaveProperty("proxy");
const listed = JSON.stringify(await api.listIdentities(dot.id));
expect(listed).not.toContain("hunter2");
expect(listed).not.toContain("proxy.test");
expect((await create({ name: "Number", proxy: 8080 })).status).toBe(400);
});
it("an MCP server's secrets are set and cleared through the API and the SDK, and no answer carries a value", async () => {
const dot = await api.createDot(`${yaml("mcp-api")}mcp_servers:\n web:\n url: https://web.example/mcp\n secrets: [Authorization]\n`);
await waitUntilSettledReady(scheduler, driver, dot.id, "mcp-api");
const route = `${base}/api/dots/${dot.id}/mcp-secrets/web/Authorization`;
expect((await fetch(route, { method: "PUT", body: JSON.stringify({ value: "x" }), headers: { "content-type": "application/json" } })).status).toBe(401);
expect((await fetch(`${base}/api/dots/${dot.id}/mcp-secrets`)).status).toBe(401);
const set = await api.setMcpSecret(dot.id, "web", "Authorization", "Bearer api-1");
expect(set).toEqual({ dot_id: dot.id, secrets: [{ server: "web", name: "Authorization", set: true }] });
expect(driver.guestOf(dot.id).mcpSecrets).toEqual({ web: { Authorization: "Bearer api-1" } });
expect(JSON.stringify(await api.mcpSecrets(dot.id))).not.toContain("api-1");
// A PUT with no value is refused, not taken as a clear; the DELETE clears.
const empty = await fetch(route, { method: "PUT", headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ value: null }) });
expect(empty.status).toBe(400);
expect((await api.setMcpSecret(dot.id, "web", "Authorization", null)).secrets[0]!.set).toBe(false);
await expect(api.setMcpSecret(dot.id, "nope", "Authorization", "x")).rejects.toMatchObject({ status: 404 });
});
it("an identity's frame is a JPEG of an open one, and close ends it, through the API and the SDK", async () => {
const dot = await readyDot("viewing");
const identity = await api.createIdentity(dot.id, { name: "Main account" });
await expect(api.getIdentityFrame(dot.id, identity.id)).rejects.toMatchObject({ status: 409, code: "not_open" });
await expect(api.getIdentityFrame(dot.id, "nobody-abc123")).rejects.toMatchObject({ status: 404, code: "not_found" });
driver.guestOf(dot.id).launchIdentity(identity.id);
const jpeg = await api.getIdentityFrame(dot.id, identity.id);
expect([...jpeg]).toEqual([0xff, 0xd8, 0xff, 0xd9]);
const raw = await fetch(`${base}/api/dots/${dot.id}/browser-identities/${identity.id}/frame`, {
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(raw.headers.get("content-type")).toBe("image/jpeg");
expect(raw.headers.get("cache-control")).toBe("no-store");
driver.guestOf(dot.id).identityBusy = true;
await expect(api.getIdentityFrame(dot.id, identity.id)).rejects.toMatchObject({ status: 503, code: "busy" });
driver.guestOf(dot.id).identityBusy = false;
await api.closeIdentity(dot.id, identity.id);
expect((await api.getIdentity(dot.id, identity.id)).status).toBe("available");
await expect(api.getIdentityFrame(dot.id, identity.id)).rejects.toMatchObject({ status: 409, code: "not_open" });
await expect(api.closeIdentity(dot.id, "nobody-abc123")).rejects.toMatchObject({ status: 404, code: "not_found" });
await api.stopComputer(dot.id);
await scheduler.settle();
await expect(api.getIdentityFrame(dot.id, identity.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
await expect(api.closeIdentity(dot.id, identity.id)).rejects.toMatchObject({ status: 409, code: "computer_stopped" });
});
it("sleeps after idle_timeout (fake clock) and a new task wakes it", async () => {
const dot = await readyDot("napper", "5m");
clock.advance(6 * 60_000);
expect(await scheduler.idleCheck()).toContain(dot.id);
await scheduler.settle();
expect((await api.computer(dot.id)).state).toBe("STOPPED");
const task = await api.createTask(dot.id, { description: "wake up" });
await waitFor(async () => (await api.getTask(task.id)).status === "COMPLETED", "task after wake");
expect((await api.computer(dot.id)).state).toBe("RUNNING");
});
it("the secret route refuses a key that cannot travel in a header with 400, naming the rule and never the key", async () => {
const dot = await readyDot("key-check");
expect(await api.setOpenRouterKey("sk-or-good", dot.id)).toEqual({ pushed: 1 });
for (const bad of ["sk-or-v1-SECRETHEAD\nSECRETTAIL", "sk-or-v1-SECRET HEAD", "sk-or-v1-SECRETüHEAD", "SECRET\u0000HEAD"]) {
const refused = await api.setOpenRouterKey(bad, dot.id).catch((error: unknown) => error);
expect(refused).toBeInstanceOf(ApiError);
expect(refused).toMatchObject({ status: 400, code: "invalid_request" });
expect((refused as ApiError).message).toContain(OPENROUTER_KEY_RULE);
expect((refused as ApiError).message).not.toContain("SECRET");
}
// Nothing was stored or pushed by the refusals.
expect(await db.secrets.get(dot.id, "openrouter_api_key")).toBe("sk-or-good");
expect(driver.guestOf(dot.id).openrouterKey).toBe("sk-or-good");
// A pasted key with its ends trimmed is the key.
await api.setOpenRouterKey(" sk-or-padded\n", dot.id);
expect(driver.guestOf(dot.id).openrouterKey).toBe("sk-or-padded");
});
it("PATCH updates the config; DELETE removes the Dot; the secret route stores the key", async () => {
const dot = await readyDot("to-patch");
const updated = await api.updateDot(dot.id, `${yaml("to-patch")}instructions: short answers\n`);
expect(updated.config.instructions).toBe("short answers");
expect(driver.guestOf(dot.id).config?.instructions).toBe("short answers");
// A save from an old read is a 409 over the wire, and a malformed read is a 400.
const body = `${yaml("to-patch")}instructions: from a stale form\n`;
await expect(api.updateDot(dot.id, body, dot.config_version)).rejects.toMatchObject({ status: 409, code: "dot_changed" });
expect((await api.updateDot(dot.id, body, updated.config_version)).config.instructions).toBe("from a stale form");
await expect(api.updateDot(dot.id, body, 1.5)).rejects.toMatchObject({ status: 400, code: "invalid_request" });
expect(await api.setOpenRouterKey("sk-or-rotated", "to-patch")).toEqual({ pushed: 1 });
expect(driver.guestOf(dot.id).openrouterKey).toBe("sk-or-rotated");
await expect(api.setOpenRouterKey("")).rejects.toMatchObject({ status: 400 });
expect(await api.deleteDot("to-patch")).toEqual({ accepted: true });
await scheduler.settle();
await expect(api.getDot("to-patch")).rejects.toMatchObject({ status: 404 });
expect((await api.events(dot.id)).at(-1)?.type).toBe("dot.deleted");
});
});
+234
View File
@@ -0,0 +1,234 @@
import type { AddressInfo } from "node:net";
import { ChannelHub, TelegramChannelType } from "@invisible-dots/channels";
import { FakeBotApi } from "@invisible-dots/channels/testing";
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import { Scheduler } from "@invisible-dots/scheduler";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "@invisible-dots/scheduler/testing";
import { InvisibleDotsClient } from "@invisible-dots/sdk";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildServer, type FastifyInstance } from "../src/index.js";
import { hostFacts } from "./host-facts.js";
const API_TOKEN = "test-token-0123456789abcdef";
const BOT_TOKEN = "123456:SECRET-TOKEN-VALUE";
const OTHER_BOT_TOKEN = "654321:OTHER-SECRET-TOKEN-VALUE";
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe.each(testAdapters())("channel routes of the control-plane API (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
let db: Database;
let bots: FakeBotApi;
let app: FastifyInstance;
let scheduler: Scheduler;
let hub: ChannelHub;
let driver: FakeDriver;
let base: string;
let api: InvisibleDotsClient;
let seq = 0;
beforeAll(async () => {
t = await createTestDatabase(kind);
db = t.db;
await db.secrets.put("global", "openrouter_api_key", "sk-or-test");
bots = await FakeBotApi.start();
driver = new FakeDriver();
scheduler = new Scheduler({
db,
driver,
clock: new ManualClock(),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10 },
dispatcher: { retryDelayMs: 0 },
});
hub = new ChannelHub({
db,
host: scheduler,
types: [new TelegramChannelType({ apiRoot: bots.apiRoot, pollSeconds: 30 })],
backoff: { initialMs: 1, maxMs: 5, jitter: 0 },
});
app = buildServer({ scheduler, channels: hub, doctor: async () => [], host: hostFacts(), token: API_TOKEN });
await scheduler.start();
await hub.start();
await app.listen({ host: "127.0.0.1", port: 0 });
base = `http://127.0.0.1:${(app.server.address() as AddressInfo).port}`;
api = new InvisibleDotsClient({ baseUrl: base, token: API_TOKEN });
}, 60_000);
beforeEach(() => {
bots.addBot(BOT_TOKEN, "dot_helper_bot");
bots.addBot(OTHER_BOT_TOKEN, "second_bot");
});
afterEach(async () => {
// A bot serves one Dot: what a test linked must not keep the next test from linking it.
for (const dot of await api.listDots()) {
for (const channel of await api.channels(dot.id)) await api.removeChannel(dot.id, channel.kind);
}
});
afterAll(async () => {
await app?.close();
await hub?.close();
await scheduler?.close();
await bots?.close();
await t?.drop();
});
async function readyDot() {
const name = `chan-${++seq}`;
const dot = await api.createDot(yaml(name));
await waitUntilSettledReady(scheduler, driver, dot.id, name);
return dot;
}
/** A raw request, to see the status and the body exactly as sent. */
async function raw(method: string, path: string, body?: unknown) {
const response = await fetch(`${base}${path}`, {
method,
headers: { authorization: `Bearer ${API_TOKEN}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, text, json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
}
it("lists nothing for a Dot without channels, and needs the API token like every route", async () => {
const dot = await readyDot();
expect(await api.channels(dot.id)).toEqual([]);
for (const [method, path] of [
["GET", `/api/dots/${dot.id}/channels`],
["PUT", `/api/dots/${dot.id}/channels/telegram`],
["PATCH", `/api/dots/${dot.id}/channels/telegram`],
["DELETE", `/api/dots/${dot.id}/channels/telegram`],
["POST", `/api/dots/${dot.id}/channels/telegram/pairing`],
["DELETE", `/api/dots/${dot.id}/channels/telegram/peers/10`],
] as const) {
const response = await fetch(`${base}${path}`, { method });
expect(response.status, `${method} ${path}`).toBe(401);
}
});
it("links a bot (201), shows its name, never returns the token, and replaces the token of a linked bot (200)", async () => {
const dot = await readyDot();
const created = await raw("PUT", `/api/dots/${dot.name}/channels/telegram`, { token: ` ${BOT_TOKEN} ` });
expect(created.status).toBe(201);
expect(created.json).toMatchObject({ kind: "telegram", enabled: true, account: "dot_helper_bot", peers: [], settings: { approvals: true, notify_tasks: true } });
expect(created.text).not.toContain("SECRET-TOKEN");
expect(await db.secrets.get(dot.id, "telegram_bot_token")).toBe(BOT_TOKEN);
expect(JSON.stringify(await api.channels(dot.id))).not.toContain("SECRET-TOKEN");
await waitFor(async () => (await api.channels(dot.name))[0]!.status === "connected", "connected");
const replaced = await raw("PUT", `/api/dots/${dot.id}/channels/telegram`, { token: OTHER_BOT_TOKEN });
expect(replaced.status).toBe(200);
expect(replaced.json).toMatchObject({ kind: "telegram", account: "second_bot" });
expect(replaced.text).not.toContain("SECRET-TOKEN");
expect(await db.secrets.get(dot.id, "telegram_bot_token")).toBe(OTHER_BOT_TOKEN);
expect(await api.channels(dot.id)).toHaveLength(1);
});
it("pairs a person through the code, lists them, and revokes them", async () => {
const dot = await readyDot();
await api.putTelegramChannel(dot.id, BOT_TOKEN);
await waitFor(() => bots.polling(BOT_TOKEN), "polling");
const pairing = await api.pairChannel(dot.id, "telegram");
expect(pairing.code).toMatch(/^[A-Z2-9]{8}$/);
expect(pairing.deep_link).toBe(`https://t.me/dot_helper_bot?start=${pairing.code}`);
expect(new Date(pairing.expires_at).getTime()).toBeGreaterThan(Date.now());
expect((await raw("POST", `/api/dots/${dot.id}/channels/telegram/pairing`)).status).toBe(201);
bots.say(BOT_TOKEN, `/start ${pairing.code}`, { id: 10, first_name: "Ann" });
await waitFor(async () => (await api.channels(dot.id))[0]!.peers.length === 1, "the person to be paired");
expect((await api.channels(dot.id))[0]!.peers[0]).toMatchObject({ peer_id: "10", role: "owner", label: "Ann" });
expect((await raw("DELETE", `/api/dots/${dot.id}/channels/telegram/peers/10`)).status).toBe(204);
expect((await api.channels(dot.id))[0]!.peers).toEqual([]);
await expect(api.removeChannelPeer(dot.id, "telegram", "10")).rejects.toMatchObject({ status: 404 });
});
it("changes settings and pauses and resumes the channel, and refuses what is not a setting", async () => {
const dot = await readyDot();
await api.putTelegramChannel(dot.id, BOT_TOKEN);
expect((await api.patchChannel(dot.id, "telegram", { settings: { notify_tasks: false } })).settings).toEqual({ approvals: true, notify_tasks: false, show_arguments: true });
expect((await api.patchChannel(dot.id, "telegram", { settings: { show_arguments: false } })).settings).toEqual({ approvals: true, notify_tasks: false, show_arguments: false });
const paused = await api.patchChannel(dot.id, "telegram", { enabled: false });
expect(paused).toMatchObject({ enabled: false, settings: { approvals: true, notify_tasks: false } });
await waitFor(() => !bots.polling(BOT_TOKEN), "polling to stop");
const both = await api.patchChannel(dot.id, "telegram", { enabled: true, settings: { approvals: false } });
expect(both).toMatchObject({ enabled: true, settings: { approvals: false, notify_tasks: false } });
await waitFor(() => bots.polling(BOT_TOKEN), "polling again");
for (const body of [{}, { enabled: "yes" }, { settings: { colour: true } }, { settings: { approvals: "no" } }, { settings: [] }]) {
const refused = await raw("PATCH", `/api/dots/${dot.id}/channels/telegram`, body);
expect(refused.status, JSON.stringify(body)).toBe(400);
expect(refused.json).toMatchObject({ error: "invalid_request" });
}
expect((await api.channels(dot.id))[0]!.settings).toEqual({ approvals: false, notify_tasks: false, show_arguments: false });
});
it("unlinks: the channel stops, its token and people are deleted, and it is gone for a second try", async () => {
const dot = await readyDot();
await api.putTelegramChannel(dot.id, BOT_TOKEN);
await waitFor(() => bots.polling(BOT_TOKEN), "polling");
expect((await raw("DELETE", `/api/dots/${dot.id}/channels/telegram`)).status).toBe(204);
await waitFor(() => !bots.polling(BOT_TOKEN), "polling to stop");
expect(await db.secrets.get(dot.id, "telegram_bot_token")).toBeNull();
expect(await api.channels(dot.id)).toEqual([]);
await expect(api.removeChannel(dot.id, "telegram")).rejects.toMatchObject({ status: 404, code: "not_found" });
await expect(api.patchChannel(dot.id, "telegram", { enabled: true })).rejects.toMatchObject({ status: 404 });
await expect(api.pairChannel(dot.id, "telegram")).rejects.toMatchObject({ status: 404 });
});
it("refuses with a status and a message that never carry the token: no token, a wrong one, a malformed one, an unreachable Telegram, a bot in use", async () => {
const dot = await readyDot();
const other = await readyDot();
const url = `/api/dots/${dot.id}/channels/telegram`;
const answers = [
await raw("PUT", url, {}),
await raw("PUT", url, { token: 42 }),
await raw("PUT", url, { token: " " }),
await raw("PUT", url, { token: "999:UNKNOWN-TOKEN-VALUE" }),
await raw("PUT", url, { token: "bad token with spaces" }),
];
expect(answers.map((a) => a.status)).toEqual([400, 400, 400, 400, 400]);
expect(answers.map((a) => a.json?.error)).toEqual(["invalid_request", "invalid_request", "invalid_request", "invalid_credentials", "invalid_credentials"]);
expect(answers[3]!.json?.message).toMatch(/refused the bot token/);
bots.addBot("777:UNREACHABLE-TOKEN-VALUE", "third_bot");
bots.failNext("777:UNREACHABLE-TOKEN-VALUE", "getMe", "drop");
const unreachable = await raw("PUT", url, { token: "777:UNREACHABLE-TOKEN-VALUE" });
expect(unreachable.status).toBe(502);
expect(unreachable.json?.error).toBe("channel_unreachable");
expect(unreachable.text).not.toContain("UNREACHABLE-TOKEN-VALUE");
expect(await api.channels(dot.id)).toEqual([]);
expect(await db.secrets.get(dot.id, "telegram_bot_token")).toBeNull();
await api.putTelegramChannel(dot.id, BOT_TOKEN);
const inUse = await raw("PUT", `/api/dots/${other.id}/channels/telegram`, { token: BOT_TOKEN });
expect(inUse.status).toBe(409);
expect(inUse.json?.error).toBe("account_in_use");
expect(inUse.text).not.toContain("SECRET-TOKEN");
expect(await api.channels(other.id)).toEqual([]);
for (const answer of [...answers, unreachable, inUse]) expect(answer.text).not.toContain(BOT_TOKEN.split(":")[1]);
});
it("answers 404 for a Dot that does not exist and 400 for a channel kind that does not", async () => {
const dot = await readyDot();
await expect(api.channels("no-such-dot")).rejects.toMatchObject({ status: 404 });
await expect(api.putTelegramChannel("no-such-dot", BOT_TOKEN)).rejects.toMatchObject({ status: 404 });
for (const [method, path] of [
["PATCH", `/api/dots/${dot.id}/channels/carrier-pigeon`],
["DELETE", `/api/dots/${dot.id}/channels/carrier-pigeon`],
["POST", `/api/dots/${dot.id}/channels/carrier-pigeon/pairing`],
["DELETE", `/api/dots/${dot.id}/channels/carrier-pigeon/peers/1`],
] as const) {
const answer = await raw(method, path, method === "PATCH" ? { enabled: true } : undefined);
expect(answer.status, `${method} ${path}`).toBe(400);
expect(answer.json?.message).toMatch(/the channels are telegram, whatsapp/);
}
// A channel this server has no adapter for yet answers like one that is not linked.
await expect(api.pairChannel(dot.id, "whatsapp")).rejects.toMatchObject({ status: 404 });
});
});
+98
View File
@@ -0,0 +1,98 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { hostPaths, type HostPaths } from "@invisible-dots/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
acquireServerLock,
loadOrCreateApiToken,
loadOrGenerateMasterKey,
parseListen,
saveMasterKey,
} from "../src/index.js";
let dir: string;
let paths: HostPaths;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "idots-config-"));
paths = hostPaths({ INVISIBLE_DOTS_HOME: dir });
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("server settings", () => {
it("parses the listen address", () => {
expect(parseListen()).toEqual({ host: "127.0.0.1", port: 8787 });
expect(parseListen("0.0.0.0:9000")).toEqual({ host: "0.0.0.0", port: 9000 });
expect(parseListen("[::1]:8787")).toEqual({ host: "::1", port: 8787 });
expect(parseListen("8080")).toEqual({ host: "127.0.0.1", port: 8080 });
expect(() => parseListen("localhost:http")).toThrow(/INVISIBLE_DOTS_LISTEN/);
expect(parseListen("127.0.0.1:0")).toEqual({ host: "127.0.0.1", port: 0 });
expect(() => parseListen("localhost:http")).toThrow('INVISIBLE_DOTS_LISTEN must look like "127.0.0.1:8787", got "localhost:http"');
});
it("names the setting it was asked for, with its own example, and refuses port 0 only where the setting needs a fixed port", () => {
const web = { variable: "INVISIBLE_DOTS_WEB_LISTEN", example: "127.0.0.1:3000", fixedPort: true };
expect(parseListen("127.0.0.1:3100", web)).toEqual({ host: "127.0.0.1", port: 3100 });
expect(() => parseListen("localhost:http", web)).toThrow('INVISIBLE_DOTS_WEB_LISTEN must look like "127.0.0.1:3000", got "localhost:http"');
expect(() => parseListen("70000", web)).toThrow(/must look like/);
expect(() => parseListen("0", web)).toThrow('INVISIBLE_DOTS_WEB_LISTEN needs a fixed port, got "0"');
expect(() => parseListen("127.0.0.1:0", web)).toThrow(/needs a fixed port/);
});
it("creates the API token once and reads it back; the environment wins; short tokens are refused", async () => {
const created = await loadOrCreateApiToken(paths, {});
expect(created).toMatchObject({ created: true, origin: paths.apiTokenPath });
expect(created.value).toMatch(/^[0-9a-f]{64}$/);
expect(await loadOrCreateApiToken(paths, {})).toEqual({ ...created, created: false });
expect(await loadOrCreateApiToken(paths, { INVISIBLE_DOTS_TOKEN: "x".repeat(32) })).toMatchObject({
value: "x".repeat(32),
origin: "INVISIBLE_DOTS_TOKEN",
});
await expect(loadOrCreateApiToken(paths, { INVISIBLE_DOTS_TOKEN: "short" })).rejects.toThrow(/shorter/);
await writeFile(paths.apiTokenPath, "tiny\r\n");
await expect(loadOrCreateApiToken(paths, {})).rejects.toThrow(/shorter/);
});
it("generates a master key without writing it; saves it on request; accepts raw or hex keys, refuses others", async () => {
const generated = await loadOrGenerateMasterKey(paths);
expect(generated.created).toBe(true);
expect(generated.value.length).toBe(32);
await expect(readFile(paths.masterKeyPath)).rejects.toMatchObject({ code: "ENOENT" });
await saveMasterKey(paths, generated.value);
expect(await loadOrGenerateMasterKey(paths)).toEqual({ value: generated.value, origin: paths.masterKeyPath, created: false });
await writeFile(paths.masterKeyPath, `${"ab".repeat(32)}\n`);
expect((await loadOrGenerateMasterKey(paths)).value).toEqual(Buffer.from("ab".repeat(32), "hex"));
await writeFile(paths.masterKeyPath, "not a key");
await expect(loadOrGenerateMasterKey(paths)).rejects.toThrow(/must hold 32 bytes/);
});
});
describe("server lock", () => {
it("is exclusive while held, released on release, and taken over from a dead pid", async () => {
const lock = await acquireServerLock(paths);
expect(JSON.parse(await readFile(paths.serverLockPath, "utf8"))).toMatchObject({ pid: process.pid });
await expect(acquireServerLock(paths)).rejects.toThrow(/already holds/);
await lock.release();
await expect(readFile(paths.serverLockPath)).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(paths.serverLockPath, "4242\n");
await expect(acquireServerLock(paths, () => "ours")).rejects.toThrow(/pid 4242.*remove .*server\.lock/);
const taken = await acquireServerLock(paths, () => "gone");
expect(JSON.parse(await readFile(paths.serverLockPath, "utf8"))).toMatchObject({ pid: process.pid });
await taken.release();
});
it("release leaves a lock that another server took over in place", async () => {
const lock = await acquireServerLock(paths);
await writeFile(paths.serverLockPath, "4242\n");
await lock.release();
expect(await readFile(paths.serverLockPath, "utf8")).toBe("4242\n");
});
});
+78
View File
@@ -0,0 +1,78 @@
/** The doctor's image rows: the verdict on an image is kept for as long as the image and its manifest are the same files. */
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ImageCheck } from "@invisible-dots/image-builder";
import { hostPaths } from "@invisible-dots/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createImageChecks } from "../src/index.js";
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "idots-doctor-"));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
});
/** Both images in the home's images directory, and a `verify` that counts the images it is asked about. */
async function setup(answer: ImageCheck = { ok: true, manifest: {} as never }) {
const paths = hostPaths({ INVISIBLE_DOTS_HOME: home });
await mkdir(paths.imagesDir, { recursive: true });
const golden = join(paths.imagesDir, "golden-1.qcow2");
const runtime = join(paths.imagesDir, "runtime-1.iso");
for (const file of [golden, runtime]) await writeFile(file, "image");
await writeFile(join(paths.imagesDir, "golden-1.json"), "{}");
await writeFile(join(paths.imagesDir, "runtime-1.json"), "{}");
const verified: string[] = [];
const checks = createImageChecks(paths, async (image) => {
verified.push(image);
return answer;
});
return { checks, verified, golden, runtime, paths };
}
describe("the image rows", () => {
it("verify each image once while its files stay the same", async () => {
const { checks, verified, golden, runtime } = await setup();
const first = await checks();
expect(first.map((row) => [row.id, row.status])).toEqual([["golden-image", "ok"], ["runtime-image", "ok"]]);
expect(await checks()).toEqual(first);
expect(await checks()).toEqual(first);
expect(verified).toEqual([golden, runtime]);
});
it("verify again when the image changes, and when its manifest does", async () => {
const { checks, verified, golden, runtime, paths } = await setup();
await checks();
await writeFile(golden, "a different image");
await checks();
expect(verified).toEqual([golden, runtime, golden]);
const manifest = join(paths.imagesDir, "runtime-1.json");
await utimes(manifest, new Date(), new Date(Date.now() + 60_000));
await checks();
expect(verified).toEqual([golden, runtime, golden, runtime]);
});
it("keep a failed verdict too, with the command that fixes it", async () => {
const { checks, verified } = await setup({ ok: false, reason: "it hashes to the wrong digest", fix: "delete it and run invisible-dots image build" });
const rows = await checks();
expect(rows[0]).toMatchObject({ id: "golden-image", status: "failed", detail: "it hashes to the wrong digest", fix: "delete it and run invisible-dots image build" });
await checks();
expect(verified).toHaveLength(2);
});
it("answer missing without hashing anything when there is no image", async () => {
const paths = hostPaths({ INVISIBLE_DOTS_HOME: home });
await mkdir(paths.imagesDir, { recursive: true });
const verified: string[] = [];
const rows = await createImageChecks(paths, async (image) => {
verified.push(image);
return { ok: false, reason: "unreachable", fix: "" };
})();
expect(rows.map((row) => row.status)).toEqual(["missing", "missing"]);
expect(verified).toEqual([]);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { serveFile } from "../src/file-types.js";
describe("serveFile: what a browser may be told a Dot's file is", () => {
it("serves images as images and text as plain text, inline", () => {
expect(serveFile("/home/dot/a.png")).toMatchObject({ contentType: "image/png" });
expect(serveFile("/home/dot/a.JPG")).toMatchObject({ contentType: "image/jpeg" });
expect(serveFile("/home/dot/a.jpeg")).toMatchObject({ contentType: "image/jpeg" });
expect(serveFile("/home/dot/a.gif")).toMatchObject({ contentType: "image/gif" });
expect(serveFile("/home/dot/a.webp")).toMatchObject({ contentType: "image/webp" });
for (const name of ["a.md", "a.txt", "a.json", "a.py", "a.CSV", "a.yaml", "memory/trips/a.log"]) {
expect(serveFile(`/home/dot/${name}`), name).toMatchObject({ contentType: "text/plain; charset=utf-8" });
expect(serveFile(`/home/dot/${name}`).disposition, name).toMatch(/^inline;/);
}
});
it("never serves markup or script as what it is: html, svg, xml and javascript are text", () => {
for (const name of ["a.html", "a.htm", "a.svg", "a.xml", "a.js", "a.mjs"]) {
expect(serveFile(`/home/dot/${name}`).contentType, name).toBe("text/plain; charset=utf-8");
}
});
it("makes everything else a download", () => {
for (const name of ["a.bin", "a.pdf", "a.zip", "Makefile", ".bashrc", "a.", "archive.tar.gz", "a.png.exe"]) {
const served = serveFile(`/home/dot/${name}`);
expect(served.contentType, name).toBe("application/octet-stream");
expect(served.disposition, name).toMatch(/^attachment;/);
}
});
it("names the file in both forms, with nothing in the plain one that could end the header value", () => {
expect(serveFile("/home/dot/memory/fares.md").disposition).toBe(`inline; filename="fares.md"; filename*=UTF-8''fares.md`);
expect(serveFile('/home/dot/é "q"\\100%.txt').disposition).toBe(
`inline; filename="_ _q__100_.txt"; filename*=UTF-8''%C3%A9%20%22q%22%5C100%25.txt`,
);
expect(serveFile("/home/dot/a\r\nx-evil: 1.txt").disposition).not.toMatch(/[\r\n]/);
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* dot-agentd's HTTP surface (architecture sections 5.1 to 5.3) in front of
* the scheduler's FakeGuest, so the real GuestClient of vm-manager talks to
* it over real TCP exactly as it talks to a guest through QEMU's port
* forward. Only the routes the control plane calls are served, including
* the unauthenticated proof route, computed the guest's way.
*/
import { createHmac } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo, Socket } from "node:net";
import type { FakeGuest } from "@invisible-dots/scheduler/testing";
import { GUEST_PROOF_CONTEXT, type SecretsRequest } from "@invisible-dots/shared";
export interface FakeGuestServer {
port: number;
close(): Promise<void>;
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}
function send(response: ServerResponse, status: number, body?: unknown): void {
if (body === undefined) {
response.writeHead(status).end();
return;
}
response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
}
const IDENTITIES = "/v1/agent/browser-identities";
/**
* Serve `guest()` (looked up per request: a test may swap the guest) on
* `port` of 127.0.0.1 (0 picks one). Every request is appended to
* `requests` as "METHOD /path". `onPowerOff` is what the guest's poweroff
* ends in: the test's QEMU exits.
*/
export async function serveFakeGuest(
guest: () => FakeGuest | undefined,
options: { port?: number; requests?: string[]; onPowerOff?: () => void } = {},
): Promise<FakeGuestServer> {
const requests = options.requests ?? [];
const sockets = new Set<Socket>();
const server: Server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://guest");
requests.push(`${request.method} ${url.pathname}`);
const current = guest();
// A powered-off VM: QEMU's forward accepts and drops, which the client sees as a reset.
if (!current?.running) {
request.socket.destroy();
return;
}
if (request.method === "GET" && url.pathname === "/v1/proof") {
const nonce = url.searchParams.get("nonce") ?? "";
return send(response, 200, { proof: createHmac("sha256", current.token).update(`${GUEST_PROOF_CONTEXT}${nonce}`).digest("hex") });
}
if (request.headers.authorization !== `Bearer ${current.token}`) {
send(response, 401, { error: "unauthorized", message: "bad token" });
return;
}
try {
const route = `${request.method} ${url.pathname}`;
if (url.pathname.startsWith(`${IDENTITIES}/`)) {
const [rawId, action] = url.pathname.slice(IDENTITIES.length + 1).split("/");
const id = decodeURIComponent(rawId!);
if (action === "frame" && request.method === "GET") {
response.writeHead(200, { "content-type": "image/jpeg" }).end(Buffer.from(await current.getBrowserIdentityFrame(id)));
return;
}
if (action === "close" && request.method === "POST") {
await current.closeBrowserIdentity(id);
return send(response, 204);
}
if (action === undefined && request.method === "GET") return send(response, 200, await current.getBrowserIdentity(id));
if (action === undefined && request.method === "DELETE") {
await current.deleteBrowserIdentity(id);
return send(response, 204);
}
}
switch (route) {
case "GET /v1/health":
return send(response, 200, await current.health());
case "GET /v1/system":
return send(response, 200, await current.system());
case "POST /v1/system/poweroff":
send(response, 202, { status: "powering_off" });
options.onPowerOff?.();
return;
case "GET /v1/screenshot":
response.writeHead(200, { "content-type": "image/png" }).end(Buffer.from(await current.screenshot()));
return;
case "POST /v1/agent/secrets":
await current.pushSecrets((await readJson(request)) as SecretsRequest);
return send(response, 204);
case "PUT /v1/agent/config":
await current.putConfig((await readJson(request)) as never);
return send(response, 204);
case "POST /v1/agent/events":
return send(response, 202, await current.postEvent((await readJson(request)) as never));
case "GET /v1/agent/state":
return send(response, 200, await current.state());
case "POST /v1/agent/prepare-sleep":
await current.prepareSleep();
return send(response, 204);
case `GET ${IDENTITIES}`:
return send(response, 200, await current.listBrowserIdentities());
case `POST ${IDENTITIES}`:
return send(response, 201, await current.createBrowserIdentity((await readJson(request)) as never));
case "GET /v1/agent/events/stream": {
const controller = new AbortController();
request.on("close", () => controller.abort());
response.writeHead(200, { "content-type": "text/event-stream" });
response.write(": open\n\n");
try {
for await (const event of current.events({ after: Number(url.searchParams.get("after") ?? 0), signal: controller.signal })) {
response.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
}
} catch {
// A disconnect inside the fake ends the stream; the client reconnects.
}
response.end();
return;
}
default:
return send(response, 404, { error: "not_found", message: route });
}
} catch (error) {
const status = (error as { status?: number }).status;
if (!status) {
request.socket.destroy();
return;
}
send(response, status, { error: (error as { code?: string }).code ?? "error", message: (error as Error).message });
}
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port ?? 0, "127.0.0.1", () => resolve());
});
return {
port: (server.address() as AddressInfo).port,
close: () =>
new Promise<void>((resolve) => {
for (const socket of sockets) socket.destroy();
server.close(() => resolve());
}),
};
}
+8
View File
@@ -0,0 +1,8 @@
import type { HostFacts } from "@invisible-dots/sdk/types";
/** Where a fake host keeps its state, as `GET /api/health` is to say it. */
export const hostFacts = (kind: HostFacts["database_kind"] = "pglite"): HostFacts => ({
database_kind: kind,
data_dir: "/srv/dots-home",
logs_dir: "/srv/dots-home/logs",
});
+303
View File
@@ -0,0 +1,303 @@
/**
* The control plane started the way `invisible-dots server` starts it, on a
* real INVISIBLE_DOTS_HOME with the embedded PGlite; only the VM layer is a
* fake. Each test gets an empty home in the system's temp directory.
*/
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FakeChannelType } from "@invisible-dots/channels/testing";
import { FakeDriver, waitFor } from "@invisible-dots/scheduler/testing";
import { InvisibleDotsClient } from "@invisible-dots/sdk";
import { newId, parseDotConfig, permissionBitsEnforced } from "@invisible-dots/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { healthyDoctor } from "../../vm-manager/test/doctor-fakes.js";
import { startServer, STOP_SIGNALS, untilStopSignal, type RunningServer, type StartServerOptions } from "../src/index.js";
const quiet = { debug() {}, info() {}, warn() {}, error() {} };
let home: string;
const running: RunningServer[] = [];
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "idots-home-"));
});
afterEach(async () => {
await Promise.all(running.splice(0).map((s) => s.close()));
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
});
/** An environment with nothing but the home: no DATABASE_URL, so PGlite; no token variable. */
function envFor(dir: string, extra: Record<string, string> = {}): Record<string, string | undefined> {
return { INVISIBLE_DOTS_HOME: dir, ...extra };
}
async function start(options: StartServerOptions = {}): Promise<RunningServer> {
const server = await startServer({
env: envFor(home),
listen: "127.0.0.1:0",
logger: quiet,
driver: new FakeDriver(),
scheduler: { dispatchIntervalMs: 60_000, idleCheckIntervalMs: 60_000 },
...options,
});
running.push(server);
return server;
}
describe("stopping on a signal", { timeout: 120_000 }, () => {
it("closes the database and releases the lock on every way a terminal or a service stops it", async () => {
// Closing the console window (SIGHUP, also on Windows) and Ctrl+Break (SIGBREAK) included.
expect([...STOP_SIGNALS]).toEqual(["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]);
for (const signal of STOP_SIGNALS) {
const server = await startServer({
env: envFor(home),
listen: "127.0.0.1:0",
logger: quiet,
driver: new FakeDriver(),
scheduler: { dispatchIntervalMs: 60_000, idleCheckIntervalMs: 60_000 },
});
const signals = new EventEmitter();
const done = untilStopSignal(() => server.close(), quiet, signals);
expect(existsSync(join(home, "server.lock"))).toBe(true);
signals.emit(signal, signal);
await done;
expect(existsSync(join(home, "server.lock")), signal).toBe(false);
expect(signals.eventNames()).toEqual([]);
}
});
});
describe("stopping while a page is open", { timeout: 120_000 }, () => {
it("ends the live streams a browser holds and closes: a stop does not wait for the person to close the tab", async () => {
const server = await start();
const controller = new AbortController();
const headers = { authorization: `Bearer ${server.token}` };
const stream = await fetch(`${server.url}/api/stream`, { headers, signal: controller.signal });
expect(stream.status).toBe(200);
const reading = stream.body!.getReader().read();
const closed = server.close().then(() => "closed" as const);
const outcome = await Promise.race([closed, new Promise<"waiting">((resolve) => setTimeout(() => resolve("waiting"), 15_000))]);
controller.abort();
expect(outcome).toBe("closed");
// The stream the client held ended: its read answers (the end of the body) and does not hang.
await expect(Promise.race([reading.then(() => "ended"), new Promise((resolve) => setTimeout(() => resolve("hung"), 2_000))])).resolves.toBe("ended");
running.splice(running.indexOf(server), 1);
});
});
async function stopped(server: RunningServer): Promise<void> {
running.splice(running.indexOf(server), 1);
await server.close();
}
// A first PGlite start runs initdb on disk, which takes seconds on a slow machine.
describe("startServer on an empty INVISIBLE_DOTS_HOME", { timeout: 120_000 }, () => {
it("creates api.token and master.key, opens PGlite in db/, and serves the API with that token", async () => {
const server = await start();
expect(server.database).toBe("pglite");
expect(server.created).toEqual({ apiToken: true, masterKey: true });
const tokenFile = await readFile(join(home, "config", "api.token"), "utf8");
expect(tokenFile).toBe(`${server.token}\n`);
expect(server.token).toMatch(/^[0-9a-f]{64}$/);
const key = await readFile(join(home, "config", "master.key"));
expect(key.length).toBe(32);
expect((await readdir(join(home, "db"))).length).toBeGreaterThan(0);
if (permissionBitsEnforced()) {
expect((await stat(join(home, "config", "api.token"))).mode & 0o777).toBe(0o600);
expect((await stat(join(home, "config", "master.key"))).mode & 0o777).toBe(0o600);
expect((await stat(join(home, "config"))).mode & 0o777).toBe(0o700);
}
const api = new InvisibleDotsClient({ baseUrl: server.url, token: server.token });
expect(await api.health()).toMatchObject({
status: "ok",
database: "ok",
openrouter_configured: false,
database_kind: "pglite",
data_dir: home,
logs_dir: join(home, "logs"),
});
await api.setOpenRouterKey("sk-or-first");
expect(await api.health()).toMatchObject({ openrouter_configured: true });
const refused = await fetch(`${server.url}/api/health`, { headers: { authorization: "Bearer wrong-token-0123456789" } });
expect(refused.status).toBe(401);
await stopped(server);
await expect(stat(server.paths.serverLockPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("serves the host report: the machine's rows, this home's images, and the OpenRouter row from its own Scheduler", async () => {
// The machine (QEMU, the accelerator, the disk) is faked; the images, the home and the key are this server's own.
const { findQemu, run, accelerator, acceleratorAccess, freeSpace } = healthyDoctor().deps;
const server = await start({ doctor: { findQemu, run, accelerator, acceleratorAccess, freeSpace } });
const api = new InvisibleDotsClient({ baseUrl: server.url, token: server.token });
const before = await api.doctor();
expect(before.checks.map((c) => c.id)).toEqual(["node", "qemu", "qemu-img", "accelerator", "accelerator-probe", "disk", "golden-image", "runtime-image", "openrouter"]);
expect(before.ok).toBe(false);
const row = (id: string) => before.checks.find((c) => c.id === id);
expect(row("qemu")?.status).toBe("ok");
expect(row("golden-image")).toMatchObject({ status: "missing", detail: `none in ${server.paths.imagesDir}`, fix: "invisible-dots image build" });
expect(row("runtime-image")?.status).toBe("missing");
expect(row("openrouter")).toMatchObject({ status: "missing", detail: "no key stored", fix: "invisible-dots secret openrouter" });
await api.setOpenRouterKey("sk-or-first");
expect((await api.doctor()).checks.find((c) => c.id === "openrouter")).toEqual({ id: "openrouter", label: "OpenRouter key", status: "ok", detail: "stored" });
});
it("runs one host report at a time: requests that arrive while it runs share its answer", async () => {
// The accelerator probe is the slow row; the onboarding checklist asks again and again.
const base = healthyDoctor().deps;
let probes = 0;
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const acceleratorAccess = async () => {
probes += 1;
await gate;
return base.acceleratorAccess();
};
const server = await start({ doctor: { ...base, acceleratorAccess } });
const api = new InvisibleDotsClient({ baseUrl: server.url, token: server.token });
let arrived = 0;
server.app.server.on("request", (request: { url?: string }) => {
if (request.url === "/api/doctor") arrived += 1;
});
const together = Promise.all([api.doctor(), api.doctor(), api.doctor()]);
await waitFor(() => probes === 1 && arrived === 3, "all three requests at the server while the probe is held");
// A request's hooks and handler run on promises once it has arrived: one turn of the event loop has them joined.
await new Promise((resolve) => setImmediate(resolve));
release();
const [first, second, third] = await together;
expect(probes).toBe(1);
expect(second).toEqual(first);
expect(third).toEqual(first);
// Once it has answered, the next request runs it again.
await api.doctor();
expect(probes).toBe(2);
});
it("a restart keeps the token, the key and the data", async () => {
const first = await start();
await first.db.secrets.put("global", "openrouter_api_key", "sk-or-kept");
const token = first.token;
await stopped(first);
const second = await start();
expect(second.created).toEqual({ apiToken: false, masterKey: false });
expect(second.token).toBe(token);
expect(await second.db.secrets.openRouterKey("dot_none")).toBe("sk-or-kept");
});
it("starts the channels stored in the database with the server and stops them when it closes", async () => {
const first = await start({ channelTypes: [new FakeChannelType()] });
const dotId = newId("dot");
await first.db.dots.insert({
id: dotId,
config: parseDotConfig("name: channeled\nmodel:\n provider: openrouter\n id: test/model\n"),
status: "IDLE",
});
await first.channels.add(dotId, "telegram");
await stopped(first);
const type = new FakeChannelType();
const second = await start({ channelTypes: [type] });
const channel = await waitFor(() => type.channels.at(-1)?.sink && type.channels.at(-1), "the stored channel to run");
expect(await second.channels.list(dotId)).toHaveLength(1);
await stopped(second);
expect(channel.sink).toBeNull();
});
it("refuses a second server on the same home while the first runs", async () => {
const first = await start();
// In one process the second start is caught by the lock's own record of what it holds.
await expect(start()).rejects.toThrow(/already holds the invisible-dots server lock/);
const api = new InvisibleDotsClient({ baseUrl: first.url, token: first.token });
expect(await api.health()).toMatchObject({ status: "ok" });
});
it("takes over the lock of a server that died", async () => {
await writeFile(join(home, "server.lock"), "999999999\n");
const server = await start();
expect(JSON.parse(await readFile(server.paths.serverLockPath, "utf8"))).toMatchObject({ pid: process.pid });
});
it("refuses to start when master.key is gone but the database holds encrypted values, and writes no new key", async () => {
const first = await start();
await first.db.secrets.put("global", "openrouter_api_key", "sk-or-locked");
await stopped(first);
await rm(join(home, "config", "master.key"));
await expect(start()).rejects.toThrow(/master\.key/);
await expect(stat(join(home, "config", "master.key"))).rejects.toMatchObject({ code: "ENOENT" });
// The failed start released everything it took: a later start can take the lock.
await expect(stat(join(home, "server.lock"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("INVISIBLE_DOTS_TOKEN replaces the token file and none is written", async () => {
const token = "env-token-0123456789abcdef";
const server = await start({ env: envFor(home, { INVISIBLE_DOTS_TOKEN: token }) });
expect(server.token).toBe(token);
expect(server.created.apiToken).toBe(false);
await expect(stat(join(home, "config", "api.token"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("a failed start releases the lock and the database", async () => {
await expect(start({ listen: "not-an-address:http" })).rejects.toThrow(/INVISIBLE_DOTS_LISTEN/);
await expect(start({ env: envFor(home, { INVISIBLE_DOTS_TOKEN: "short" }) })).rejects.toThrow(/shorter/);
await expect(stat(join(home, "server.lock"))).rejects.toMatchObject({ code: "ENOENT" });
const server = await start();
expect(server.database).toBe("pglite");
});
it("with INVISIBLE_DOTS_WHATSAPP=1 and no WhatsApp client installed, starts anyway and says in the log how to install it", async () => {
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const server = await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: join(home, "no-client") });
expect(warnings.filter((m) => m.includes("WhatsApp client is not installed"))).toHaveLength(1);
expect(warnings.join("\n")).toContain("npm run whatsapp:install");
expect(server.channels.kinds).toEqual(["telegram", "whatsapp"]);
const api = new InvisibleDotsClient({ baseUrl: server.url, token: server.token });
expect(await api.health()).toMatchObject({ status: "ok" });
});
it("with INVISIBLE_DOTS_WHATSAPP=1 and a client that is not the pinned release, starts anyway and says in the log which releases differ", async () => {
const client = join(home, "client");
await mkdir(join(client, "node_modules", "baileys"), { recursive: true });
await writeFile(join(client, "package.json"), JSON.stringify({ dependencies: { baileys: "1.2.4" } }));
await writeFile(join(client, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", version: "1.2.3", main: "index.js" }));
await writeFile(join(client, "node_modules", "baileys", "index.js"), "");
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const server = await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: client });
const line = warnings.filter((m) => m.includes("WhatsApp"));
expect(line).toHaveLength(1);
expect(line[0]).toContain("1.2.3");
expect(line[0]).toContain("1.2.4");
expect(line[0]).toContain("npm run whatsapp:install");
expect(server.channels.kinds).toEqual(["telegram", "whatsapp"]);
});
it("says nothing of the WhatsApp client when WhatsApp is not asked for, or when the client is there", async () => {
const client = join(home, "client");
await mkdir(join(client, "node_modules", "baileys"), { recursive: true });
await writeFile(join(client, "package.json"), JSON.stringify({ dependencies: { baileys: "1.2.3" } }));
await writeFile(join(client, "node_modules", "baileys", "package.json"), JSON.stringify({ name: "baileys", version: "1.2.3", main: "index.js" }));
await writeFile(join(client, "node_modules", "baileys", "index.js"), "");
const warnings: string[] = [];
const logger = { ...quiet, warn: (message: string) => void warnings.push(message) };
const first = await start({ logger, whatsappClientDir: join(home, "no-client") });
await stopped(first);
await start({ env: envFor(home, { INVISIBLE_DOTS_WHATSAPP: "1" }), logger, whatsappClientDir: client });
expect(warnings.filter((m) => m.includes("WhatsApp"))).toEqual([]);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* VmManagerDriver over the real VmManager. QEMU is vm-manager's own fake at
* the process boundary (it parses the real argv); qemu-img is a fake runner;
* the guest is a FakeGuest behind real HTTP on the port VmManager picked and
* passed to the "QEMU", listening only while that QEMU runs, as QEMU's
* forward does; its poweroff stops the QEMU. The last test runs the whole
* Scheduler on top of it.
*/
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestDatabase, type TestDatabase } from "@invisible-dots/database/testing";
import { Scheduler } from "@invisible-dots/scheduler";
import { FAKE_MAX_IDENTITIES, FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
import { computerResources, hostPaths, type HostPaths, parseDotConfig, prefixedStderrLogger, silentLogger } from "@invisible-dots/shared";
import { VmManager } from "@invisible-dots/vm-manager";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FakeQemuHost, FakeRunner, type FakeVm } from "../../vm-manager/test/fakes.js";
import { compareVersions, latestImage, VmManagerDriver } from "../src/index.js";
import { serveFakeGuest, type FakeGuestServer } from "./guest-http.js";
const DOT = "dot_01k6h3w2ze8m4qv7r1xk9bntc5";
const QEMU = { system: "/opt/qemu/bin/qemu-system-x86_64", img: "/opt/qemu/bin/qemu-img" };
const TOKEN = "dot-token-0123456789";
/**
* The fake QEMU host whose forward is a fake guest server on the guest port
* of the argv, up from the spawn until the process ends: the guest boots and
* powers off together with its VM, with the token of its seed.
*/
class GuestQemuHost extends FakeQemuHost {
/** Guest requests of every VM, as "METHOD /path", in order. */
readonly requests: string[] = [];
readonly #servers = new Map<number, FakeGuestServer>();
constructor(
private readonly guest: () => FakeGuest | undefined,
private readonly replaceGuest: (guest: FakeGuest) => void,
) {
super();
}
protected override async openForward(vm: FakeVm): Promise<void> {
if (this.guest()?.token !== vm.token) this.replaceGuest(new FakeGuest(vm.token));
this.guest()?.boot();
const server = await serveFakeGuest(() => this.guest(), {
port: vm.guestPort,
requests: this.requests,
onPowerOff: () => setTimeout(() => this.terminate(vm), 5),
});
this.#servers.set(vm.pid, server);
}
protected override closeForward(vm: FakeVm): void {
this.guest()?.powerOff();
void this.#servers.get(vm.pid)?.close();
this.#servers.delete(vm.pid);
}
async closeAll(): Promise<void> {
await Promise.all([...this.#servers.values()].map((s) => s.close()));
this.#servers.clear();
}
}
let root: string;
let paths: HostPaths;
let diskSize: number;
let runner: FakeRunner;
let guest: FakeGuest | undefined;
let host: GuestQemuHost;
function manager(): VmManager {
return new VmManager({
paths,
logger: silentLogger,
qemu: QEMU,
accelerator: "kvm",
runner,
processes: host,
pollIntervalMs: 1,
startSettleMs: 1,
shutdownTimeoutMs: 200,
killTimeoutMs: 100,
startTimeoutMs: 1_000,
});
}
function input(dotId = DOT, disk = "40gb") {
const config = parseDotConfig({ name: "drv", model: { provider: "openrouter", id: "m" }, computer: { disk } });
return { dotId, token: TOKEN, resources: computerResources(config) };
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "idots-drv-"));
paths = hostPaths({ INVISIBLE_DOTS_HOME: root });
await mkdir(paths.imagesDir, { recursive: true });
for (const name of ["golden-2026.9.0.qcow2", "golden-2026.10.0.qcow2", "runtime-1.2.iso", "runtime-1.10.iso", "noise.txt"]) {
await writeFile(join(paths.imagesDir, name), name);
}
diskSize = 0;
runner = new FakeRunner(async (_command, args) => {
if (args[0] === "create") {
diskSize = Number(args.at(-1));
await writeFile(args.at(-2)!, "qcow2");
}
if (args[0] === "info") return { stdout: JSON.stringify({ "virtual-size": diskSize, format: "qcow2" }), stderr: "" };
if (args[0] === "resize") diskSize = Number(args.at(-1));
return undefined;
});
guest = new FakeGuest(TOKEN);
host = new GuestQemuHost(
() => guest,
(next) => {
guest = next;
},
);
});
afterEach(async () => {
guest?.powerOff();
await host.closeAll();
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
describe("image selection", () => {
it("compares versions numerically", () => {
expect(compareVersions("2026.10.1", "2026.9.3")).toBeGreaterThan(0);
expect(compareVersions("1.2", "1.10")).toBeLessThan(0);
});
it("picks the newest golden image and runtime ISO, and names the command that builds one", async () => {
const driver = new VmManagerDriver(manager());
expect(await driver.latestGolden()).toBe(join(paths.imagesDir, "golden-2026.10.0.qcow2"));
expect(await driver.latestRuntime()).toBe(join(paths.imagesDir, "runtime-1.10.iso"));
await expect(latestImage(join(root, "missing"), "golden")).rejects.toThrow(/cannot list/);
await rm(join(paths.imagesDir, "runtime-1.2.iso"));
await rm(join(paths.imagesDir, "runtime-1.10.iso"));
await expect(driver.latestRuntime()).rejects.toThrow(/no runtime ISO .*invisible-dots image build/);
});
});
describe("VmManagerDriver over VmManager", () => {
it("create, start, health, stop, start again on a grown disk, reboot to a new process, destroy", async () => {
const driver = new VmManagerDriver(manager());
expect(await driver.state(DOT)).toEqual({ exists: false, state: "STOPPED", pid: null, guestPort: null, detail: null });
const created = await driver.create(input());
expect(created).toEqual({
goldenImage: join(paths.imagesDir, "golden-2026.10.0.qcow2"),
runtimeImage: join(paths.imagesDir, "runtime-1.10.iso"),
});
const started = await driver.start({ ...input(), goldenImage: created.goldenImage });
expect(started).toMatchObject({ alreadyRunning: false, runtimeImage: created.runtimeImage });
expect(await driver.state(DOT)).toMatchObject({ exists: true, state: "RUNNING", pid: started.pid, guestPort: started.guestPort });
const argv = host.spawned.at(-1)!.args;
// The newest runtime ISO is the one attached.
expect(argv.some((arg) => arg.includes("runtime-1.10.iso"))).toBe(true);
const endpoint = { dotId: DOT, port: started.guestPort };
const health = await driver.waitForHealth(endpoint, TOKEN, { timeoutMs: 2_000, intervalMs: 5, requestTimeoutMs: 1_000 });
expect(health).toMatchObject({ agentd: "ok", agent: { status: "ok" } });
await driver.guest(endpoint, TOKEN).pushSecrets({ openrouter_api_key: "sk-or-x", mcp_secrets: { time: { TOKEN: "t-1" } } });
expect(guest?.mcpSecrets).toEqual({ time: { TOKEN: "t-1" } });
expect(guest?.openrouterKey).toBe("sk-or-x");
// Another token cannot check the guest's proof, so it is never sent; waiting does not help.
await expect(driver.guest(endpoint, "wrong-token-0000").health()).rejects.toMatchObject({ code: "guest_unproven" });
await expect(driver.waitForHealth(endpoint, "wrong-token-0000", { timeoutMs: 2_000, intervalMs: 5, requestTimeoutMs: 1_000 })).rejects.toMatchObject({
code: "guest_unproven",
});
// Identities through the real client, with the agent's own rules: a bad request and the limit come back as the guest's 4xx.
const client = driver.guest(endpoint, TOKEN);
const identity = await client.createBrowserIdentity({ name: "Shop" });
expect((await client.listBrowserIdentities()).identities.map((i) => i.id)).toEqual([identity.id]);
expect((await client.getBrowserIdentity(identity.id)).name).toBe("Shop");
await expect(client.createBrowserIdentity({ name: "proxied", proxy: 8080 as never })).rejects.toMatchObject({ status: 400, code: "invalid" });
const more = [];
for (let n = 1; n < FAKE_MAX_IDENTITIES; n++) more.push(await client.createBrowserIdentity({ name: `extra-${n}` }));
await expect(client.createBrowserIdentity({ name: "one-too-many" })).rejects.toMatchObject({ status: 409, code: "limit" });
for (const made of [identity, ...more]) await client.deleteBrowserIdentity(made.id);
expect(await driver.stop(DOT, TOKEN)).toEqual({ forced: false });
expect(host.requests).toContain("POST /v1/system/poweroff");
// Like QEMU's forward, the guest port is gone with the process.
await expect(driver.guest(endpoint, TOKEN).health()).rejects.toMatchObject({ code: "ECONNREFUSED" });
expect(await driver.state(DOT)).toMatchObject({ exists: true, state: "STOPPED", pid: null, guestPort: null });
// A PATCH made computer.disk bigger: the next start grows the overlay first.
await driver.start({ ...input(DOT, "60gb"), goldenImage: created.goldenImage });
expect(runner.lines().some((line) => line.includes(" resize ") && line.endsWith(String(60 * 1024 ** 3)))).toBe(true);
const before = (await driver.state(DOT)).pid;
const rebooted = await driver.reboot({ ...input(DOT, "60gb"), goldenImage: created.goldenImage });
expect(rebooted.pid).not.toBe(before);
expect(rebooted.alreadyRunning).toBe(false);
await driver.destroy(DOT);
expect(await driver.state(DOT)).toMatchObject({ exists: false, state: "STOPPED" });
});
it("waitForHealth fails at once when QEMU is gone instead of waiting out the timeout", async () => {
const driver = new VmManagerDriver(manager());
const created = await driver.create(input());
const started = await driver.start({ ...input(), goldenImage: created.goldenImage });
guest!.bootPolls = Number.MAX_SAFE_INTEGER;
const waiting = driver.waitForHealth({ dotId: DOT, port: started.guestPort }, TOKEN, { timeoutMs: 60_000, intervalMs: 5, requestTimeoutMs: 1_000 });
host.terminate([...host.vms.values()][0]!);
await expect(waiting).rejects.toThrow(/not running any more/);
});
});
describe("Scheduler over VmManagerDriver", () => {
let t: TestDatabase;
beforeEach(async () => {
t = await createTestDatabase("pglite");
await t.db.secrets.put("global", "openrouter_api_key", "sk-or-real-path");
});
afterEach(async () => {
await t.drop();
});
function scheduler(): Scheduler {
return new Scheduler({
db: t.db,
driver: new VmManagerDriver(manager()),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 5_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 },
dispatcher: { retryDelayMs: 0 },
dispatchIntervalMs: 60_000,
idleCheckIntervalMs: 60_000,
...(process.env.IDOTS_TEST_DEBUG ? { logger: prefixedStderrLogger("scheduler", true) } : {}),
});
}
it("create -> READY -> task over HTTP -> sleep -> restart of the control plane -> wake", { timeout: 30_000 }, async () => {
let s = scheduler();
const yaml = "name: real-path\nmodel:\n provider: openrouter\n id: m\n";
const dot = await s.createDot(yaml);
await waitFor(async () => (await t.db.dots.get(dot.id))?.status === "READY", "READY", 10_000);
const computer = await t.db.computers.get(dot.id);
expect(computer).toMatchObject({ state: "RUNNING", vm_name: `invisible-dot-${dot.id}` });
expect(computer?.guest_port).toBe((await new VmManagerDriver(manager()).state(dot.id)).guestPort);
expect(computer?.pid).toEqual(expect.any(Number));
expect(guest?.openrouterKey).toBe("sk-or-real-path");
expect(guest?.config?.name).toBe("real-path");
const task = await s.createTask(dot.id, { description: "over http" });
await waitFor(async () => (await t.db.tasks.get(task.id))?.status === "COMPLETED", "task COMPLETED over HTTP", 10_000);
// A control plane restart: QEMU keeps running and the new process adopts it from its pid file.
await s.close();
const spawnedBefore = host.spawned.length;
s = scheduler();
await s.start();
await waitFor(() => s.lifecycle.isReady(dot.id), "READY after restart", 10_000);
expect(host.spawned.length).toBe(spawnedBefore);
await s.lifecycle.stop(dot.id, "idle");
expect(host.requests).toContain("POST /v1/agent/prepare-sleep");
expect(host.requests).toContain("POST /v1/system/poweroff");
expect(await t.db.computers.get(dot.id)).toMatchObject({ state: "STOPPED", guest_port: null, pid: null });
expect([...host.vms.values()]).toHaveLength(0);
expect(host.killed).toEqual([]);
const wake = await s.createTask(dot.id, { description: "after sleep" });
await waitFor(async () => (await t.db.tasks.get(wake.id))?.status === "COMPLETED", "task after wake", 10_000);
expect(host.spawned.length).toBe(spawnedBefore + 1);
await s.close();
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* The WhatsApp routes of the control-plane API: starting a link, the stream of codes to scan, and what a server
* that does not run WhatsApp (the default: it is opt-in) says. The channel is the real one on a fake connection.
*/
import type { AddressInfo } from "node:net";
import { ChannelHub, WhatsAppChannelType } from "@invisible-dots/channels";
import { FakeChannelType, FakeWhatsAppConnector } from "@invisible-dots/channels/testing";
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import { Scheduler } from "@invisible-dots/scheduler";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "@invisible-dots/scheduler/testing";
import { InvisibleDotsClient } from "@invisible-dots/sdk";
import type { ChannelLinkFrame } from "@invisible-dots/shared";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { buildServer, defaultChannelTypes, type FastifyInstance } from "../src/index.js";
import { hostFacts } from "./host-facts.js";
const API_TOKEN = "test-token-0123456789abcdef";
const NUMBER = "15550001111";
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe.each(testAdapters())("WhatsApp routes of the control-plane API (%s)", { timeout: 60_000 }, (kind) => {
let t: TestDatabase;
let db: Database;
let app: FastifyInstance;
let plain: FastifyInstance;
let scheduler: Scheduler;
let hub: ChannelHub;
let plainHub: ChannelHub;
let driver: FakeDriver;
let connector: FakeWhatsAppConnector;
let base: string;
let plainBase: string;
let api: InvisibleDotsClient;
let seq = 0;
beforeAll(async () => {
t = await createTestDatabase(kind);
db = t.db;
await db.secrets.put("global", "openrouter_api_key", "sk-or-test");
driver = new FakeDriver();
scheduler = new Scheduler({
db,
driver,
clock: new ManualClock(),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10 },
dispatcher: { retryDelayMs: 0 },
});
connector = new FakeWhatsAppConnector();
hub = new ChannelHub({
db,
host: scheduler,
types: [new FakeChannelType(), new WhatsAppChannelType({ connector: () => connector, pauseMs: () => 0 })],
backoff: { initialMs: 1, maxMs: 5, jitter: 0 },
});
app = buildServer({ scheduler, channels: hub, doctor: async () => [], host: hostFacts(), token: API_TOKEN, heartbeatMs: 50 });
// The server as it is when WhatsApp was not asked for: Telegram only. It never starts a binding.
plainHub = new ChannelHub({ db, host: scheduler, types: [new FakeChannelType()] });
plain = buildServer({ scheduler, channels: plainHub, doctor: async () => [], host: hostFacts(), token: API_TOKEN });
await scheduler.start();
await hub.start();
await app.listen({ host: "127.0.0.1", port: 0 });
await plain.listen({ host: "127.0.0.1", port: 0 });
base = `http://127.0.0.1:${(app.server.address() as AddressInfo).port}`;
plainBase = `http://127.0.0.1:${(plain.server.address() as AddressInfo).port}`;
api = new InvisibleDotsClient({ baseUrl: base, token: API_TOKEN });
}, 60_000);
afterEach(async () => {
for (const dot of await api.listDots()) {
for (const channel of await api.channels(dot.id)) await api.removeChannel(dot.id, channel.kind);
}
connector.connections.length = 0;
});
afterAll(async () => {
await app?.close();
await plain?.close();
await hub?.close();
await scheduler?.close();
await t?.drop();
});
async function readyDot() {
const name = `wa-${++seq}`;
const dot = await api.createDot(yaml(name));
await waitUntilSettledReady(scheduler, driver, dot.id, name);
return dot;
}
async function raw(origin: string, method: string, path: string) {
const response = await fetch(`${origin}${path}`, { method, headers: { authorization: `Bearer ${API_TOKEN}` } });
const text = await response.text();
return { status: response.status, text, json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
}
async function linkedDot() {
const dot = await readyDot();
await api.linkWhatsApp(dot.id);
await waitFor(() => connector.connections.length === 1, "the connection");
connector.current.open(NUMBER);
await waitFor(async () => (await api.channels(dot.id))[0]?.status === "connected", "connected");
return dot;
}
it("needs the API token like every route", async () => {
const dot = await readyDot();
for (const [method, path] of [
["POST", `/api/dots/${dot.id}/channels/whatsapp/link`],
["GET", `/api/dots/${dot.id}/channels/whatsapp/qr`],
] as const) {
expect((await fetch(`${base}${path}`, { method })).status, `${method} ${path}`).toBe(401);
}
});
it("lists the kinds this server runs next to the Dot's channels", async () => {
const dot = await readyDot();
expect(await api.channelsOverview(dot.id)).toEqual({ channels: [], available: ["telegram", "whatsapp"] });
const plainAnswer = await raw(plainBase, "GET", `/api/dots/${dot.id}/channels`);
expect(plainAnswer.json).toEqual({ channels: [], available: ["telegram"] });
});
it("is off unless the server was started with it, and says how to turn it on", async () => {
const dot = await readyDot();
const link = await raw(plainBase, "POST", `/api/dots/${dot.id}/channels/whatsapp/link`);
expect(link.status).toBe(400);
expect(link.json).toMatchObject({ error: "invalid_request", message: expect.stringContaining("INVISIBLE_DOTS_WHATSAPP=1") });
expect((await raw(plainBase, "GET", `/api/dots/${dot.id}/channels/whatsapp/qr`)).status).toBe(400);
});
it("starts a link with 202 and the record of a channel that waits, and streams the codes and the end", async () => {
const dot = await readyDot();
const started = await raw(base, "POST", `/api/dots/${dot.id}/channels/whatsapp/link`);
expect(started.status).toBe(202);
expect(started.json).toMatchObject({ kind: "whatsapp", enabled: true, status: "connecting", account: null, peers: [] });
const frames: ChannelLinkFrame[] = [];
const controller = new AbortController();
const reading = (async () => {
for await (const frame of api.whatsappLink(dot.id, { signal: controller.signal })) frames.push(frame);
})();
await waitFor(() => connector.connections.length === 1, "the connection");
await waitFor(() => frames.length === 1, "the first frame");
connector.current.showCode("2@first");
await waitFor(() => frames.length === 2, "the first code");
connector.current.showCode("2@second");
connector.current.open(NUMBER);
await reading;
expect(frames).toEqual([{ state: "waiting" }, { state: "code", code: "2@first" }, { state: "code", code: "2@second" }, { state: "linked", account: NUMBER }]);
expect(await api.channels(dot.id)).toMatchObject([{ kind: "whatsapp", status: "connected", account: NUMBER }]);
});
it("answers the stream as an uncached event stream with a heartbeat, and never writes a code anywhere else", async () => {
const dot = await readyDot();
await api.linkWhatsApp(dot.id);
await waitFor(() => connector.connections.length === 1, "the connection");
connector.current.showCode("2@SECRET-CODE-TO-SCAN");
const controller = new AbortController();
const response = await fetch(`${base}/api/dots/${dot.id}/channels/whatsapp/qr`, { headers: { authorization: `Bearer ${API_TOKEN}` }, signal: controller.signal });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/^text\/event-stream/);
expect(response.headers.get("cache-control")).toBe("no-store");
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let text = "";
while (!text.includes(": ping")) text += (await reader.read()).value ?? "";
expect(text).toContain(": connected");
expect(text).toContain(`data: ${JSON.stringify({ state: "code", code: "2@SECRET-CODE-TO-SCAN" })}`);
controller.abort();
await reader.cancel().catch(() => {});
expect(JSON.stringify(await api.channels(dot.id))).not.toContain("SECRET-CODE-TO-SCAN");
expect(JSON.stringify(await api.events(dot.id))).not.toContain("SECRET-CODE-TO-SCAN");
});
it("is a 404 before anything is linked, as JSON and not as a stream", async () => {
const dot = await readyDot();
const qr = await raw(base, "GET", `/api/dots/${dot.id}/channels/whatsapp/qr`);
expect(qr.status).toBe(404);
expect(qr.json).toMatchObject({ error: "not_found" });
expect((await raw(base, "GET", `/api/dots/nothing-here/channels/whatsapp/qr`)).status).toBe(404);
expect((await raw(base, "POST", `/api/dots/nothing-here/channels/whatsapp/link`)).status).toBe(404);
});
it("refuses to link a channel that is linked", async () => {
const dot = await linkedDot();
const again = await raw(base, "POST", `/api/dots/${dot.id}/channels/whatsapp/link`);
expect(again.status).toBe(409);
expect(again.json).toMatchObject({ error: "already_linked" });
const frames: ChannelLinkFrame[] = [];
for await (const frame of api.whatsappLink(dot.id)) frames.push(frame);
expect(frames).toEqual([{ state: "linked", account: NUMBER }]);
});
it("pairs with a link and the words to send, and removes the channel with everything it kept", async () => {
const dot = await linkedDot();
await db.secrets.put(dot.id, "whatsapp_creds", "identity");
const pairing = await api.pairChannel(dot.id, "whatsapp");
expect(pairing.deep_link).toBe(`https://wa.me/${NUMBER}?text=pair%20${pairing.code}`);
expect(pairing.message).toBe(`pair ${pairing.code}`);
await api.removeChannel(dot.id, "whatsapp");
expect(await api.channels(dot.id)).toEqual([]);
expect(await db.secrets.get(dot.id, "whatsapp_creds")).toBeNull();
});
it("runs WhatsApp only when INVISIBLE_DOTS_WHATSAPP=1, and Telegram always", () => {
expect(defaultChannelTypes({}).map((type) => type.kind)).toEqual(["telegram"]);
expect(defaultChannelTypes({ INVISIBLE_DOTS_WHATSAPP: "0" }).map((type) => type.kind)).toEqual(["telegram"]);
expect(defaultChannelTypes({ INVISIBLE_DOTS_WHATSAPP: "1" }).map((type) => type.kind)).toEqual(["telegram", "whatsapp"]);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@invisible-dots/cli",
"version": "0.0.0",
"private": true,
"description": "The invisible-dots command: setup, doctor, image build, server, and the API client commands.",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"build": "node scripts/build.mjs",
"dev": "tsx src/main.ts"
},
"dependencies": {
"@invisible-dots/api": "0.0.0",
"@invisible-dots/image-builder": "0.0.0",
"@invisible-dots/sdk": "0.0.0",
"@invisible-dots/shared": "0.0.0",
"@invisible-dots/vm-manager": "0.0.0",
"qrcode": "1.5.4"
},
"bin": {
"invisible-dots": "dist/invisible-dots.mjs"
},
"devDependencies": {
"@types/qrcode": "1.5.6"
}
}
+25
View File
@@ -0,0 +1,25 @@
// Bundles the invisible-dots command into one ESM file that plain node runs
// (the workspace packages are TypeScript sources). `invisible-dots server`
// is in the same bundle, so the database's run-time files go next to it.
// The pinned Windows QEMU installer (virtualization/qemu/windows.json) is
// imported as JSON and so is inside the bundle.
import { mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { copyDatabaseRuntimeFiles } from "../../../packages/database/scripts/runtime-files.mjs";
import { BUNDLE_OPTIONS } from "./bundle-options.mjs";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(root, "dist");
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
await build({
...BUNDLE_OPTIONS,
entryPoints: [join(root, "src", "main.ts")],
outfile: join(dist, "invisible-dots.mjs"),
logLevel: "info",
});
copyDatabaseRuntimeFiles(dist);
+4
View File
@@ -0,0 +1,4 @@
import type { BuildOptions } from "esbuild";
/** The esbuild options of the invisible-dots bundle (bundle-options.mjs). */
export declare const BUNDLE_OPTIONS: BuildOptions;
+22
View File
@@ -0,0 +1,22 @@
// The esbuild options of the invisible-dots bundle, shared by scripts/build.mjs and tests/repo/cli-bundle.test.ts,
// which bundles a real Telegram call with them: what breaks only once bundled is caught there, not by a person.
export const BUNDLE_OPTIONS = {
bundle: true,
platform: "node",
format: "esm",
target: "node24",
sourcemap: true,
// Classes and functions keep their names. esbuild renames one whose name another module also uses (AbortSignal
// became AbortSignal2), and node-fetch, under grammY, recognizes a signal by its constructor's name: renamed, every
// Telegram call failed as "Network request for 'getMe' failed!", in the bundle only.
keepNames: true,
// pg loads its optional native binding with require(); it is never installed here.
// The opt-in WhatsApp client (Baileys, which depends on libsignal, GPL-3.0) is not in the bundle either: the adapter
// loads it at run time from optional/whatsapp/ by path (packages/channels/src/whatsapp-baileys/client.ts), so no
// import of it is in the bundle's graph, and a bundle that held it would be a GPL work.
external: ["pg-native"],
banner: {
// CommonJS dependencies inside an ESM bundle still call require().
js: "#!/usr/bin/env node\nimport { createRequire as __createRequire } from 'node:module';\nconst require = __createRequire(import.meta.url);",
},
};
+32
View File
@@ -0,0 +1,32 @@
/** How the CLI reaches the API: its URL and its token, from the environment or INVISIBLE_DOTS_HOME. */
import { InvisibleDotsClient } from "@invisible-dots/sdk";
import { ApiTokenError, DEFAULT_LISTEN, ENV, hostPaths, readApiToken } from "@invisible-dots/shared";
export const DEFAULT_URL = `http://${DEFAULT_LISTEN}`;
/** No usable token: the person has to start the server once or set the variable. */
export class AuthSetupError extends Error {}
export function apiUrl(env: Record<string, string | undefined>): string {
return env[ENV.URL]?.trim() || DEFAULT_URL;
}
/** The token as the server reads it (shared readApiToken): INVISIBLE_DOTS_TOKEN, else the first line of api.token. */
export async function resolveToken(env: Record<string, string | undefined>): Promise<string> {
let token;
try {
token = await readApiToken(env);
} catch (error) {
if (error instanceof ApiTokenError) throw new AuthSetupError(`no usable API token: ${error.message}`);
throw error;
}
if (!token) {
const path = hostPaths(env).apiTokenPath;
throw new AuthSetupError(`no API token: ${path} does not exist yet; start the server once (invisible-dots server) or set ${ENV.TOKEN}`);
}
return token.value;
}
export async function connectApi(env: Record<string, string | undefined>, fetchImpl?: typeof fetch): Promise<InvisibleDotsClient> {
return new InvisibleDotsClient({ baseUrl: apiUrl(env), token: await resolveToken(env), fetch: fetchImpl });
}
+755
View File
@@ -0,0 +1,755 @@
/**
* The `invisible-dots` command line. `run` takes its arguments and its world
* (output streams, stdin, environment, fetch, the host commands) as
* parameters, so the tests drive it in-process against a fake server and
* fake host commands.
*
* Two kinds of command live here: the host commands of architecture
* section 11 (setup, doctor, image build, server), which act on this
* machine, and the API client commands, which talk to a running server.
*/
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import QRCode from "qrcode";
import { ApiError, readRecentEvents, type ApprovalRecord, type DotSummary, type InvisibleDotsClient, type TaskRecord } from "@invisible-dots/sdk";
import {
accountLabel,
CHANNEL_KINDS,
COMPUTER_STOPPED,
DEFAULT_WEB_LISTEN,
ENV,
MAX_EVENT_PAGE,
permissionPhrase,
toolLabel,
type ChannelKind,
type ChannelRecord,
type ComputerAnswer,
type McpSecretsAnswer,
type StoredEvent,
type ToolListAnswer,
} from "@invisible-dots/shared";
import { STORE_OPENROUTER_KEY } from "@invisible-dots/vm-manager";
import cliPackage from "../package.json" with { type: "json" };
import { apiUrl, AuthSetupError, connectApi, DEFAULT_URL } from "./api-client.js";
import { addTelegramChannel } from "./commands.js";
import { EXIT } from "./exit.js";
export { DEFAULT_URL } from "./api-client.js";
export { EXIT } from "./exit.js";
/** The version of this package, which the bundle carries inside it (esbuild inlines the JSON). */
export const CLI_VERSION: string = cliPackage.version;
/** How many tasks `status` shows: the newest, which is all it asks the server for. */
const STATUS_TASKS = 10;
/**
* The commands that act on this host. The real ones (host.ts) load QEMU
* discovery, the image builder and the whole control plane, so they are
* imported only when one of them runs; tests pass fakes.
*/
/** `image build`: whether the golden image may be downloaded instead of built, and whether it is written compressed. */
export interface ImageBuildOptions {
download: boolean;
compress: boolean;
}
export interface HostCommands {
doctor(options: { json: boolean }, io: CliIo): Promise<number>;
setup(io: CliIo): Promise<number>;
/** `setup --all`: setup, the builds and `image build` in one run that can be run again (setup/all.ts). */
setupAll(io: CliIo): Promise<number>;
imageBuild(io: CliIo, options?: ImageBuildOptions): Promise<number>;
server(io: CliIo, options: { web: boolean }): Promise<number>;
}
export interface CliIo {
stdout: (text: string) => void;
stderr: (text: string) => void;
/** All of standard input, as text: for input that is piped in. */
readStdin: () => Promise<string>;
/**
* One line of a secret typed in the terminal, with the echo off, so it stays out of the scrollback. Enter ends it
* on every host; the end-of-input key differs (Ctrl-D on Linux, Ctrl-Z then Enter on Windows), so nothing a person
* types depends on it. It rejects when the person cancels (Ctrl-C).
*/
readSecret: () => Promise<string>;
stdinIsTTY: boolean;
env: Record<string, string | undefined>;
cwd: string;
fetch?: typeof fetch;
/** Aborted on Ctrl-C, to end `logs` and stop `image build`. */
signal?: AbortSignal;
/** Default: the real host commands. */
host?: HostCommands;
}
class UsageError extends Error {}
export const USAGE = `invisible-dots - control your Dots
Getting this host ready (the same commands on Linux and Windows):
invisible-dots setup --all everything in one run, in order: the guest daemon, QEMU and its accelerator, the web client, the images;
run it again after a restart or a failure and it carries on
invisible-dots setup just QEMU and its accelerator (may ask for administrator rights once)
invisible-dots doctor [--json] check everything; one line per check and the command that fixes a failure
invisible-dots image build [--no-download] [--compress]
the runtime ISO, and the golden image: downloaded when one is published for
these inputs, built here otherwise (--no-download: always build; --compress:
a compressed qcow2, as the published one is)
invisible-dots server [--no-web] run the control plane and the web client in the foreground (--no-web: the control plane only)
Using the server:
invisible-dots init [file] [--force] write a sample Dot config (default dot.yaml), check the server
invisible-dots create <file.yaml> create a Dot from a YAML config
invisible-dots update <dot> <file.yaml> replace the Dot's config with a YAML config; refused if it was changed since this command read it
invisible-dots delete <dot> --yes delete the Dot: its computer, its disk with everything on it, and its channels (cannot be undone)
invisible-dots list list Dots
invisible-dots status <dot> show a Dot, its computer and its recent tasks
invisible-dots message <dot> <text...> send a chat message
invisible-dots task <dot> <description...> [--priority N] [--at ISO-8601]
queue a task
invisible-dots tasks <dot> list a Dot's tasks
invisible-dots cancel <task-id> cancel a task that has not finished
invisible-dots computer <dot> start|stop|reboot
invisible-dots browser <dot> identities list the Dot's browser identities
invisible-dots mcp <dot> list the MCP servers the Dot's config declares: where each is on its computer, and which secrets are set
invisible-dots approvals [--all] list pending approvals (--all: every approval, the newest first)
invisible-dots approve <approval-id> [--note text] [--always]
--always also allows that permission for the Dot from now on
invisible-dots reject <approval-id> [--note text]
invisible-dots secret openrouter [--dot <dot>]
store the OpenRouter key: asked for in a terminal, read from stdin when piped (never from arguments)
invisible-dots secret proxy --dot <dot> [--clear]
set the Dot's VM proxy (socks5://user:pass@host:port), used from its next start; asked for like the key; --clear removes it
invisible-dots secret mcp --dot <dot> <server> <name> [--clear]
set a secret an MCP server of the Dot names (an environment variable or a header, e.g. "Bearer <token>"); asked for like the key; --clear removes it
invisible-dots channel add telegram --dot <dot>
link the Dot to a Telegram bot: the token from @BotFather is asked for in a terminal, read from stdin when piped (never from arguments)
invisible-dots channel link whatsapp --dot <dot>
link a WhatsApp number (opt-in on the server, unofficial, a ban of the account is possible): shows a code to scan in WhatsApp > Linked devices
invisible-dots channel list [--dot <dot>] list channels (of every Dot without --dot) with their status and paired people
invisible-dots channel pair telegram|whatsapp --dot <dot>
print a one-time link (valid 10 minutes) that pairs your Telegram or WhatsApp account to the Dot
invisible-dots channel remove telegram|whatsapp --dot <dot>
unlink: the token (or the linked device's keys) and the paired people are deleted
invisible-dots logs <dot> [--tail N] [--no-follow]
print recent events, then follow new ones
<dot> is a Dot name or id. Add --json for machine-readable output. invisible-dots --version prints the version.
Environment:
${ENV.HOME} the data directory (default ~/.invisible-dots)
${ENV.WEB_LISTEN} where the web client listens, host:port (default ${DEFAULT_WEB_LISTEN})
${ENV.QEMU_DIR} the one directory QEMU is looked for in, when set (otherwise the official installer's directory, then PATH)
${ENV.URL} server URL (default ${DEFAULT_URL})
${ENV.TOKEN} API token (default: the first line of <${ENV.HOME}>/config/api.token)
Exit codes: 0 ok; 1 the server reported an error, a doctor check is not ok or a setup step failed;
2 usage error; 3 server unreachable; 4 missing or refused token; 5 restart the computer, then run doctor (or setup --all again).
`;
export const SAMPLE_DOT = `# A Dot configuration (docs/architecture.md, section 7).
name: my-first-dot # lowercase letters, digits and '-', up to 40
instructions: >
Be concise. Write findings to files in ~/workspace.
model:
provider: openrouter
id: z-ai/glm-5.3-flash # any OpenRouter model id
computer:
cpu: 2
memory: 4gb
disk: 40gb
idle_timeout: 15m # sleep after 15 minutes with nothing to do; 0 = never
permissions:
computer.exec: allow
browser.identity.delete: ask
limits:
max_steps_per_task: 60
max_cost_per_task_usd: 1.00
`;
const OPTIONS = {
json: { type: "boolean" },
force: { type: "boolean" },
priority: { type: "string" },
at: { type: "string" },
note: { type: "string" },
always: { type: "boolean" },
dot: { type: "string" },
tail: { type: "string" },
"no-follow": { type: "boolean" },
"no-web": { type: "boolean" },
"no-download": { type: "boolean" },
compress: { type: "boolean" },
all: { type: "boolean" },
clear: { type: "boolean" },
yes: { type: "boolean" },
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
} as const;
/**
* Whether Ctrl-C asks the command to stop (through `io.signal`) instead of ending the process at once: `logs` ends
* cleanly, and `image build`, alone or as the last step of `setup --all`, kills its builder VM first. `server` installs
* its own handlers.
*/
export function interruptIsAsked(argv: string[]): boolean {
try {
const { positionals, values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
return positionals[0] === "logs" || positionals[0] === "image" || (positionals[0] === "setup" && values.all === true);
} catch {
return false;
}
}
/** The command word of an argument list, so main.ts can decide what Ctrl-C does before `run` starts. */
export function commandOf(argv: string[]): string | undefined {
try {
return parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false }).positionals[0];
} catch {
return undefined;
}
}
function noArguments(args: string[], command: string): void {
if (args.length > 0) throw new UsageError(`${command} takes no arguments, got "${args.join(" ")}"`);
}
function need(args: string[], index: number, what: string): string {
const value = args[index];
if (value === undefined || value === "") throw new UsageError(`missing ${what}`);
return value;
}
function pad(rows: string[][]): string {
const widths: number[] = [];
for (const row of rows) row.forEach((cell, i) => (widths[i] = Math.max(widths[i] ?? 0, cell.length)));
return rows.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]!))).join(" ")).join("\n") + "\n";
}
function oneLine(text: string, max = 60): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
}
/** What `status` says about the Dot's automations: when the next one is due, or that the person's stop has paused them (when one was due: a Dot with none has nothing to pause). */
function nextAutomation(computer: ComputerAnswer): string {
if (computer.stop_reason === "user" && computer.next_automation_at !== null) return "paused: the computer was stopped by you (start it to resume)";
return computer.next_automation_at ?? "none due";
}
export function formatEvent(event: StoredEvent): string {
const { guest_event_id: _id, guest_ts: _ts, ...data } = event.data;
const detail = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : "";
return `${event.created_at} #${event.id} ${event.type}${detail}`;
}
function taskRows(tasks: TaskRecord[]): string {
if (tasks.length === 0) return "no tasks\n";
return pad([
["ID", "STATUS", "PRIORITY", "CREATED", "DESCRIPTION"],
...tasks.map((t) => [t.id, t.status, String(t.priority), t.created_at, oneLine(t.description)]),
]);
}
function dotRows(dots: DotSummary[]): string {
if (dots.length === 0) return "no Dots yet: create one with invisible-dots create <file.yaml>\n";
return pad([
["NAME", "STATUS", "COMPUTER", "MODEL", "ID"],
...dots.map((d) => [d.name, d.status, d.computer_state ?? "-", d.config.model.id, d.id]),
]);
}
/** How a person is told to finish a pairing on each channel: Telegram's link opens the bot with a Start button, WhatsApp's opens a chat with the words ready to send. */
const PAIRING_WORDS: Record<ChannelKind, { action: string; to: string }> = {
telegram: { action: "press Start", to: "the bot" },
whatsapp: { action: "press Send", to: "the number" },
};
function channelRows(rows: { dot: string; channel: ChannelRecord }[]): string {
if (rows.length === 0) return "no channels: link one with invisible-dots channel add telegram --dot <dot>\n";
return pad([
["DOT", "CHANNEL", "STATUS", "ACCOUNT", "PEOPLE", "NOTE"],
...rows.map(({ dot, channel: c }) => [
dot,
c.kind,
c.enabled ? c.status : "paused",
accountLabel(c.kind, c.account) ?? "-",
c.peers.length === 0 ? "-" : c.peers.map((p) => p.label).join(", "),
c.status_detail ? oneLine(c.status_detail, 80) : "",
]),
]);
}
/** The approvals in the words of the web client's approval card: the Dot by its name, what it wants to do, the permission with its risk. */
function approvalRows(approvals: ApprovalRecord[], dotNames: ReadonlyMap<string, string>): string {
return pad([
["ID", "STATUS", "DOT", "WANTS TO", "PERMISSION", "REASON"],
// A Dot that was deleted has no name any more: its id is what is left of it.
...approvals.map((a) => [a.id, a.status, dotNames.get(a.dot_id) ?? a.dot_id, toolLabel(a.tool).ask, permissionPhrase(a.permission), oneLine(a.reason)]),
]);
}
/** A Dot config read from a YAML file named on the command line. */
async function readConfigFile(io: CliIo, path: string): Promise<string> {
const file = resolve(io.cwd, path);
try {
return await readFile(file, "utf8");
} catch (error) {
throw new UsageError(`cannot read ${file}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`);
}
}
/** The newest `count` events of the Dot's log, oldest first: read from the end, a page at a time, never from its start. */
async function newestEvents(client: InvisibleDotsClient, dotId: string, count: number): Promise<StoredEvent[]> {
const found: StoredEvent[] = [];
let before: number | undefined;
while (found.length < count) {
const want = Math.min(count - found.length, MAX_EVENT_PAGE);
const page = await readRecentEvents(client, dotId, { count: want, before });
found.unshift(...page);
if (page.length < want) break;
before = page[0]!.id;
}
return found;
}
/** One line of a secret, from the terminal (echo off) or from stdin: never from the arguments, which end up in shell history and ps. */
async function secretFromInput(io: CliIo, what: { prompt: string; noun: string; command: string; spaces?: true }): Promise<string> {
if (io.stdinIsTTY) io.stderr(`paste the ${what.prompt}, then press Enter:\n`);
const value = (io.stdinIsTTY ? await io.readSecret() : await io.readStdin()).trim();
if (!value) throw new UsageError(`no ${what.noun} given; run "${what.command}" in a terminal and paste it, or pipe it in`);
// A header's value may be "Bearer <token>"; a key or a proxy never has a space, and a line break is never part of one.
if (what.spaces ? /[\r\n]/.test(value) : /\s/.test(value)) {
throw new UsageError(`the ${what.noun} on stdin contains ${what.spaces ? "a line break" : "whitespace"}; pass only the ${what.noun}`);
}
return value;
}
function mcpRows(tools: ToolListAnswer | null, secrets: McpSecretsAnswer): string {
const servers = new Set([...(tools?.mcp_servers ?? []).map((server) => server.name), ...secrets.secrets.map((secret) => secret.server)]);
if (servers.size === 0 && tools !== null) return "no MCP servers\n";
const state = (name: string) => {
if (tools === null) return "computer stopped";
const server = tools.mcp_servers.find((s) => s.name === name);
if (server === undefined) return "-";
return server.state === "connected" ? `connected, ${server.tools} tools` : server.state === "failed" ? `failed: ${oneLine(server.error ?? "", 80)}` : server.state;
};
const secretsOf = (name: string) =>
secrets.secrets.filter((secret) => secret.server === name).map((secret) => `${secret.name}${secret.set ? "" : " (not set)"}`).join(", ") || "-";
return pad([["SERVER", "STATE", "SECRETS"], ...[...servers].map((name) => [name, state(name), secretsOf(name)])]);
}
function channelKind(value: string): ChannelKind {
if (!(CHANNEL_KINDS as readonly string[]).includes(value)) throw new UsageError(`unknown channel "${value}": use ${CHANNEL_KINDS.join(" or ")}`);
return value as ChannelKind;
}
export async function run(argv: string[], io: CliIo): Promise<number> {
let parsed;
try {
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
} catch (error) {
io.stderr(`invisible-dots: ${(error as Error).message}\nRun "invisible-dots --help" for usage.\n`);
return EXIT.usage;
}
const { values, positionals } = parsed;
if (values.version) {
io.stdout(`${CLI_VERSION}\n`);
return EXIT.ok;
}
const [command, ...args] = positionals;
if (values.help || !command || command === "help") {
io.stdout(USAGE);
return command || values.help ? EXIT.ok : EXIT.usage;
}
const baseUrl = apiUrl(io.env);
const out = (value: unknown, text: string) => io.stdout(values.json ? `${JSON.stringify(value, null, 2)}\n` : text);
let client: InvisibleDotsClient | undefined;
const api = async () => (client ??= await connectApi(io.env, io.fetch));
const host = async (): Promise<HostCommands> => io.host ?? (await import("./host.js")).realHostCommands();
try {
switch (command) {
case "setup":
noArguments(args, "setup");
return values.all === true ? await (await host()).setupAll(io) : await (await host()).setup(io);
case "doctor":
noArguments(args, "doctor");
return await (await host()).doctor({ json: values.json === true }, io);
case "image": {
const what = need(args, 0, "build");
if (what !== "build") throw new UsageError(`unknown image subcommand "${what}": use build`);
noArguments(args.slice(1), "image build");
return await (await host()).imageBuild(io, { download: values["no-download"] !== true, compress: values.compress === true });
}
case "server":
noArguments(args, "server");
return await (await host()).server(io, { web: values["no-web"] !== true });
case "init": {
const file = resolve(io.cwd, args[0] ?? "dot.yaml");
try {
await writeFile(file, SAMPLE_DOT, { flag: values.force ? "w" : "wx" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
io.stderr(`invisible-dots: ${file} already exists; use --force to overwrite it\n`);
return EXIT.failed;
}
throw error;
}
io.stdout(`wrote ${file}\n`);
const health = await (await api()).health();
io.stdout(`server ${baseUrl} is reachable (version ${health.version}, database ${health.database})\n`);
io.stdout(`edit the file, then: invisible-dots create ${args[0] ?? "dot.yaml"}\n`);
return EXIT.ok;
}
case "create": {
const yaml = await readConfigFile(io, need(args, 0, "<file.yaml>"));
const dot = await (await api()).createDot(yaml);
out(dot, `created Dot ${dot.name} (${dot.id}); its computer is being provisioned.\nfollow it with: invisible-dots logs ${dot.name}\n`);
return EXIT.ok;
}
case "update": {
const name = need(args, 0, "<dot>");
const yaml = await readConfigFile(io, need(args, 1, "<file.yaml>"));
if (args.length > 2) throw new UsageError("update takes a Dot and one YAML file");
const c = await api();
// The version read here makes the save conditional: a config changed meanwhile (in the web client, by an
// "always allow") is a 409 and nothing is saved, instead of being overwritten unseen.
const current = await c.getDot(name);
const dot = await c.updateDot(current.id, yaml, current.config_version);
out(dot, `updated Dot ${dot.name} (config version ${dot.config_version})\n`);
return EXIT.ok;
}
case "delete": {
const name = need(args, 0, "<dot>");
noArguments(args.slice(1), "delete <dot>");
if (values.yes !== true) {
throw new UsageError(
`delete destroys Dot ${name}'s computer and its disk, with every file, memory note and browser identity on them, and its channels; it cannot be undone. To do it: invisible-dots delete ${name} --yes`,
);
}
const c = await api();
const dot = await c.getDot(name);
const answer = await c.deleteDot(dot.id);
out(answer, `deleting Dot ${dot.name}; what it did stays in its log: invisible-dots logs ${dot.id}\n`);
return EXIT.ok;
}
case "list": {
const dots = await (await api()).listDots();
out(dots, dotRows(dots));
return EXIT.ok;
}
case "status": {
const name = need(args, 0, "<dot>");
const c = await api();
const [dot, computer, tasks] = await Promise.all([c.getDot(name), c.computer(name), c.listTasks(name, { limit: STATUS_TASKS })]);
const text =
pad([
["name", dot.name],
["id", dot.id],
["status", dot.status + (dot.error ? ` (${dot.error})` : "")],
["model", dot.config.model.id],
["computer", `${computer.state}${computer.ready ? ", ready" : ""}${computer.last_error ? ` (last error: ${computer.last_error})` : ""}`],
["resources", `${dot.config.computer.cpu} cpu, ${dot.config.computer.memory} memory, ${dot.config.computer.disk} disk, idle_timeout ${dot.config.computer.idle_timeout}`],
["last active", computer.last_active_at ?? "never"],
["next automation", nextAutomation(computer)],
]) +
"\n" +
taskRows(tasks);
out({ dot, computer, tasks }, text);
return EXIT.ok;
}
case "message": {
const name = need(args, 0, "<dot>");
const text = args.slice(1).join(" ");
if (!text.trim()) throw new UsageError("missing <text>");
const answer = await (await api()).sendMessage(name, text);
out(
answer,
answer.delivery === "delivered"
? `message delivered (${answer.message_id})\n`
: `message queued (${answer.message_id}): the Dot's computer is starting and gets it once READY\n`,
);
return EXIT.ok;
}
case "task": {
const name = need(args, 0, "<dot>");
const description = args.slice(1).join(" ");
if (!description.trim()) throw new UsageError("missing <description>");
let priority: number | undefined;
if (values.priority !== undefined) {
priority = Number(values.priority);
if (!Number.isInteger(priority)) throw new UsageError("--priority must be an integer");
}
if (values.at !== undefined && Number.isNaN(new Date(values.at).getTime())) {
throw new UsageError("--at must be an ISO 8601 time, e.g. 2026-10-03T08:00:00Z");
}
const task = await (await api()).createTask(name, {
description,
...(priority !== undefined ? { priority } : {}),
...(values.at !== undefined ? { scheduled_at: new Date(values.at).toISOString() } : {}),
});
out(task, `queued task ${task.id}\n`);
return EXIT.ok;
}
case "tasks": {
const tasks = await (await api()).listTasks(need(args, 0, "<dot>"));
out(tasks, taskRows(tasks));
return EXIT.ok;
}
case "cancel": {
const id = need(args, 0, "<task-id>");
noArguments(args.slice(1), "cancel <task-id>");
const task = await (await api()).cancelTask(id);
out(task, `task ${task.id} ${task.status}\n`);
return EXIT.ok;
}
case "computer": {
const name = need(args, 0, "<dot>");
const action = need(args, 1, "start|stop|reboot");
const c = await api();
if (action === "start") await c.startComputer(name);
else if (action === "stop") await c.stopComputer(name);
else if (action === "reboot") await c.rebootComputer(name);
else throw new UsageError(`unknown computer action "${action}": use start, stop or reboot`);
const note = action === "stop" ? `automations are paused while the computer is stopped; resume them with: invisible-dots computer ${name} start\n` : "";
out({ accepted: true }, `${action} requested; follow it with: invisible-dots logs ${name}\n${note}`);
return EXIT.ok;
}
case "browser": {
const name = need(args, 0, "<dot>");
const what = need(args, 1, "identities");
if (what !== "identities") throw new UsageError(`unknown browser subcommand "${what}": use identities`);
const identities = await (await api()).listIdentities(name);
out(
identities,
identities.length === 0
? "no browser identities\n"
: pad([
["ID", "NAME", "STATUS", "LAST USED", "PROXY"],
...identities.map((i) => [i.id, i.name, i.status, i.lastUsedAt ?? "never", i.hasProxy ? "yes" : "-"]),
]),
);
return EXIT.ok;
}
case "approvals": {
const c = await api();
// With --all the newest come first, as in the web client's history, so the cap of the list drops the oldest and
// never the newest; the pending ones come oldest first, the one that has waited longest on top.
const [approvals, dots] = await Promise.all([values.all ? c.listApprovals(undefined, { order: "desc" }) : c.listApprovals("pending"), c.listDots()]);
out(
approvals,
approvals.length === 0 ? (values.all ? "no approvals\n" : "no pending approvals\n") : approvalRows(approvals, new Map(dots.map((d) => [d.id, d.name]))),
);
return EXIT.ok;
}
case "approve":
case "reject": {
const id = need(args, 0, "<approval-id>");
const c = await api();
if (values.always && command === "reject") throw new UsageError("--always applies to approve, not to reject");
const note = values.note === undefined ? {} : { note: values.note };
const approval =
command === "approve"
? await c.approve(id, { ...note, ...(values.always ? { always: true as const } : {}) })
: await c.reject(id, note);
out(approval, `approval ${approval.id} ${approval.status}\n`);
return EXIT.ok;
}
case "mcp": {
const name = need(args, 0, "<dot>");
const c = await api();
const secrets = await c.mcpSecrets(name);
let tools: ToolListAnswer | null = null;
try {
tools = await c.listTools(name);
} catch (error) {
// A stopped computer has no engine to ask where the servers are; their secrets are the host's.
if (!(error instanceof ApiError && error.code === COMPUTER_STOPPED)) throw error;
}
out({ mcp_servers: tools?.mcp_servers ?? null, secrets: secrets.secrets }, mcpRows(tools, secrets));
return EXIT.ok;
}
case "secret": {
const kind = need(args, 0, "openrouter|proxy|mcp");
if (kind === "mcp") {
if (values.dot === undefined || values.dot === "") throw new UsageError("missing --dot <dot>");
const server = need(args, 1, "<server>");
const secretName = need(args, 2, "<name>");
if (args.length > 3) throw new UsageError("the secret is read from stdin, never from arguments (they end up in shell history and ps)");
const command = `invisible-dots secret mcp --dot ${values.dot} ${server} ${secretName}`;
const value = values.clear ? null : await secretFromInput(io, { prompt: `value of ${secretName} for the MCP server ${server}`, noun: "secret", command, spaces: true });
const result = await (await api()).setMcpSecret(values.dot, server, secretName, value);
out(result, `${secretName} of the MCP server ${server} ${value === null ? "cleared" : "stored"} for Dot ${result.dot_id}; a running Dot starts the server again with it\n`);
return EXIT.ok;
}
if (kind === "proxy") {
if (values.dot === undefined || values.dot === "") throw new UsageError("missing --dot <dot>");
if (args.length > 1) throw new UsageError("the proxy is read from stdin, never from arguments (they end up in shell history and ps)");
const proxy = values.clear ? null : await secretFromInput(io, { prompt: "VM proxy (socks5://user:pass@host:port)", noun: "proxy", command: `invisible-dots secret proxy --dot ${values.dot}` });
const result = await (await api()).setVmProxy(values.dot, proxy);
out(result, result.proxy ? `VM proxy stored for Dot ${result.dot_id}; it is used from the Dot's next start\n` : `Dot ${result.dot_id} goes out directly from its next start\n`);
return EXIT.ok;
}
if (kind !== "openrouter") throw new UsageError(`unknown secret "${kind}": openrouter, proxy or mcp`);
if (args.length > 1) {
throw new UsageError("the key is read from stdin, never from arguments (they end up in shell history and ps)");
}
const key = await secretFromInput(io, { prompt: "OpenRouter API key", noun: "key", command: STORE_OPENROUTER_KEY });
const result = await (await api()).setOpenRouterKey(key, values.dot);
out(
result,
`OpenRouter key stored ${values.dot ? `for Dot ${values.dot}` : "for every Dot"}; pushed to ${result.pushed} running Dot${result.pushed === 1 ? "" : "s"}\n`,
);
return EXIT.ok;
}
case "channel": {
const what = need(args, 0, "add|link|list|pair|remove");
const dotName = () => {
if (values.dot === undefined || values.dot === "") throw new UsageError("missing --dot <dot>");
return values.dot;
};
switch (what) {
case "add": {
const kind = need(args, 1, "telegram");
if (kind !== "telegram") {
throw new UsageError(`cannot add "${kind}" with a token: only telegram is supported there; WhatsApp is linked with "invisible-dots channel link whatsapp --dot <dot>"`);
}
if (args.length > 2) throw new UsageError("the token is read from stdin, never from arguments (they end up in shell history and ps)");
const name = dotName();
const token = await secretFromInput(io, { prompt: "Telegram bot token from @BotFather", noun: "token", command: addTelegramChannel(name) });
const channel = await (await api()).putTelegramChannel(name, token);
out(
channel,
`linked Telegram bot @${channel.account ?? "?"} to Dot ${name}\n` +
`next: invisible-dots channel pair telegram --dot ${name}\n` +
"note: Telegram chats are not end-to-end encrypted; Telegram can read what you and the Dot write there.\n",
);
return EXIT.ok;
}
case "link": {
const kind = need(args, 1, "whatsapp");
if (kind !== "whatsapp") throw new UsageError(`cannot link "${kind}" by scanning a code: only whatsapp is linked that way; telegram takes a token ("invisible-dots channel add telegram --dot <dot>")`);
if (args.length > 2) throw new UsageError("channel link takes only the channel and --dot <dot>");
const name = dotName();
const c = await api();
await c.linkWhatsApp(name);
io.stderr(
"WhatsApp: this links the number as a device of a personal account through an unofficial client, which WhatsApp can answer by banning the account. Use a number of its own, not the one you live on.\n" +
"On the phone: WhatsApp > Settings > Linked devices > Link a device, then scan the code below (it is replaced every few seconds).\n",
);
for await (const frame of c.whatsappLink(name, { signal: io.signal })) {
if (values.json) io.stdout(`${JSON.stringify(frame)}\n`);
if (frame.state === "code") {
if (!values.json) io.stdout(`${await QRCode.toString(frame.code, { type: "terminal", small: true })}\n`);
} else if (frame.state === "linked") {
if (!values.json) {
io.stdout(`linked WhatsApp${frame.account ? ` number ${accountLabel("whatsapp", frame.account)}` : ""} to Dot ${name}\nnext: invisible-dots channel pair whatsapp --dot ${name}\n`);
}
return EXIT.ok;
} else if (frame.state === "failed") {
io.stderr(`invisible-dots: ${frame.detail}\n`);
return EXIT.failed;
} else if (frame.detail && !values.json) {
io.stderr(`${frame.detail}; trying again\n`);
}
}
io.stderr("invisible-dots: stopped before the link finished\n");
return EXIT.failed;
}
case "list": {
noArguments(args.slice(1), "channel list");
const c = await api();
const dots = values.dot === undefined ? await c.listDots() : [await c.getDot(values.dot)];
const rows = (await Promise.all(dots.map(async (d) => (await c.channels(d.id)).map((channel) => ({ dot: d.name, channel }))))).flat();
out(rows, channelRows(rows));
return EXIT.ok;
}
case "pair": {
const kind = channelKind(need(args, 1, "telegram"));
const name = dotName();
const pairing = await (await api()).pairChannel(name, kind);
out(
pairing,
pairing.deep_link
? `open this link on the device where you use ${kind}, then ${PAIRING_WORDS[kind].action} (valid until ${pairing.expires_at}):\n ${pairing.deep_link}\nor send ${PAIRING_WORDS[kind].to}: ${pairing.message}\n`
: `pairing code ${pairing.code}, valid until ${pairing.expires_at}\n`,
);
return EXIT.ok;
}
case "remove": {
const kind = channelKind(need(args, 1, "telegram"));
const name = dotName();
await (await api()).removeChannel(name, kind);
out({ removed: true }, `unlinked ${kind} from Dot ${name}: ${kind === "whatsapp" ? "the linked device's keys" : "its token"} and paired people are deleted\n`);
return EXIT.ok;
}
default:
throw new UsageError(`unknown channel subcommand "${what}": use add, link, list, pair or remove`);
}
}
case "logs": {
const name = need(args, 0, "<dot>");
const tail = values.tail === undefined ? 20 : Number(values.tail);
if (!Number.isInteger(tail) || tail < 0) throw new UsageError("--tail must be a non-negative integer");
const c = await api();
const dot = await c.getDot(name).catch((error: unknown) => {
// A deleted Dot's events stay readable by id.
if (error instanceof ApiError && error.status === 404 && name.includes("_")) return { id: name };
throw error;
});
// The newest `tail` events, and at least the newest one, which following goes on from.
const recent = await newestEvents(c, dot.id, Math.max(tail, 1));
const last = recent.at(-1)?.id ?? 0;
for (const event of recent.slice(recent.length - tail)) io.stdout(values.json ? `${JSON.stringify(event)}\n` : `${formatEvent(event)}\n`);
if (values["no-follow"]) return EXIT.ok;
for await (const event of c.stream({
dotId: dot.id,
after: last,
signal: io.signal,
onReconnect: ({ error }) => io.stderr(`connection lost (${error.message}), reconnecting...\n`),
})) {
io.stdout(values.json ? `${JSON.stringify(event)}\n` : `${formatEvent(event)}\n`);
}
return EXIT.ok;
}
default:
throw new UsageError(`unknown command "${command}"`);
}
} catch (error) {
if (error instanceof UsageError) {
io.stderr(`invisible-dots: ${error.message}\nRun "invisible-dots --help" for usage.\n`);
return EXIT.usage;
}
if (error instanceof AuthSetupError) {
io.stderr(`invisible-dots: ${error.message}\n`);
return EXIT.auth;
}
if (error instanceof ApiError) {
if (error.status === 0) {
io.stderr(`invisible-dots: ${error.message}\nIs the server running (invisible-dots server)? Set ${ENV.URL} if it listens elsewhere.\n`);
return EXIT.unreachable;
}
if (error.status === 401) {
io.stderr(`invisible-dots: the server refused the API token (401); check ${ENV.TOKEN} or api.token\n`);
return EXIT.auth;
}
io.stderr(`invisible-dots: ${error.message} [${error.status} ${error.code}]\n`);
const details = error.details;
if (Array.isArray(details)) {
for (const issue of details as { path?: string; message?: string }[]) {
io.stderr(` ${issue.path ? `${issue.path}: ` : ""}${issue.message ?? ""}\n`);
}
}
return EXIT.failed;
}
// Ctrl-C is how `logs` ends; for every other command it is an interruption.
if (io.signal?.aborted && command === "logs") return EXIT.ok;
io.stderr(`invisible-dots: ${(error as Error).message}\n`);
return EXIT.failed;
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Command lines this command prints as the fix for something, kept in one
* place so doctor, setup and the usage errors never tell the person two
* different things. Each works as printed in a POSIX shell and in Windows
* PowerShell alike: no redirection, nothing to quote. The ones the vm-manager's
* doctor report names too (setup, the OpenRouter key) are in its errors.ts.
*/
/** Links a Dot to a Telegram bot: in a terminal it asks for the token, one line; piped, it reads standard input. */
export const addTelegramChannel = (dot: string): string => `invisible-dots channel add telegram --dot ${dot}`;
+10
View File
@@ -0,0 +1,10 @@
/** `invisible-dots doctor`: the report on stdout, exit code 0 only when every check is ok (section 11.1). */
import { EXIT } from "../exit.js";
import { allOk, doctorAnswer, runDoctor, type DoctorDeps } from "@invisible-dots/vm-manager";
import { renderReport } from "./render.js";
export async function doctorCommand(deps: DoctorDeps, options: { json: boolean }, out: (text: string) => void): Promise<number> {
const results = await runDoctor(deps);
out(options.json ? `${JSON.stringify(doctorAnswer(results), null, 2)}\n` : renderReport(results));
return allOk(results) ? EXIT.ok : EXIT.failed;
}
+22
View File
@@ -0,0 +1,22 @@
/** The doctor report as text: one line per check, a fix line under each that is not ok, and a count. */
import type { DoctorCheck } from "@invisible-dots/shared";
const STATUS_WIDTH = "missing".length + 2;
export function renderReport(results: readonly DoctorCheck[]): string {
const labelWidth = Math.max(...results.map((r) => r.label.length)) + 2;
const indent = " ".repeat(STATUS_WIDTH + labelWidth);
const lines: string[] = [];
for (const r of results) {
lines.push(`${r.status.padEnd(STATUS_WIDTH)}${r.label.padEnd(labelWidth)}${r.detail}`);
if (r.status !== "ok" && r.fix) lines.push(`${indent}fix: ${r.fix}`);
}
const count = (status: DoctorCheck["status"]) => results.filter((r) => r.status === status).length;
const ok = count("ok");
lines.push(
ok === results.length
? `all ${results.length} checks ok`
: `${results.length} checks: ${ok} ok, ${count("missing")} missing, ${count("failed")} failed`,
);
return `${lines.join("\n")}\n`;
}
+14
View File
@@ -0,0 +1,14 @@
/** Exit codes of `invisible-dots`: what went wrong, for scripts. */
export const EXIT = {
ok: 0,
/** The server answered with an error, a doctor check is not ok, or a setup step failed. */
failed: 1,
/** Wrong command line. */
usage: 2,
/** The server could not be reached. */
unreachable: 3,
/** No API token, or the server refused it. */
auth: 4,
/** setup enabled something that works only after Windows restarts. */
restart: 5,
} as const;
+187
View File
@@ -0,0 +1,187 @@
/**
* The real host commands: the doctor, setup, image build and server of
* architecture section 11, wired to the real machine. Everything decided
* here comes from the package that owns it: QEMU discovery, the accelerator
* and the doctor report from the vm-manager, image verification and building
* from the image builder (the doctor's image rows through the API), the
* control plane from the API, paths from shared. This file only connects
* them; it holds no platform branch (those are in setup/install.ts and the
* vm-manager's accelerator-access.ts).
*
* cli.ts imports this module only when a host command runs, so the API
* client commands never load QEMU discovery, the image builder or the
* database.
*/
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createImageChecks, parseListen, serverLogger, startServer, untilStopSignal } from "@invisible-dots/api";
import {
buildGoldenImage,
buildRuntimeIso,
defaultRuntimeInputs,
fetchVerified,
type ProcessRunner,
} from "@invisible-dots/image-builder";
import { allowlistedEnvironment, currentUserSid, hostPaths, type DoctorCheck } from "@invisible-dots/shared";
import {
accelerator,
findQemu,
firstLine,
hostAccessDeps,
hostDoctorDeps,
NodeCommandRunner,
openRouterCheck,
runProcess,
startProcess,
type DoctorDeps,
} from "@invisible-dots/vm-manager";
import windowsQemuPinJson from "../../../virtualization/qemu/windows.json" with { type: "json" };
import { connectApi } from "./api-client.js";
import type { CliIo, HostCommands, ImageBuildOptions } from "./cli.js";
import { doctorCommand } from "./doctor/command.js";
import { EXIT } from "./exit.js";
import { runSetupAll } from "./setup/all.js";
import { buildAgent, buildWeb, type BuildDeps } from "./setup/build.js";
import { currentUserIsRoot, type InstallDeps } from "./setup/install.js";
import { parseWindowsQemuPin } from "./setup/qemu-pin.js";
import { serve } from "./serve.js";
import { prepareHost, runSetup, type SetupDeps } from "./setup/setup.js";
import { locateWebBuild, startWebServer } from "./web.js";
/**
* Whether the running server holds an OpenRouter key. The key lives in the
* database, and the embedded PGlite database belongs to the one server
* process that has it open (a second opener would corrupt it), so doctor
* asks the server instead of reading the database.
*/
async function openRouterKey(env: Record<string, string | undefined>, fetchImpl?: typeof fetch): Promise<DoctorCheck> {
const base = { id: "openrouter", label: "OpenRouter key" } as const;
let health: unknown;
try {
health = await (await connectApi(env, fetchImpl)).health();
} catch (error) {
return {
...base,
status: "failed",
detail: `cannot tell while the server is not reachable (${firstLine((error as Error).message)})`,
fix: "start it with: invisible-dots server, then run invisible-dots doctor again",
};
}
const configured = (health as { openrouter_configured?: unknown }).openrouter_configured;
if (typeof configured === "boolean") return openRouterCheck(configured);
return { ...base, status: "failed", detail: "the server's /api/health does not report openrouter_configured", fix: "update the server" };
}
function doctorDeps(io: CliIo): DoctorDeps {
const paths = hostPaths(io.env);
return {
...hostDoctorDeps({ env: io.env, home: paths.home, images: createImageChecks(paths), openRouterKey: () => openRouterKey(io.env, io.fetch) }),
webBuild: () => locateWebBuild(REPO_ROOT),
};
}
function setupDeps(io: CliIo): SetupDeps {
return { doctor: doctorDeps(io), install: installDeps(io), out: io.stdout };
}
function buildDeps(io: CliIo): BuildDeps {
return {
run: runProcess,
env: io.env,
repoRoot: REPO_ROOT,
node: process.execPath,
webBuilt: async () => (await locateWebBuild(REPO_ROOT)).missing === undefined,
};
}
function installDeps(io: CliIo): InstallDeps {
const log = (line: string) => io.stdout(`${line}\n`);
return {
...hostAccessDeps(io.env),
log,
readText: (path) => readFile(path, "utf8"),
isRoot: currentUserIsRoot(),
windowsQemuPin: () => parseWindowsQemuPin(windowsQemuPinJson),
userSid: () => currentUserSid(io.env),
uniqueName: () => randomBytes(16).toString("hex"),
makeTempDir: () => mkdtemp(join(tmpdir(), "invisible-dots-setup-")),
removeDir: (path) => rm(path, { recursive: true, force: true }),
download: async (pin, dest) => {
await fetchVerified({ url: pin.url, sha256: pin.sha256 }, dest, { log, ...(io.fetch ? { fetch: io.fetch } : {}) });
},
};
}
/** The image builder's process boundary: the host's one runner, the same one the vm-manager uses for qemu-img. */
const commands = new NodeCommandRunner();
const processRunner: ProcessRunner = {
run: (command, args, options) => commands.run(command, args, options),
spawn: (command, args, options) => startProcess(command, args, { cwd: options.cwd, env: allowlistedEnvironment(process.env) }),
};
/**
* The repository root. apps/cli/src/host.ts and the bundle
* apps/cli/dist/invisible-dots.mjs sit at the same depth, so this holds for
* both. The image builder's own default would point inside apps/cli once
* bundled, so its guest files are named here (the engine's source is named by
* defaultRuntimeInputs, from this same root).
*/
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
const IMAGE_BUILDER_DIR = join(REPO_ROOT, "guest", "image-builder");
/**
* The runtime ISO first: it takes seconds and fails at once when the agent
* bundle or dot-agentd has not been built, before an hour of golden image
* provisioning.
*/
async function imageBuild(io: CliIo, options: ImageBuildOptions = { download: true, compress: false }): Promise<number> {
const log = (line: string) => io.stdout(`${line}\n`);
const paths = hostPaths(io.env);
const runtime = await buildRuntimeIso({ paths, log, assetRoot: IMAGE_BUILDER_DIR, inputs: defaultRuntimeInputs(REPO_ROOT) });
log(`${runtime.created ? "built" : "already built"}: ${runtime.iso}`);
const qemu = await findQemu(io.env);
const golden = await buildGoldenImage({
qemu,
accelerator: accelerator(),
runner: processRunner,
paths,
assetRoot: IMAGE_BUILDER_DIR,
log,
...(options.download ? {} : { prebuilt: false as const }),
...(options.compress ? { compress: true } : {}),
...(io.fetch ? { fetch: io.fetch } : {}),
...(io.signal ? { signal: io.signal } : {}),
});
log(`${golden.created ? "ready" : "already there"}: ${golden.image}`);
return EXIT.ok;
}
export function realHostCommands(): HostCommands {
return {
doctor: (options, io) => doctorCommand(doctorDeps(io), options, io.stdout),
setup: (io) => runSetup(setupDeps(io)),
setupAll: (io) => {
const builds = buildDeps(io);
return runSetupAll({
out: io.stdout,
agent: () => buildAgent(builds),
host: () => prepareHost(setupDeps(io)),
web: () => buildWeb(builds),
images: () => imageBuild(io),
env: io.env,
...(io.signal ? { signal: io.signal } : {}),
});
},
imageBuild,
server: async (io, options) => {
await serve(
{ env: io.env, logger: serverLogger(io.env), web: options.web, repoRoot: REPO_ROOT },
{ startServer, untilStopSignal, parseListen, startWebServer },
);
return EXIT.ok;
},
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./cli.js";
+43
View File
@@ -0,0 +1,43 @@
/** The `invisible-dots` executable: wires `run` to the real process. */
import { commandOf, interruptIsAsked, run } from "./cli.js";
import { readSecretLine } from "./secret-input.js";
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
const argv = process.argv.slice(2);
const command = commandOf(argv);
const controller = new AbortController();
// What Ctrl-C does depends on the command. `logs` ends cleanly. `image build`
// (and `setup --all`, which ends with it) is asked to stop, so it can kill its
// builder VM, and a second Ctrl-C exits at once. `server` installs its own
// handlers (it closes the database before exiting). Every other command keeps
// Node's default: exit immediately.
if (interruptIsAsked(argv)) {
process.on("SIGINT", () => {
if (controller.signal.aborted) process.exit(130);
controller.abort();
});
}
const code = await run(argv, {
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
readStdin,
readSecret: () => readSecretLine(process.stdin, process.stderr),
stdinIsTTY: Boolean(process.stdin.isTTY),
env: process.env,
cwd: process.cwd(),
signal: controller.signal,
});
process.exitCode = code;
// After a clean stop the server has nothing left to say, but a database or
// VM-manager handle may still hold the event loop; the server's own bin
// exits the same way. Other commands end naturally, which lets piped output
// flush on Windows, where pipe writes are asynchronous.
if (command === "server") process.exit(code);
+49
View File
@@ -0,0 +1,49 @@
/**
* One line a person types in the terminal that must not show: an API key or a bot token pasted at a prompt. The
* terminal's own echo would leave it in the scrollback and in any session recording, so the line is read through a
* readline whose output is a stream that swallows what it is given: readline still handles the keys (backspace,
* Ctrl-U, paste) and the terminal is in raw mode, but nothing of the value is written back to the screen.
*/
import { createInterface } from "node:readline";
import { Writable, type Readable } from "node:stream";
/** What a terminal's input is to this reader: a stream that may be put in raw mode. */
export type TerminalInput = Readable & { isTTY?: boolean; setRawMode?: (mode: boolean) => unknown };
/** The person pressed Ctrl-C at the prompt. */
export class SecretInputCancelled extends Error {
constructor() {
super("cancelled");
}
}
/**
* Read one line from `input` with the echo off, then write one newline to `output` (Enter was not echoed either,
* and the next line of output must not follow the prompt on the same line). The line is returned as typed; an end
* of input before Enter returns what was typed so far.
*/
export function readSecretLine(input: TerminalInput, output: Pick<Writable, "write">): Promise<string> {
return new Promise((resolve, reject) => {
const swallowed = new Writable({
write(_chunk, _encoding, done) {
done();
},
});
const lines = createInterface({ input, output: swallowed, terminal: true, historySize: 0 });
let line = "";
let settled = false;
const finish = (settle: () => void) => {
if (settled) return;
settled = true;
lines.close();
output.write("\n");
settle();
};
lines.once("line", (text) => {
line = text;
finish(() => resolve(line));
});
lines.once("SIGINT", () => finish(() => reject(new SecretInputCancelled())));
lines.once("close", () => finish(() => resolve(line)));
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* `invisible-dots server`: the control plane and, next to it, the web client,
* in the foreground until a stop signal. The order is the contract: the
* signal handlers are installed as soon as the control plane listens (a
* Ctrl+C while the web client is starting must still close the database),
* and a stop closes the web client first, then the control plane.
*/
import type { ListenAddress, ListenSetting, StartServerOptions } from "@invisible-dots/api";
import { DEFAULT_WEB_LISTEN, ENV, type HostPaths, type Logger } from "@invisible-dots/shared";
import { locateWebBuild, type WebServer, type WebServerOptions } from "./web.js";
/** What `serve` needs from the packages that own the processes; tests pass fakes. */
export interface ServeDeps {
startServer(options: StartServerOptions): Promise<{ url: string; paths: HostPaths; close(): Promise<void> }>;
untilStopSignal(close: () => Promise<void>, logger: Logger, signals?: NodeJS.EventEmitter): Promise<void>;
parseListen(value: string, setting: ListenSetting): ListenAddress;
startWebServer(options: WebServerOptions): Promise<WebServer>;
}
export interface ServeOptions {
env: Record<string, string | undefined>;
logger: Logger;
/** Serve the web client too (`--no-web` turns it off). */
web: boolean;
/** The repository (or install) root the web build is found under. */
repoRoot: string;
/** The process, or a stand-in in tests. */
signals?: NodeJS.EventEmitter;
}
/** The web client's listen address: its own default shown in the error, and a port the person can be told. */
const WEB_LISTEN: ListenSetting = { variable: ENV.WEB_LISTEN, example: DEFAULT_WEB_LISTEN, fixedPort: true };
export async function serve(options: ServeOptions, deps: ServeDeps): Promise<void> {
const { env, logger } = options;
// Parsed before anything starts, so a bad INVISIBLE_DOTS_WEB_LISTEN fails the command at once.
const webListen = options.web ? deps.parseListen(env[ENV.WEB_LISTEN]?.trim() || DEFAULT_WEB_LISTEN, WEB_LISTEN) : undefined;
const server = await deps.startServer({ env, logger });
logger.info(`API token in ${server.paths.apiTokenPath}; stop with Ctrl+C (Dots keep running)`);
const starting = new AbortController();
let web: Promise<WebServer | undefined> = Promise.resolve(undefined);
const stopped = deps.untilStopSignal(
async () => {
starting.abort();
try {
await (await web)?.stop();
} finally {
await server.close();
}
},
logger,
options.signals,
);
if (webListen) {
// Assigned in the same tick the handlers were installed, so a stop always finds the start it has to end.
web = (async () => {
try {
const started = await deps.startWebServer({
build: await locateWebBuild(options.repoRoot),
listen: webListen,
apiUrl: server.url,
env,
signal: starting.signal,
onUnexpectedExit: (message) => logger.warn(`${message}; the control plane keeps running`),
});
logger.info(`web client at ${started.url}`);
return started;
} catch (error) {
if (!starting.signal.aborted) logger.warn(`web client not started: ${(error as Error).message}; the control plane keeps running`);
return undefined;
}
})();
}
await stopped;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* `invisible-dots setup --all`: everything the quick start did by hand after `npm ci`, in order, as one command that
* can be run again. Each step is the code of the command that owns it (the guest daemon and web client builds in
* build.ts, QEMU and its accelerator in setup.ts, `image build` in host.ts) and skips what is already done, so a run
* that stopped, for a restart, a new login or a failure, is continued by running the same command again.
*
* The one step that needs administrator rights stays one step: it is `setup`'s, which asks once. It comes second,
* after the quick daemon build that finds a missing Go before anything is changed, and before the long builds, so a
* restart it asks for costs nothing already done.
*/
import { DEFAULT_WEB_LISTEN, ENV } from "@invisible-dots/shared";
import { EXIT } from "../exit.js";
import type { StepResult } from "./build.js";
import type { HostOutcome } from "./setup.js";
/** The command this prints wherever the person has to come back: the same one, which continues. */
export const SETUP_ALL_COMMAND = "invisible-dots setup --all";
export interface SetupAllDeps {
out(text: string): void;
/** Build dot-agentd. */
agent(): Promise<StepResult>;
/** Get QEMU and its accelerator ready (`setup`). */
host(): Promise<HostOutcome>;
/** Build the web client unless it is built. */
web(): Promise<StepResult>;
/** `image build`: the exit code, or a rejection with the reason. */
images(): Promise<number>;
env: Record<string, string | undefined>;
/** Aborted by Ctrl-C. Checked between steps: only `image build` can stop in the middle of one. */
signal?: AbortSignal;
}
/** The address the web client listens on: the setting, or its default. */
export function webAddress(env: Record<string, string | undefined>): string {
return `http://${env[ENV.WEB_LISTEN]?.trim() || DEFAULT_WEB_LISTEN}`;
}
const STEPS = 4;
export async function runSetupAll(deps: SetupAllDeps): Promise<number> {
const { out } = deps;
const heading = (n: number, text: string) => out(`\n[${n}/${STEPS}] ${text}\n`);
const interrupted = () => deps.signal?.aborted === true;
const stopped = (lines: readonly string[]) => {
for (const line of lines) out(` ${line}\n`);
out(`\nStopped. Nothing after this step was run. Once it is fixed, run ${SETUP_ALL_COMMAND} again: it skips what is done.\n`);
return EXIT.failed;
};
out(`${SETUP_ALL_COMMAND}: ${STEPS} steps. Each skips what is already done, so run it again after anything that stops it.\n`);
heading(1, "the guest daemon dot-agentd (Go builds it)");
const agent = await deps.agent();
if (!agent.ok) return stopped(agent.lines);
for (const line of agent.lines) out(` ${line}\n`);
if (interrupted()) return stopped(["interrupted"]);
heading(2, "QEMU and its accelerator (the `setup` command: it asks for administrator rights once, and only if something is missing)");
const host = await deps.host();
if (!host.ready) {
if (host.code === EXIT.restart) {
out(`\nRestart the computer now, then run ${SETUP_ALL_COMMAND} again from this folder: it carries on with step 3.\n`);
} else if (host.code !== EXIT.usage) {
out(`\nStopped. When what is listed above is done (log out and in again if it says so), run ${SETUP_ALL_COMMAND} again: it skips what is done.\n`);
}
return host.code;
}
if (interrupted()) return stopped(["interrupted"]);
heading(3, "the web client");
const web = await deps.web();
if (!web.ok) return stopped(web.lines);
for (const line of web.lines) out(` ${line}\n`);
if (interrupted()) return stopped(["interrupted"]);
heading(4, "the guest images (what `invisible-dots image build` does: the golden image is downloaded when one is published for these inputs)");
try {
const code = await deps.images();
if (code !== EXIT.ok) return stopped([`image build ended with exit code ${code}`]);
} catch (error) {
return stopped([`image build stopped: ${(error as Error).message}`]);
}
out(`
This computer is ready. To use it:
1. start the server and leave it running: invisible-dots server
2. open ${webAddress(deps.env)} in your browser
3. the page asks for your OpenRouter key, then lets you create your first Dot.
`);
return EXIT.ok;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* The two builds the quick start used to ask for by hand, as steps of `invisible-dots setup --all`: the guest
* daemon dot-agentd (Go) and the web client (Next). What each one runs is owned elsewhere (the image builder names the
* Go command, apps/web/scripts/build.mjs is the web build); this file runs it and says what happened. Both go
* through the host's one process runner, with an argument array and never a shell.
*/
import { agentdBuildCommand, defaultRuntimeInputs } from "@invisible-dots/image-builder";
import { WEB_BUILD_COMMAND, type Runner, type RunResult } from "@invisible-dots/vm-manager";
import { webBuildScript } from "../web.js";
/** A step either went through (and says what it did) or stopped the run (and says what to do). */
export interface StepResult {
ok: boolean;
lines: string[];
}
export interface BuildDeps {
run: Runner;
/** The environment the builds start from; the Go build adds its own variables. */
env: Record<string, string | undefined>;
repoRoot: string;
/** The program that runs the web build script: the node running this command. */
node: string;
/** Whether the web client is built completely (`locateWebBuild`). */
webBuilt(): Promise<boolean>;
}
/**
* How to get Go: both hosts' commands in one line, so that no platform check is needed to choose one. setup never
* runs it (the Windows installer and snap each ask for administrator rights of their own, and setup asks once).
*/
export const GO_INSTALL_HINT = "install it: on Windows `winget install -e --id GoLang.Go`, on Linux `sudo snap install go --classic`, or see https://go.dev/dl";
/** Go downloads its toolchain when go.mod asks for a newer one, and compiles the daemon from a cold cache. */
const AGENT_TIMEOUT_MS = 15 * 60_000;
const WEB_TIMEOUT_MS = 30 * 60_000;
const STDERR_LINES = 10;
function why(answer: RunResult, limit: number): string {
if (answer.timedOut) return `it did not finish within ${limit / 60_000} minutes`;
return answer.startError?.message ?? `exit code ${answer.code ?? answer.signal}`;
}
/**
* Builds dot-agentd. Always run, never skipped: Go keeps its own build cache, so an unchanged daemon takes seconds,
* and the image builder turns the same bytes into "already built".
*/
export async function buildAgent(deps: BuildDeps): Promise<StepResult> {
const build = agentdBuildCommand(deps.repoRoot);
const answer = await deps.run(build.command, build.args, { env: { ...deps.env, ...build.env }, timeoutMs: AGENT_TIMEOUT_MS });
if (answer.startError?.code === "ENOENT") {
return {
ok: false,
lines: [
"Go is not installed, or is not on PATH: the guest daemon dot-agentd is written in Go",
GO_INSTALL_HINT,
"then open a new terminal (the installer changes PATH) and run: invisible-dots setup --all",
],
};
}
if (answer.code !== 0 || answer.timedOut) {
const tail = answer.stderr.trim().split(/\r?\n/).slice(-STDERR_LINES);
return { ok: false, lines: [`building dot-agentd failed (${why(answer, AGENT_TIMEOUT_MS)}):`, ...tail.filter((line) => line.trim() !== "").map((line) => ` ${line}`)] };
}
return { ok: true, lines: [`built ${defaultRuntimeInputs(deps.repoRoot).agentdBinary}`] };
}
/** Builds the web client unless it is complete already (a rebuild after an update is the command this prints). */
export async function buildWeb(deps: BuildDeps): Promise<StepResult> {
if (await deps.webBuilt()) return { ok: true, lines: [`already built; after an update, build it again with: ${WEB_BUILD_COMMAND}`] };
// The output is the person's to see: a Next build takes minutes and prints its progress.
const answer = await deps.run(deps.node, [webBuildScript(deps.repoRoot)], {
env: deps.env,
inheritStdio: true,
timeoutMs: WEB_TIMEOUT_MS,
});
if (answer.code !== 0 || answer.timedOut) {
return { ok: false, lines: [`building the web client failed (${why(answer, WEB_TIMEOUT_MS)}; its output is above)`, `after fixing it, run: invisible-dots setup --all`] };
}
if (!(await deps.webBuilt())) {
return { ok: false, lines: [`the web client build finished but its server files are not where invisible-dots looks (apps/web/.next/standalone); build it with: ${WEB_BUILD_COMMAND}`] };
}
return { ok: true, lines: ["built the web client"] };
}
+257
View File
@@ -0,0 +1,257 @@
/**
* The CLI's platform split (architecture section 1.1), and the only place in
* apps/cli that asks which operating system it runs on: how QEMU and the
* accelerator are installed, the distribution's package with sudo on Linux,
* one UAC prompt on Windows that enables the HypervisorPlatform feature and
* runs the official QEMU installer silently. How the accelerator is READ
* (/dev/kvm, the feature state) is the vm-manager's `checkAcceleratorAccess`,
* which the API's doctor route runs too.
*
* Everything else `setup` and `doctor` do is the same code on both hosts.
*/
// The Windows branch builds Windows paths whatever host the tests run on.
import { win32 } from "node:path";
import { ENV } from "@invisible-dots/shared";
import {
checkKvmDevice,
firstLine,
HYPERVISOR_PLATFORM_FEATURE,
MIN_QEMU_VERSION_TEXT,
officialQemuDir,
powershellArgs,
powershellPath,
UNSUPPORTED_FIX,
type AccessDeps,
} from "@invisible-dots/vm-manager";
import {
ELEVATED_RESULT_FILE,
elevatedSetupScript,
type ElevatedScriptOptions,
elevationLauncherScript,
parseElevatedResult,
RESTART_REQUIRED_EXIT_CODE,
UNSAFE_WORK_DIR_EXIT_CODE,
} from "./powershell.js";
import { installerFileName, type WindowsQemuPin } from "./qemu-pin.js";
export const APT_PACKAGES = ["qemu-system-x86", "qemu-utils"] as const;
/** The UAC prompt waits for a person, and the installer copies a few hundred MiB. */
const ELEVATED_TIMEOUT_MS = 60 * 60 * 1000;
export interface InstallDeps extends AccessDeps {
log(line: string): void;
readText(path: string): Promise<string>;
/** Linux: whether setup runs as root (see `currentUserIsRoot`), which it refuses. */
isRoot: boolean;
/** Windows: the pinned installer, read only when QEMU has to be installed. */
windowsQemuPin(): WindowsQemuPin;
makeTempDir(): Promise<string>;
removeDir(path: string): Promise<void>;
/** Downloads `pin.url` to `dest` as the normal user and rejects unless it hashes to `pin.sha256`. */
download(pin: WindowsQemuPin, dest: string): Promise<void>;
/** Windows: the security identifier of the user running setup. */
userSid(): Promise<string>;
/** A name no one can guess, for the elevated session's own directory. */
uniqueName(): string;
}
/**
* Whether this process runs as root. getuid exists only on POSIX hosts, so
* on Windows the answer is false: there setup never runs as an
* administrator, it asks for one prompt instead.
*/
export function currentUserIsRoot(): boolean {
return process.getuid?.() === 0;
}
/**
* Why setup must not go on, or null. As root on Linux it would check
* /dev/kvm for root instead of for the person who runs the server, report
* the accelerator ready, and that person's first Dot would then fail;
* setup calls sudo itself for the one step that needs it, as Windows asks
* for one prompt.
*/
export function setupRefusal(deps: Pick<InstallDeps, "platform" | "isRoot">): string | null {
if (deps.platform === "linux" && deps.isRoot) {
return "run invisible-dots setup as the user who runs the server, not as root: it calls sudo itself for the step that needs it";
}
return null;
}
export interface InstallRequest {
/** qemu-system-x86_64 or qemu-img is missing or too old. */
installQemu: boolean;
/** The accelerator check answered `missing`. */
enableAccelerator: boolean;
}
export type InstallOutcome =
/** Installed; doctor should now see QEMU and the accelerator. */
| { kind: "done"; lines: string[] }
/** Installed, but Windows must restart before the accelerator works. */
| { kind: "restart"; lines: string[] }
/** The person has to run something setup does not run itself. */
| { kind: "manual"; lines: string[] }
| { kind: "failed"; lines: string[] };
/** Installs what doctor found missing, the way this host installs things. */
export async function installHostPrerequisites(request: InstallRequest, deps: InstallDeps): Promise<InstallOutcome> {
if (deps.platform === "linux") return installOnLinux(request, deps);
if (deps.platform === "win32") return installOnWindows(request, deps);
return { kind: "failed", lines: [`${deps.platform} hosts are not supported: ${UNSUPPORTED_FIX}`] };
}
// Linux
export interface OsRelease {
id: string;
idLike: string[];
prettyName?: string;
}
/** /etc/os-release, the fields that say which package manager the distribution uses. */
export function parseOsRelease(text: string): OsRelease {
const fields: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const match = /^([A-Z_]+)=(.*)$/.exec(line.trim());
if (match) fields[match[1]!] = match[2]!.replace(/^(["'])(.*)\1$/, "$2");
}
return {
id: (fields.ID ?? "").toLowerCase(),
idLike: (fields.ID_LIKE ?? "").toLowerCase().split(/\s+/).filter(Boolean),
...(fields.PRETTY_NAME ? { prettyName: fields.PRETTY_NAME } : {}),
};
}
export type PackageManager = { kind: "apt" } | { kind: "print"; line: string };
/**
* apt is run; other package managers only get their line printed, because a
* wrong package name on a distribution nobody tested would install the wrong
* thing with root rights.
*/
export function packageManagerFor(os: OsRelease): PackageManager {
const family = [os.id, ...os.idLike];
if (family.some((id) => id === "debian" || id === "ubuntu")) return { kind: "apt" };
if (family.some((id) => id === "fedora" || id === "rhel" || id === "centos")) {
return { kind: "print", line: "sudo dnf install -y qemu-system-x86 qemu-img" };
}
if (family.includes("arch")) return { kind: "print", line: "sudo pacman -S --needed qemu-system-x86 qemu-img" };
return { kind: "print", line: `install qemu-system-x86_64 and qemu-img (QEMU ${MIN_QEMU_VERSION_TEXT} or newer) with your distribution's package manager` };
}
async function installOnLinux(request: InstallRequest, deps: InstallDeps): Promise<InstallOutcome> {
const manual: string[] = [];
if (request.installQemu) {
const os = parseOsRelease(await deps.readText("/etc/os-release").catch(() => ""));
const manager = packageManagerFor(os);
if (manager.kind === "apt") {
// Never run as root (setupRefusal), so sudo is always the way to the one root step.
const argv = ["sudo", "apt-get", "install", "-y", ...APT_PACKAGES];
deps.log(`$ ${argv.join(" ")}`);
const answer = await deps.run(argv[0]!, argv.slice(1), { inheritStdio: true });
if (answer.code !== 0) {
const why = answer.startError?.message ?? `exit code ${answer.code ?? answer.signal}`;
return { kind: "failed", lines: [`${argv.join(" ")} failed (${why})`] };
}
} else {
manual.push(`QEMU is not installed by setup on ${os.prettyName ?? (os.id || "this distribution")}; run:`, ` ${manager.line}`);
}
}
const access = await checkKvmDevice(deps);
if (access.status !== "ok") manual.push(`${access.detail}; fix:`, ` ${access.fix}`);
return manual.length > 0 ? { kind: "manual", lines: manual } : { kind: "done", lines: [] };
}
// Windows
async function installOnWindows(request: InstallRequest, deps: InstallDeps): Promise<InstallOutcome> {
if (!request.installQemu && !request.enableAccelerator) return { kind: "done", lines: [] };
const work = await deps.makeTempDir();
// The elevated session's own directory; it checks this is directly under the real ProgramData.
const programData = deps.env.ProgramData ?? deps.env.PROGRAMDATA ?? "C:\\ProgramData";
const elevatedDir = win32.join(programData, `invisible-dots-setup-${deps.uniqueName()}`);
try {
let installer: ElevatedScriptOptions["installer"];
if (request.installQemu) {
const pin = deps.windowsQemuPin();
const path = win32.join(work, installerFileName(pin));
deps.log(`downloading the official QEMU ${pin.version} installer: ${pin.url}`);
await deps.download(pin, path);
deps.log(`verified its SHA-256 (${pin.sha256})`);
installer = { path, sha256: pin.sha256, silentArgs: pin.silentArgs };
}
const userSid = await deps.userSid();
const steps = [
...(request.enableAccelerator ? [`enable the ${HYPERVISOR_PLATFORM_FEATURE} feature (dism)`] : []),
...(installer ? ["run the QEMU installer silently"] : []),
];
deps.log(`asking for administrator rights once, to ${steps.join(" and ")}`);
const elevated = elevatedSetupScript({
enableHypervisorPlatform: request.enableAccelerator,
...(installer ? { installer } : {}),
workDir: elevatedDir,
userSid,
});
const powershell = powershellPath(deps.env);
const launch = await deps.run(powershell, powershellArgs(elevationLauncherScript(powershell, elevated)), { timeoutMs: ELEVATED_TIMEOUT_MS });
const text = await deps.readText(win32.join(elevatedDir, ELEVATED_RESULT_FILE)).catch(() => undefined);
if (text === undefined) {
if (launch.code === UNSAFE_WORK_DIR_EXIT_CODE) {
return {
kind: "failed",
lines: [`the elevated step could not create its own directory ${elevatedDir} for administrators only, so it ran nothing; run setup again`],
};
}
const why = firstLine(launch.stderr) || (launch.timedOut ? "it did not finish within an hour" : `exit code ${launch.code}`);
const declined = /cancel/i.test(launch.stderr);
return {
kind: "failed",
lines: [
declined
? "administrator rights were not granted (the prompt was declined); nothing was changed"
: `the elevated step did not report back: ${why}`,
],
};
}
return interpretElevatedResult(text, deps.env);
} finally {
await deps.removeDir(elevatedDir).catch(() => undefined);
await deps.removeDir(work).catch(() => undefined);
}
}
/** What the elevated session's result file means for the person. */
export function interpretElevatedResult(text: string, env: Record<string, string | undefined> = {}): InstallOutcome {
let result;
try {
result = parseElevatedResult(text);
} catch (error) {
return { kind: "failed", lines: [`the elevated step wrote an unreadable result: ${(error as Error).message}`] };
}
if (result.error) return { kind: "failed", lines: [`the elevated step failed: ${result.error}`] };
const lines: string[] = [];
if (result.installer_exit_code === 0) {
lines.push("QEMU installed");
// The installer reuses the directory of an earlier QEMU, which may be one invisible-dots does not search.
const official = officialQemuDir(env);
// Windows paths compared as Windows does: either separator, any case, no trailing one.
const trim = (dir: string) => dir.replace(/[\\/]+/g, "\\").replace(/\\$/, "").toLowerCase();
if (result.install_dir && (!official || trim(result.install_dir) !== trim(official))) {
lines.push(`it went to ${result.install_dir}, where an earlier QEMU was installed: set ${ENV.QEMU_DIR}=${result.install_dir} so invisible-dots uses it`);
}
}
if (result.dism_exit_code === RESTART_REQUIRED_EXIT_CODE) {
lines.push(
`the ${HYPERVISOR_PLATFORM_FEATURE} feature is enabled; Windows must restart before it works`,
"restart Windows, then run: invisible-dots doctor",
);
return { kind: "restart", lines };
}
if (result.dism_exit_code === 0) lines.push(`the ${HYPERVISOR_PLATFORM_FEATURE} feature is enabled`);
return { kind: "done", lines };
}
+160
View File
@@ -0,0 +1,160 @@
/**
* The PowerShell that `invisible-dots setup` runs on Windows (architecture
* section 11.2). Pure text generation, so the tests pin every line that will
* run with administrator rights.
*
* Scripts travel as -EncodedCommand (base64 of UTF-16LE), never as a file:
* nothing on disk can be swapped between generating the elevated script and
* the UAC prompt, and no argument needs quoting rules beyond PowerShell's own.
*/
import { encodeCommand, HYPERVISOR_PLATFORM_FEATURE, powershellArgs } from "@invisible-dots/vm-manager";
/** dism's "succeeded, restart required". */
export const RESTART_REQUIRED_EXIT_CODE = 3010;
/**
* A PowerShell single-quoted string literal. PowerShell also treats the
* typographic quotes U+2018 to U+201B as single quotes, so they are doubled too.
*/
export function psQuote(value: string): string {
return `'${value.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
}
export interface ElevatedScriptOptions {
enableHypervisorPlatform: boolean;
/** The downloaded and verified QEMU installer; omitted when QEMU is already installed. */
installer?: { path: string; sha256: string; silentArgs: readonly string[] };
/**
* A directory that does not exist yet, directly under the system's
* ProgramData. The elevated session creates it for administrators and
* SYSTEM alone (the person running setup may read it) and keeps in it
* everything it runs or writes: the installer's copy and the result.
*/
workDir: string;
/** The security identifier of the person running setup, who reads the result and then removes `workDir`. */
userSid: string;
}
/** The file in `workDir` the elevated session writes what happened to, as JSON. */
export const ELEVATED_RESULT_FILE = "result.json";
/** The elevated session's exit code when it could not create its private directory, so nothing ran. */
export const UNSAFE_WORK_DIR_EXIT_CODE = 2;
/**
* The elevated session. It records each exit code in a JSON file the
* non-elevated setup reads back, because the exit code of an elevated
* process is all Start-Process hands back and it cannot carry both steps.
*
* Nothing it runs or writes is in a place the normal user can change: a
* program running as that user could swap an installer between its hash
* check and its start, plant a DLL next to it, or turn the result file into
* a link to anywhere. So the installer is copied into a new directory under
* ProgramData that only administrators and SYSTEM can write, created with
* that ACL in one step and checked to be empty; the COPY is hashed and run,
* from that directory; the result is written there. dism is found through
* the system directory Windows reports, never an environment variable the
* user can set.
*/
export function elevatedSetupScript(options: ElevatedScriptOptions): string {
const unsafe = UNSAFE_WORK_DIR_EXIT_CODE;
const lines = [
"# invisible-dots setup: the one step that needs administrator rights (architecture section 11.2).",
"$ErrorActionPreference = 'Stop'",
"$result = [ordered]@{ dism_exit_code = $null; installer_exit_code = $null; install_dir = $null; error = $null }",
`$work = ${psQuote(options.workDir)}`,
`$user = New-Object System.Security.Principal.SecurityIdentifier ${psQuote(options.userSid)}`,
"function New-Rule($sid, $rights) { New-Object System.Security.AccessControl.FileSystemAccessRule($sid, $rights, 'ContainerInherit,ObjectInherit', 'None', 'Allow') }",
`if ((Split-Path -Parent $work) -ne [Environment]::GetFolderPath('CommonApplicationData')) { exit ${unsafe} }`,
"$acl = New-Object System.Security.AccessControl.DirectorySecurity",
"$acl.SetAccessRuleProtection($true, $false)",
"$acl.AddAccessRule((New-Rule (New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544') 'FullControl'))",
"$acl.AddAccessRule((New-Rule (New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18') 'FullControl'))",
"$acl.AddAccessRule((New-Rule $user 'ReadAndExecute'))",
`if (Test-Path -LiteralPath $work) { exit ${unsafe} }`,
"[void][System.IO.Directory]::CreateDirectory($work, $acl)",
`if (-not ((Get-Acl -LiteralPath $work).AreAccessRulesProtected) -or (@(Get-ChildItem -LiteralPath $work -Force).Count -ne 0)) { exit ${unsafe} }`,
"try {",
];
if (options.enableHypervisorPlatform) {
lines.push(
" $dism = Join-Path ([Environment]::SystemDirectory) 'dism.exe'",
` & $dism /online /enable-feature /featurename:${HYPERVISOR_PLATFORM_FEATURE} /all /norestart`,
" $result.dism_exit_code = $LASTEXITCODE",
` if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne ${RESTART_REQUIRED_EXIT_CODE}) { throw "dism exited with code $LASTEXITCODE" }`,
);
}
if (options.installer) {
lines.push(
" $installer = Join-Path $work 'qemu-setup.exe'",
` Copy-Item -LiteralPath ${psQuote(options.installer.path)} -Destination $installer`,
" $hash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant()",
` if ($hash -ne ${psQuote(options.installer.sha256.toLowerCase())}) { throw "the QEMU installer is not the one that was verified (its SHA-256 is $hash)" }`,
` $setup = Start-Process -FilePath $installer -ArgumentList ${options.installer.silentArgs.map(psQuote).join(",")} -WorkingDirectory $work -Wait -PassThru`,
" $result.installer_exit_code = $setup.ExitCode",
' if ($setup.ExitCode -ne 0) { throw "the QEMU installer exited with code $($setup.ExitCode)" }',
" $result.install_dir = (Get-ItemProperty -LiteralPath 'HKLM:\\Software\\qemu64' -ErrorAction SilentlyContinue).Install_Dir",
);
}
lines.push(
"} catch {",
" $result.error = $_.Exception.Message",
"}",
"Remove-Item -LiteralPath (Join-Path $work 'qemu-setup.exe') -Force -ErrorAction SilentlyContinue",
`[System.IO.File]::WriteAllText((Join-Path $work ${psQuote(ELEVATED_RESULT_FILE)}), ($result | ConvertTo-Json -Compress))`,
"# Nothing in it runs any more: setup, as the normal user, reads the result and removes the directory.",
"$done = Get-Acl -LiteralPath $work",
"$done.AddAccessRule((New-Rule $user 'FullControl'))",
"Set-Acl -LiteralPath $work -AclObject $done",
"",
);
return lines.join("\r\n");
}
/**
* The non-elevated launcher: ONE Start-Process -Verb RunAs -Wait, so the
* person sees exactly one UAC prompt. Declining it makes Start-Process throw,
* which is reported on stderr and as exit code 1.
*/
export function elevationLauncherScript(powershell: string, elevatedScript: string): string {
const args = powershellArgs(elevatedScript).map(psQuote).join(",");
return [
"$ErrorActionPreference = 'Stop'",
"try {",
` $elevated = Start-Process -FilePath ${psQuote(powershell)} -Verb RunAs -Wait -PassThru -ArgumentList ${args}`,
" exit $elevated.ExitCode",
"} catch {",
" [Console]::Error.WriteLine($_.Exception.Message)",
" exit 1",
"}",
"",
].join("\r\n");
}
export interface ElevatedResult {
dism_exit_code: number | null;
installer_exit_code: number | null;
/** Where the installer put QEMU, as its own registry value says. */
install_dir: string | null;
error: string | null;
}
/** The result file, written by Windows PowerShell; tolerates a byte order mark. */
export function parseElevatedResult(text: string): ElevatedResult {
const value = JSON.parse(text.replace(/^\uFEFF/, "")) as Record<string, unknown>;
const code = (key: string): number | null => {
const v = value[key];
if (v === null || v === undefined) return null;
if (typeof v !== "number" || !Number.isInteger(v)) throw new Error(`${key} is not an exit code: ${JSON.stringify(v)}`);
return v;
};
const error = value.error;
const installDir = value.install_dir;
return {
dism_exit_code: code("dism_exit_code"),
installer_exit_code: code("installer_exit_code"),
install_dir: typeof installDir === "string" && installDir.trim() !== "" ? installDir.trim() : null,
error: typeof error === "string" && error.length > 0 ? error : null,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The QEMU version `setup` installs on Windows, pinned in
* virtualization/qemu/windows.json (architecture sections 2 and 11.2): the
* official installer's URL, its SHA-256 and the arguments that run it
* silently. The project never redistributes QEMU; the pin only says which
* official bytes are acceptable and how to run them. Fields this file does
* not read (size, SHA-512, the provenance notes) are for the person who
* updates the pin.
*/
export interface WindowsQemuPin {
version: string;
url: string;
sha256: string;
/** Passed to the installer in the elevated session, e.g. ["/S"] for NSIS. */
silentArgs: string[];
}
export const WINDOWS_QEMU_PIN_FILE = "virtualization/qemu/windows.json";
/** Validates the pin file, so a typo stops setup before anything is downloaded or elevated. */
export function parseWindowsQemuPin(value: unknown, origin = WINDOWS_QEMU_PIN_FILE): WindowsQemuPin {
if (typeof value !== "object" || value === null) throw new Error(`${origin}: expected a JSON object`);
const record = value as Record<string, unknown>;
const text = (key: string): string => {
const v = record[key];
if (typeof v !== "string" || v.trim() === "") throw new Error(`${origin}: "${key}" must be a non-empty string`);
return v.trim();
};
const version = text("version");
const url = text("url");
const sha256 = text("sha256").toLowerCase();
if (!/^\d+\.\d+/.test(version)) throw new Error(`${origin}: "version" must start with major.minor, got "${version}"`);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`${origin}: "url" is not a URL: "${url}"`);
}
if (parsed.protocol !== "https:") throw new Error(`${origin}: "url" must use https, got "${url}"`);
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${origin}: "sha256" must be 64 hexadecimal characters`);
const silent = record.silent_args;
// Without its silent switch the installer opens a wizard in the elevated session and setup waits on it forever.
if (!Array.isArray(silent) || silent.length === 0 || !silent.every((a) => typeof a === "string" && a.trim() !== "")) {
throw new Error(`${origin}: "silent_args" must be a non-empty list of strings, e.g. ["/S"]`);
}
return { version, url, sha256, silentArgs: silent.map((a: string) => a.trim()) };
}
/**
* The file name the installer is saved under: the URL's own name when it is
* a plain .exe name, else a fixed one. It ends up inside a PowerShell string
* and a Windows path, so nothing but letters, digits, ".", "_" and "-" is kept.
*/
export function installerFileName(pin: WindowsQemuPin): string {
let last = "";
try {
last = decodeURIComponent(new URL(pin.url).pathname.split("/").pop() ?? "");
} catch {
// A malformed escape in the URL: fall back to the fixed name below.
}
return /^[A-Za-z0-9._-]+\.exe$/i.test(last) ? last : `qemu-${pin.version.replace(/[^A-Za-z0-9.-]/g, "")}-setup.exe`;
}
+130
View File
@@ -0,0 +1,130 @@
/**
* `invisible-dots setup` (architecture section 11.2): run doctor, install
* only what is missing for QEMU and its accelerator, then check again. The
* rest of doctor's list (images, the web client, the OpenRouter key) has its own commands,
* which setup names at the end instead of running.
*/
import type { DoctorCheck, DoctorCheckId } from "@invisible-dots/shared";
import { allOk, runDoctor, STORE_OPENROUTER_KEY, WEB_BUILD_COMMAND, type DoctorDeps } from "@invisible-dots/vm-manager";
import { renderReport } from "../doctor/render.js";
import { EXIT } from "../exit.js";
import { installHostPrerequisites, setupRefusal, type InstallDeps, type InstallOutcome, type InstallRequest } from "./install.js";
/** The checks setup is responsible for; it succeeds when these are ok. */
export const SETUP_CHECKS: readonly DoctorCheckId[] = ["qemu", "qemu-img", "accelerator", "accelerator-probe"];
export interface SetupDeps {
doctor: DoctorDeps;
install: InstallDeps;
out(text: string): void;
/** Default: the platform module's installHostPrerequisites; tests pass a fake. */
installPrerequisites?: (request: InstallRequest, deps: InstallDeps) => Promise<InstallOutcome>;
}
function byId(results: readonly DoctorCheck[]): Map<DoctorCheckId, DoctorCheck> {
return new Map(results.map((r) => [r.id, r]));
}
function setupReady(results: readonly DoctorCheck[]): boolean {
const checks = byId(results);
return SETUP_CHECKS.every((id) => checks.get(id)?.status === "ok");
}
/** What is left after QEMU and the accelerator, as the commands that do it. */
export function nextSteps(results: readonly DoctorCheck[]): string[] {
const checks = byId(results);
const steps: string[] = [];
if (checks.get("golden-image")?.status !== "ok" || checks.get("runtime-image")?.status !== "ok") steps.push("invisible-dots image build");
// The row is optional (`DoctorDeps.webBuild`): a report without it has no client to build.
const web = checks.get("web");
if (web && web.status !== "ok") steps.push(WEB_BUILD_COMMAND);
if (checks.get("openrouter")?.status !== "ok") {
steps.push("invisible-dots server (keep it running, then in another terminal:)");
steps.push(STORE_OPENROUTER_KEY);
} else {
steps.push("invisible-dots server");
}
return steps;
}
function printOutcome(outcome: InstallOutcome, out: (text: string) => void): void {
for (const line of outcome.lines) out(`${line}\n`);
}
/** What `prepareHost` ends with: the doctor report once QEMU and the accelerator are ready, or the exit code of the run that could not get them ready. */
export type HostOutcome = { ready: true; results: readonly DoctorCheck[] } | { ready: false; code: number };
/**
* The work of `setup`, up to QEMU and its accelerator being ready: the one place that does it, run by `setup` itself
* and as a step of `setup --all`. It prints what it does; what to do next is the caller's to say.
*/
export async function prepareHost(deps: SetupDeps): Promise<HostOutcome> {
const { out } = deps;
const refusal = setupRefusal(deps.install);
if (refusal) {
out(`${refusal}\n`);
return { ready: false, code: EXIT.usage };
}
out("checking this host first (invisible-dots doctor):\n\n");
const before = await runDoctor(deps.doctor);
out(`${renderReport(before)}\n`);
const checks = byId(before);
const status = (id: DoctorCheckId) => checks.get(id)?.status;
const request = {
installQemu: status("qemu") !== "ok" || status("qemu-img") !== "ok",
enableAccelerator: status("accelerator") === "missing",
};
if (setupReady(before)) {
out("QEMU and its accelerator are ready; nothing to install.\n");
return { ready: true, results: before };
}
if (!request.installQemu && !request.enableAccelerator) {
out("setup cannot fix this by installing something:\n");
for (const id of SETUP_CHECKS) {
const check = checks.get(id);
if (check && check.status !== "ok") out(` ${check.label}: ${check.detail}${check.fix ? `\n fix: ${check.fix}` : ""}\n`);
}
return { ready: false, code: EXIT.failed };
}
let outcome: InstallOutcome;
try {
outcome = await (deps.installPrerequisites ?? installHostPrerequisites)(request, deps.install);
} catch (error) {
outcome = { kind: "failed", lines: [(error as Error).message] };
}
printOutcome(outcome, out);
if (outcome.kind === "restart") return { ready: false, code: EXIT.restart };
if (outcome.kind === "failed") {
out("setup stopped; nothing after the failed step was run.\n");
return { ready: false, code: EXIT.failed };
}
if (outcome.kind === "manual") {
out("then run: invisible-dots doctor\n");
return { ready: false, code: EXIT.failed };
}
out("\nchecking again:\n\n");
const after = await runDoctor(deps.doctor);
out(`${renderReport(after)}\n`);
if (!setupReady(after)) {
out("QEMU or its accelerator is still not usable; see the fix lines above.\n");
return { ready: false, code: EXIT.failed };
}
return { ready: true, results: after };
}
export async function runSetup(deps: SetupDeps): Promise<number> {
const outcome = await prepareHost(deps);
return outcome.ready ? finish(outcome.results, deps.out) : outcome.code;
}
function finish(results: readonly DoctorCheck[], out: (text: string) => void): number {
if (allOk(results)) {
out("this host is ready: invisible-dots server\n");
} else {
out("QEMU is ready. Next:\n");
for (const step of nextSteps(results)) out(` ${step}\n`);
}
return EXIT.ok;
}
+164
View File
@@ -0,0 +1,164 @@
/**
* The web client as a child of `invisible-dots server` (architecture section
* 9.7): the built Next standalone server, started on its own loopback port and
* stopped with the control plane. The web client is a companion, never a
* dependency: when it cannot start, the control plane and the command line go
* on without it, and the server says why.
*/
import { stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { abortableSleep, allowlistedEnvironment, BASE_CHILD_ENV_VARS, ENV } from "@invisible-dots/shared";
import { startProcess, WEB_BUILD_COMMAND, type StartedProcess, type StartProcessOptions, type WebBuild } from "@invisible-dots/vm-manager";
/**
* Where `npm run build --workspace @invisible-dots/web` leaves the server
* (apps/web/scripts/standalone.mjs writes it there: Next puts the standalone
* tree under .next/standalone and the app at its own path inside it, because
* the workspace root is the tracing root) and the static files it serves.
*/
const ENTRY = ["apps", "web", ".next", "standalone", "apps", "web", "server.js"] as const;
const STATIC = ["apps", "web", ".next", "standalone", "apps", "web", ".next", "static"] as const;
/** The script `npm run build --workspace @invisible-dots/web` runs, which `setup --all` runs with node itself. */
export function webBuildScript(repoRoot: string): string {
return join(repoRoot, "apps", "web", "scripts", "build.mjs");
}
export async function locateWebBuild(repoRoot: string): Promise<WebBuild> {
const entry = join(repoRoot, ...ENTRY);
for (const path of [entry, join(repoRoot, ...STATIC)]) {
if (!(await stat(path).catch(() => undefined))) return { entry, missing: path };
}
return { entry, missing: undefined };
}
export class WebStartError extends Error {}
export interface WebServer {
/** The address the person opens, e.g. http://127.0.0.1:3000. */
url: string;
/** Stop the child; resolves once it has exited. Idempotent. */
stop(): Promise<void>;
}
export interface WebServerOptions {
build: WebBuild;
/** A fixed port: the person has to know where to go (parseListen refuses port 0 for this setting). */
listen: { host: string; port: number };
/** The control plane's address, where the web server's proxy sends `/api`. */
apiUrl: string;
/** The server's own environment: only what the web server needs is passed on. */
env: Record<string, string | undefined>;
/** Called once if the child exits while the server runs on. */
onUnexpectedExit: (message: string) => void;
/** Ends the wait for the child to answer, e.g. on Ctrl+C during startup. */
signal?: AbortSignal;
start?: (command: string, args: readonly string[], options: StartProcessOptions) => StartedProcess;
/** How long the child gets to answer its first request. */
readyTimeoutMs?: number;
/** How long the child gets to exit after SIGTERM before it is killed. */
stopTimeoutMs?: number;
}
const READY_TIMEOUT_MS = 30_000;
const STOP_TIMEOUT_MS = 10_000;
const READY_POLL_MS = 100;
/**
* The environment of the web server. Not the server's own: it may hold
* DATABASE_URL with its password. The web server gets what it reads: where
* to listen (Next's PORT and HOSTNAME), where the API is, which data
* directory holds `api.token` (read on every request, so a rotated token
* takes effect), the API token itself only when the server was given it that
* way, and the extra host names its Host check allows. `parentPid` is the
* server's own pid: the web server exits when that process is gone, so a
* `kill -9` of the server does not leave it holding the port.
*/
export function webEnvironment(options: Pick<WebServerOptions, "env" | "listen" | "apiUrl"> & { parentPid: number }): Record<string, string> {
const { env, listen, apiUrl, parentPid } = options;
return {
...allowlistedEnvironment(env, [...BASE_CHILD_ENV_VARS, ENV.HOME, ENV.TOKEN, ENV.WEB_ALLOWED_HOSTS]),
[ENV.URL]: apiUrl,
[ENV.WEB_PARENT_PID]: String(parentPid),
PORT: String(listen.port),
HOSTNAME: listen.host,
};
}
function urlOf(listen: { host: string; port: number }): string {
return `http://${listen.host.includes(":") ? `[${listen.host}]` : listen.host}:${listen.port}`;
}
/** Whether anything answers HTTP at `url`. */
function answers(url: string): Promise<boolean> {
return fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2000) }).then(
(response) => response.body?.cancel().then(() => true, () => true) ?? true,
() => false,
);
}
/**
* Start the web server and resolve once it answers a request. Throws
* WebStartError, with the child already gone, when the build is incomplete,
* the child exits first (a port in use ends here), it does not answer in time
* or `signal` aborts.
*/
export async function startWebServer(options: WebServerOptions): Promise<WebServer> {
const { build, listen } = options;
if (build.missing) {
throw new WebStartError(`the web client is not built (${build.missing} does not exist); build it with: ${WEB_BUILD_COMMAND}`);
}
if (options.signal?.aborted) throw new WebStartError("stopped before the web client started");
const url = urlOf(listen);
// Something else answering there would make the wait below succeed for the wrong server.
if (await answers(url)) {
throw new WebStartError(`${url} is already in use; free the port or set ${ENV.WEB_LISTEN} to another host:port`);
}
// The server script chdirs into its own directory, so that is where it starts: never the server's directory.
const env = webEnvironment({ ...options, parentPid: process.pid });
const child = (options.start ?? startProcess)(process.execPath, [build.entry], { cwd: dirname(build.entry), env });
let exited = false;
let started = false;
let stopping = false;
let exitText = "";
const exit = new Promise<void>((resolve) => {
child.onExit((code, signal, error) => {
exited = true;
const why = error?.message ?? (code === null ? `signal ${signal}` : `exit code ${code}`);
exitText = `the web server exited (${why})${child.stderrTail().trim() ? `: ${child.stderrTail().trim().split(/\r?\n/).slice(-3).join(" | ")}` : ""}`;
if (started && !stopping) options.onUnexpectedExit(exitText);
resolve();
});
});
// The server's own hard exit (a second Ctrl+C) must not leave the child holding the port.
const killAtExit = () => child.kill("SIGKILL");
process.on("exit", killAtExit);
const stop = async () => {
if (stopping) return exit;
stopping = true;
process.off("exit", killAtExit);
child.kill("SIGTERM");
const grace = setTimeout(() => child.kill("SIGKILL"), options.stopTimeoutMs ?? STOP_TIMEOUT_MS);
await exit;
clearTimeout(grace);
};
const deadline = Date.now() + (options.readyTimeoutMs ?? READY_TIMEOUT_MS);
try {
for (;;) {
if (options.signal?.aborted) throw new WebStartError("stopped while the web client was starting");
if (exited) throw new WebStartError(exitText);
if (Date.now() > deadline) throw new WebStartError(`the web server did not answer at ${url} within ${(options.readyTimeoutMs ?? READY_TIMEOUT_MS) / 1000} s`);
if ((await answers(url)) && !exited) {
started = true;
return { url, stop };
}
await abortableSleep(READY_POLL_MS, options.signal);
}
} catch (error) {
await stop();
throw error;
}
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { agentdBuildCommand, defaultRuntimeInputs } from "@invisible-dots/image-builder";
import { WEB_BUILD_COMMAND } from "@invisible-dots/vm-manager";
import { fakeRunner, type Answer } from "../../vm-manager/test/doctor-fakes.js";
import { buildAgent, buildWeb, GO_INSTALL_HINT, type BuildDeps } from "../src/setup/build.js";
import { webBuildScript } from "../src/web.js";
const REPO = "/repo";
function deps(respond: (command: string, args: readonly string[]) => Answer, options: { built?: boolean[] } = {}) {
const runner = fakeRunner(respond);
const built = [...(options.built ?? [false, true])];
const result: BuildDeps = {
run: runner.run,
env: { PATH: "/usr/bin", HOME: "/home/someone" },
repoRoot: REPO,
node: "/usr/bin/node",
webBuilt: async () => built.length > 1 ? built.shift()! : built[0]!,
};
return { deps: result, calls: runner.calls };
}
describe("the guest daemon build", () => {
it("runs the image builder's go build command with its linux/amd64 environment added to the caller's", async () => {
const { deps: d, calls } = deps(() => ({}));
const step = await buildAgent(d);
const build = agentdBuildCommand(REPO);
expect(calls).toHaveLength(1);
expect(calls[0]!.command).toBe("go");
expect(calls[0]!.args).toEqual(build.args);
expect(calls[0]!.options.env).toEqual({ PATH: "/usr/bin", HOME: "/home/someone", CGO_ENABLED: "0", GOOS: "linux", GOARCH: "amd64" });
expect(step).toEqual({ ok: true, lines: [`built ${defaultRuntimeInputs(REPO).agentdBinary}`] });
});
it("asks for Go, with both hosts' install commands, when it is not installed, and to open a new terminal", async () => {
const missing = Object.assign(new Error("spawn go ENOENT"), { code: "ENOENT" });
const { deps: d } = deps(() => ({ code: null, startError: missing }));
const step = await buildAgent(d);
expect(step.ok).toBe(false);
expect(step.lines).toContain(GO_INSTALL_HINT);
expect(GO_INSTALL_HINT).toContain("winget install -e --id GoLang.Go");
expect(GO_INSTALL_HINT).toContain("sudo snap install go --classic");
expect(step.lines.join("\n")).toContain("open a new terminal");
expect(step.lines.join("\n")).toContain("invisible-dots setup --all");
});
it("shows the end of the compiler's complaint when the build fails", async () => {
const stderr = Array.from({ length: 30 }, (_, i) => `error line ${i}`).join("\n");
const { deps: d } = deps(() => ({ code: 1, stderr }));
const step = await buildAgent(d);
expect(step.ok).toBe(false);
expect(step.lines[0]).toBe("building dot-agentd failed (exit code 1):");
expect(step.lines).toHaveLength(11);
expect(step.lines.at(-1)).toBe(" error line 29");
});
it("reports a build that ran out of time", async () => {
const { deps: d } = deps(() => ({ code: null, timedOut: true }));
expect((await buildAgent(d)).lines[0]).toBe("building dot-agentd failed (it did not finish within 15 minutes):");
});
});
describe("the web client build", () => {
it("skips a client that is built, and says how to build it again", async () => {
const { deps: d, calls } = deps(() => ({}), { built: [true] });
const step = await buildWeb(d);
expect(calls).toEqual([]);
expect(step.ok).toBe(true);
expect(step.lines[0]).toContain(WEB_BUILD_COMMAND);
});
it("runs the web build script with node, no shell, Next's telemetry off and the person's terminal for its output", async () => {
const { deps: d, calls } = deps(() => ({}));
const step = await buildWeb(d);
expect(step).toEqual({ ok: true, lines: ["built the web client"] });
expect(calls).toHaveLength(1);
expect(calls[0]!.command).toBe("/usr/bin/node");
expect(calls[0]!.args).toEqual([webBuildScript(REPO)]);
expect(calls[0]!.options.env).toMatchObject({ PATH: "/usr/bin" });
expect(calls[0]!.options.inheritStdio).toBe(true);
});
it("stops with the command to run again when the build fails", async () => {
const { deps: d } = deps(() => ({ code: 1 }));
const step = await buildWeb(d);
expect(step.ok).toBe(false);
expect(step.lines[0]).toBe("building the web client failed (exit code 1; its output is above)");
expect(step.lines[1]).toContain("invisible-dots setup --all");
});
it("does not call a build done whose server files are not there", async () => {
const { deps: d } = deps(() => ({}), { built: [false] });
const step = await buildWeb(d);
expect(step.ok).toBe(false);
expect(step.lines[0]).toContain("not where invisible-dots looks");
expect(step.lines[0]).toContain(WEB_BUILD_COMMAND);
});
});
+813
View File
@@ -0,0 +1,813 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseDotConfig } from "@invisible-dots/shared";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import cliPackage from "../package.json" with { type: "json" };
import { commandOf, EXIT, interruptIsAsked, run, SAMPLE_DOT, type CliIo, type HostCommands } from "../src/index.js";
const TOKEN = "cli-test-token-0123456789";
interface Recorded {
method: string;
path: string;
query: URLSearchParams;
body: unknown;
}
const now = "2026-10-02T08:00:00.000Z";
const dot = {
id: "dot_01abc",
name: "fare-watch",
config: {
name: "fare-watch",
model: { provider: "openrouter", id: "test/model" },
computer: { cpu: 2, memory: "4gb", disk: "40gb", idle_timeout: "15m" },
},
status: "READY",
error: null,
config_version: 1,
created_at: now,
updated_at: now,
computer_state: "RUNNING",
};
const computer = {
dot_id: dot.id,
vm_name: "invisible-dot-dot_01abc",
guest_port: 40123,
pid: 4242,
state: "RUNNING",
golden_image: null,
runtime_image: null,
event_cursor: 3,
last_active_at: now,
next_automation_at: "2030-01-01T09:00:00.000Z",
stop_reason: null,
last_error: null,
updated_at: now,
ready: true,
};
const task = {
id: "task_01",
dot_id: dot.id,
description: "find the cheapest day",
priority: 0,
status: "COMPLETED",
created_at: now,
scheduled_at: null,
started_at: now,
finished_at: now,
summary: "tuesday",
error: null,
};
const channel = {
kind: "telegram",
enabled: true,
status: "connected",
status_detail: null,
account: "dot_helper_bot",
settings: { approvals: true, notify_tasks: true, show_arguments: true },
peers: [{ peer_id: "10", role: "owner", label: "Ann (@ann)", created_at: now }],
created_at: now,
};
let events = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
dot_id: dot.id,
type: i % 2 ? "agent.state" : "automation.next_run",
data: i % 2 ? { state: "IDLE", guest_event_id: "x", guest_ts: now } : { next_run_at_ms: null },
source: "guest",
guest_seq: i + 1,
created_at: now,
}));
let server: Server;
let base: string;
const requests: Recorded[] = [];
let stopped = false;
let stoppedByPerson = false;
/** The Dot has no automation due: the engine reported none. */
let noAutomationDue = false;
/** What the fake server answers about WhatsApp. */
let whatsappLinked = false;
let whatsappOff = false;
let linkFrames: { state: string; [key: string]: unknown }[] = [];
/** The config_version the fake server holds: an update that expects another one is the 409 of a concurrent change. */
let configVersion = 1;
/** Another client saves the Dot's config between a read and the PATCH that follows it. */
let savedMeanwhile = false;
function send(res: ServerResponse, status: number, body?: unknown) {
res.writeHead(status, { "content-type": "application/json" });
res.end(body === undefined ? undefined : JSON.stringify(body));
}
async function handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://x");
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString("utf8");
const body = text ? JSON.parse(text) : undefined;
requests.push({ method: req.method ?? "", path: url.pathname, query: url.searchParams, body });
if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(res, 401, { error: "unauthorized", message: "bad token" });
const route = `${req.method} ${url.pathname}`;
const byName = (p: string) => p.replace("/fare-watch", `/${dot.id}`);
switch (byName(route)) {
case "GET /api/health":
return send(res, 200, { status: "ok", database: "ok", version: "9.9.9" });
case "POST /api/dots":
if (typeof body?.config === "string" && body.config.includes("Bad Name")) {
return send(res, 400, {
error: "invalid_config",
message: "invalid Dot configuration: name: must be lowercase",
details: [{ path: "name", message: "must be lowercase" }],
});
}
return send(res, 201, { ...dot, status: "CREATING" });
case "GET /api/dots":
return send(res, 200, { dots: [dot] });
case `GET /api/dots/${dot.id}`:
return send(res, 200, { ...dot, config_version: configVersion });
case `PATCH /api/dots/${dot.id}`:
if (savedMeanwhile) configVersion++;
if (body?.expected_config_version !== configVersion) return send(res, 409, { error: "dot_changed", message: "the Dot's config changed since it was read" });
return send(res, 200, { ...dot, config_version: configVersion + 1 });
case `DELETE /api/dots/${dot.id}`:
return send(res, 202, { accepted: true });
case "POST /api/tasks/task_02/cancel":
return send(res, 200, { ...task, id: "task_02", status: "CANCELLED" });
case "POST /api/tasks/task_01/cancel":
return send(res, 409, { error: "task_finished", message: "task task_01 is already COMPLETED" });
case `GET /api/dots/${dot.id}/computer`:
return send(res, 200, { ...(stoppedByPerson ? { ...computer, state: "STOPPED", stop_reason: "user", ready: false } : computer), ...(noAutomationDue ? { next_automation_at: null } : {}) });
case `GET /api/dots/${dot.id}/tasks`:
return send(res, 200, { tasks: [task] });
case `POST /api/dots/${dot.id}/tasks`:
return send(res, 201, { ...task, id: "task_02", status: "PENDING", description: body.description });
case `POST /api/dots/${dot.id}/messages`:
return send(res, 202, { message_id: "msg_1", event_id: 9, delivery: "queued" });
case `POST /api/dots/${dot.id}/computer/start`:
case `POST /api/dots/${dot.id}/computer/stop`:
case `POST /api/dots/${dot.id}/computer/reboot`:
return send(res, 202, { accepted: true });
case `GET /api/dots/${dot.id}/browser-identities`:
if (stopped) return send(res, 409, { error: "computer_stopped", message: "the computer of Dot fare-watch is STOPPED; start it first" });
return send(res, 200, {
identities: [
{ id: "shop-abc123", name: "Shop", createdAt: now, lastUsedAt: null, status: "available", profilePath: "/p", hasProxy: false },
{ id: "work-def456", name: "Work", createdAt: now, lastUsedAt: null, status: "available", profilePath: "/q", hasProxy: true },
],
});
case "GET /api/approvals":
return send(res, 200, {
approvals: [{ id: "apr_1", dot_id: dot.id, task_id: null, tool: "browser_identity_delete", permission: "browser.identity.delete", arguments: {}, reason: "cleanup", status: "pending", note: null, created_at: now, resolved_at: null }],
});
case "POST /api/approvals/apr_1/approve":
return send(res, 200, { id: "apr_1", status: "approved", note: body?.note ?? null });
case "POST /api/approvals/apr_1/reject":
return send(res, 200, { id: "apr_1", status: "rejected", note: body?.note ?? null });
case "PUT /api/secrets/openrouter":
return send(res, 200, { pushed: 2 });
case `GET /api/dots/${dot.id}/mcp-secrets`:
return send(res, 200, { dot_id: dot.id, secrets: [{ server: "web", name: "Authorization", set: false }] });
case `PUT /api/dots/${dot.id}/mcp-secrets/web/Authorization`:
return send(res, 200, { dot_id: dot.id, secrets: [{ server: "web", name: "Authorization", set: true }] });
case `DELETE /api/dots/${dot.id}/mcp-secrets/web/Authorization`:
return send(res, 200, { dot_id: dot.id, secrets: [{ server: "web", name: "Authorization", set: false }] });
case `GET /api/dots/${dot.id}/tools`:
if (stopped) return send(res, 409, { error: "computer_stopped", message: "the computer of Dot fare-watch is STOPPED; start it first" });
return send(res, 200, {
tools: [],
mcp_servers: [
{ name: "time", state: "connected", error: null, tools: 2 },
{ name: "web", state: "failed", error: "its secret Authorization is not set", tools: 0 },
],
});
case `GET /api/dots/${dot.id}/channels`:
return send(res, 200, { channels: whatsappLinked ? [channel, { ...channel, kind: "whatsapp", account: "15550001111", peers: [] }] : [channel] });
case `PUT /api/dots/${dot.id}/channels/telegram`:
if (body?.token === "9:REFUSED-TOKEN-VALUE") {
return send(res, 400, { error: "invalid_credentials", message: "Telegram refused the bot token: it is wrong, or it was revoked in @BotFather. Paste a current token." });
}
return send(res, 201, channel);
case `POST /api/dots/${dot.id}/channels/telegram/pairing`:
return send(res, 201, { code: "ABCD2345", deep_link: "https://t.me/dot_helper_bot?start=ABCD2345", message: "/start ABCD2345", expires_at: "2026-10-02T08:10:00.000Z" });
case `POST /api/dots/${dot.id}/channels/whatsapp/pairing`:
if (whatsappLinked) {
return send(res, 201, { code: "WXYZ6789", deep_link: "https://wa.me/15550001111?text=pair%20WXYZ6789", message: "pair WXYZ6789", expires_at: "2026-10-02T08:10:00.000Z" });
}
return send(res, 201, { code: "WXYZ6789", deep_link: null, message: "pair WXYZ6789", expires_at: "2026-10-02T08:10:00.000Z" });
case `POST /api/dots/${dot.id}/channels/whatsapp/link`:
if (whatsappOff) return send(res, 400, { error: "invalid_request", message: 'no "whatsapp" channel: it is off: set INVISIBLE_DOTS_WHATSAPP=1 and restart the server to turn it on' });
return send(res, 202, { ...channel, kind: "whatsapp", status: "connecting", account: null, peers: [] });
case `GET /api/dots/${dot.id}/channels/whatsapp/qr`:
res.writeHead(200, { "content-type": "text/event-stream" });
res.write(": connected\n\n");
for (const frame of linkFrames) res.write(`data: ${JSON.stringify(frame)}\n\n`);
if (linkFrames.at(-1)?.state === "linked" || linkFrames.at(-1)?.state === "failed") return res.end();
return; // left open, like the real stream
case `DELETE /api/dots/${dot.id}/channels/telegram`:
case `DELETE /api/dots/${dot.id}/channels/whatsapp`:
return send(res, 204);
case `GET /api/dots/${dot.id}/events`: {
// The route's paging: after and before bound the ids, order=desc gives the newest first, limit keeps that many.
const after = Number(url.searchParams.get("after") ?? 0);
const before = Number(url.searchParams.get("before") ?? Infinity);
const kept = events.filter((e) => e.id > after && e.id < before);
const ordered = url.searchParams.get("order") === "desc" ? [...kept].reverse() : kept;
return send(res, 200, { events: ordered.slice(0, Number(url.searchParams.get("limit") ?? 1000)) });
}
case "GET /api/stream": {
res.writeHead(200, { "content-type": "text/event-stream" });
const after = Number(url.searchParams.get("after") ?? 0);
res.write(": connected\n\n");
const live = { ...events[0]!, id: after + 1, type: "task.completed", data: { task_id: "task_02", summary: "done" } };
res.write(`id: ${live.id}\ndata: ${JSON.stringify(live)}\n\n`);
return; // left open, like the real stream
}
default:
return send(res, 404, { error: "not_found", message: `no route ${route}` });
}
}
let configDir: string;
beforeAll(async () => {
server = createServer((req, res) => void handle(req, res));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
configDir = await mkdtemp(join(tmpdir(), "idots-cli-"));
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(configDir, { recursive: true, force: true });
});
beforeEach(() => {
requests.length = 0;
stopped = false;
stoppedByPerson = false;
noAutomationDue = false;
whatsappLinked = false;
whatsappOff = false;
linkFrames = [];
configVersion = 1;
savedMeanwhile = false;
});
async function cli(
argv: string[],
options: { env?: Record<string, string>; stdin?: string; line?: string; tty?: boolean; signal?: AbortSignal; host?: HostCommands } = {},
) {
let stdout = "";
let stderr = "";
const io: CliIo = {
stdout: (t) => (stdout += t),
stderr: (t) => (stderr += t),
readStdin: async () => options.stdin ?? "",
readSecret: async () => options.line ?? "",
stdinIsTTY: options.tty ?? false,
env: { INVISIBLE_DOTS_URL: base, INVISIBLE_DOTS_TOKEN: TOKEN, INVISIBLE_DOTS_HOME: configDir, ...options.env },
cwd: configDir,
...(options.signal ? { signal: options.signal } : {}),
...(options.host ? { host: options.host } : {}),
};
const code = await run(argv, io);
return { code, stdout, stderr };
}
describe("usage", () => {
it("prints help with exit 0, and exit 2 with no command, an unknown command or an unknown flag", async () => {
expect((await cli(["--help"])).code).toBe(EXIT.ok);
const none = await cli([]);
expect(none.code).toBe(EXIT.usage);
expect(none.stdout).toContain("invisible-dots setup");
expect((await cli(["launch"])).stderr).toMatch(/unknown command "launch"/);
expect((await cli(["list", "--bogus"])).code).toBe(EXIT.usage);
expect((await cli(["status"])).stderr).toMatch(/missing <dot>/);
expect((await cli(["--version"])).stdout).toBe(`${cliPackage.version}\n`);
});
});
describe("server and token", () => {
it("reads the token from <INVISIBLE_DOTS_HOME>/config/api.token when INVISIBLE_DOTS_TOKEN is unset", async () => {
await mkdir(join(configDir, "config"), { recursive: true });
await writeFile(join(configDir, "config", "api.token"), `${TOKEN}\n`);
const result = await cli(["list"], { env: { INVISIBLE_DOTS_TOKEN: "" } });
expect(result.code).toBe(EXIT.ok);
await rm(join(configDir, "config"), { recursive: true });
});
it("exit 4 without a token or with a refused one, exit 3 when the server is unreachable", async () => {
const missing = await cli(["list"], { env: { INVISIBLE_DOTS_TOKEN: "", INVISIBLE_DOTS_HOME: join(configDir, "none") } });
expect(missing.code).toBe(EXIT.auth);
expect(missing.stderr).toMatch(/no API token: .*api\.token does not exist yet; start the server once/);
const refused = await cli(["list"], { env: { INVISIBLE_DOTS_TOKEN: "wrong-token-123456789" } });
expect(refused.code).toBe(EXIT.auth);
expect(refused.stderr).toMatch(/refused the API token/);
const down = await cli(["list"], { env: { INVISIBLE_DOTS_URL: "http://127.0.0.1:1" } });
expect(down.code).toBe(EXIT.unreachable);
expect(down.stderr).toMatch(/cannot reach the invisible_dots API/);
});
});
describe("commands", () => {
it("init writes the sample, checks the server and refuses to overwrite without --force", async () => {
const first = await cli(["init", "sample.yaml"]);
expect(first.code).toBe(EXIT.ok);
expect(first.stdout).toMatch(/is reachable \(version 9\.9\.9/);
expect(await readFile(join(configDir, "sample.yaml"), "utf8")).toBe(SAMPLE_DOT);
const again = await cli(["init", "sample.yaml"]);
expect(again.code).toBe(EXIT.failed);
expect(again.stderr).toMatch(/already exists/);
expect((await cli(["init", "sample.yaml", "--force"])).code).toBe(EXIT.ok);
});
it("writes a sample the host accepts as it is: every key of it is one the schema knows", () => {
expect(parseDotConfig(SAMPLE_DOT).name).toBe("my-first-dot");
});
it("create sends the YAML text and prints validation details on 400", async () => {
await writeFile(join(configDir, "good.yaml"), SAMPLE_DOT);
const ok = await cli(["create", "good.yaml"]);
expect(ok.code).toBe(EXIT.ok);
expect(ok.stdout).toMatch(/created Dot fare-watch/);
expect(requests.find((r) => r.method === "POST")?.body).toEqual({ config: SAMPLE_DOT });
await writeFile(join(configDir, "bad.yaml"), "name: Bad Name\n");
const bad = await cli(["create", "bad.yaml"]);
expect(bad.code).toBe(EXIT.failed);
expect(bad.stderr).toMatch(/\[400 invalid_config\]/);
expect(bad.stderr).toMatch(/name: must be lowercase/);
expect((await cli(["create", "missing.yaml"])).code).toBe(EXIT.usage);
});
it("list, status and tasks print tables, and --json prints the data", async () => {
const list = await cli(["list"]);
expect(list.stdout).toMatch(/^NAME\s+STATUS\s+COMPUTER/);
expect(list.stdout).toContain("fare-watch READY");
const json = await cli(["list", "--json"]);
expect(JSON.parse(json.stdout)[0].id).toBe(dot.id);
const status = await cli(["status", "fare-watch"]);
expect(status.code).toBe(EXIT.ok);
expect(status.stdout).toMatch(/computer\s+RUNNING, ready/);
expect(status.stdout).toMatch(/next automation\s+2030-01-01T09:00:00.000Z/);
expect(status.stdout).toContain("task_01");
// It shows the newest ten tasks, and asks for ten.
expect(requests.find((r) => r.path === "/api/dots/fare-watch/tasks")?.query.get("limit")).toBe("10");
expect((await cli(["tasks", "fare-watch"])).stdout).toContain("COMPLETED");
});
it("status does not say paused for a Dot the person stopped that has no automation due", async () => {
stoppedByPerson = true;
noAutomationDue = true;
const status = await cli(["status", "fare-watch"]);
expect(status.stdout).toMatch(/next automation\s+none due/);
expect(status.stdout).not.toMatch(/paused/);
});
it("status says the automations are paused while the person's stop lasts, and stop says so when it is asked for", async () => {
stoppedByPerson = true;
const status = await cli(["status", "fare-watch"]);
expect(status.stdout).toMatch(/computer\s+STOPPED/);
expect(status.stdout).toMatch(/next automation\s+paused: the computer was stopped by you \(start it to resume\)/);
expect(status.stdout).not.toContain("2030-01-01T09:00:00.000Z");
const stop = await cli(["computer", "fare-watch", "stop"]);
expect(stop.stdout).toMatch(/automations are paused while the computer is stopped; resume them with: invisible-dots computer fare-watch start/);
expect((await cli(["computer", "fare-watch", "start"])).stdout).not.toMatch(/paused/);
});
it("message and task send what was typed", async () => {
const message = await cli(["message", "fare-watch", "how", "is", "it", "going?"]);
expect(message.stdout).toMatch(/message queued/);
expect(requests.at(-1)?.body).toEqual({ text: "how is it going?" });
const queued = await cli(["task", "fare-watch", "check", "Lisbon", "--priority", "3", "--at", "2030-01-01T08:00:00Z"]);
expect(queued.code).toBe(EXIT.ok);
expect(requests.at(-1)?.body).toEqual({ description: "check Lisbon", priority: 3, scheduled_at: "2030-01-01T08:00:00.000Z" });
expect((await cli(["task", "fare-watch", "x", "--priority", "high"])).code).toBe(EXIT.usage);
expect((await cli(["message", "fare-watch"])).code).toBe(EXIT.usage);
});
it("computer actions, and browser identities with the server's 409 shown", async () => {
for (const action of ["start", "stop", "reboot"]) {
expect((await cli(["computer", "fare-watch", action])).code).toBe(EXIT.ok);
expect(requests.at(-1)?.path).toBe(`/api/dots/fare-watch/computer/${action}`);
}
expect((await cli(["computer", "fare-watch", "pause"])).code).toBe(EXIT.usage);
const listed = (await cli(["browser", "fare-watch", "identities"])).stdout;
expect(listed).toContain("shop-abc123");
// The identity has no proxy of its own, the normal case: the column says none instead of asking for one; one that has a
// proxy says only that.
expect(listed).toMatch(/shop-abc123\s+Shop\s+available\s+never\s+-\s*$/m);
expect(listed).toMatch(/work-def456\s+Work\s+available\s+never\s+yes\s*$/m);
stopped = true;
const conflict = await cli(["browser", "fare-watch", "identities"]);
expect(conflict.code).toBe(EXIT.failed);
expect(conflict.stderr).toMatch(/start it first \[409 computer_stopped\]/);
});
it("approvals, approve and reject", async () => {
const pending = await cli(["approvals"]);
expect(pending.stdout).toContain("apr_1");
const listed = requests.find((r) => r.path === "/api/approvals");
expect(listed?.query.get("status")).toBe("pending");
expect(listed?.query.get("order")).toBeNull();
// The Dot by its name, and the question and the permission in the web client's words.
expect(pending.stdout.split("\n")[0]).toMatch(/^ID\s+STATUS\s+DOT\s+WANTS TO\s+PERMISSION\s+REASON$/);
expect(pending.stdout).toMatch(/apr_1\s+pending\s+fare-watch\s+delete a browser identity\s+Delete browser identities \(high risk\)\s+cleanup/);
requests.length = 0;
await cli(["approvals", "--all"]);
const all = requests.find((r) => r.path === "/api/approvals");
expect(all?.query.get("status")).toBeNull();
// The newest first, so the cap of the list drops the oldest and never the newest.
expect(all?.query.get("order")).toBe("desc");
const approved = await cli(["approve", "apr_1", "--note", "fine"]);
expect(approved.stdout).toBe("approval apr_1 approved\n");
expect(requests.at(-1)?.body).toEqual({ note: "fine" });
expect((await cli(["reject", "apr_1"])).stdout).toBe("approval apr_1 rejected\n");
expect(requests.at(-1)?.body).toEqual({});
await cli(["approve", "apr_1", "--always"]);
expect(requests.at(-1)?.body).toEqual({ always: true });
await cli(["approve", "apr_1", "--always", "--note", "ok"]);
expect(requests.at(-1)?.body).toEqual({ note: "ok", always: true });
const refused = await cli(["reject", "apr_1", "--always"]);
expect(refused.code).toBe(EXIT.usage);
expect(refused.stderr).toContain("--always applies to approve");
});
it("secret openrouter reads the key from stdin only", async () => {
const stored = await cli(["secret", "openrouter"], { stdin: "sk-or-v1-abc\n" });
expect(stored.code).toBe(EXIT.ok);
expect(stored.stdout).toMatch(/pushed to 2 running Dots/);
expect(requests.at(-1)?.body).toEqual({ value: "sk-or-v1-abc" });
await cli(["secret", "openrouter", "--dot", "fare-watch"], { stdin: "sk-or-v1-abc" });
expect(requests.at(-1)?.body).toEqual({ value: "sk-or-v1-abc", dot_id: "fare-watch" });
const inArgs = await cli(["secret", "openrouter", "sk-or-v1-abc"]);
expect(inArgs.code).toBe(EXIT.usage);
expect(inArgs.stderr).toMatch(/never from arguments/);
expect((await cli(["secret", "openrouter"], { stdin: " " })).code).toBe(EXIT.usage);
});
it("secret mcp reads the value from stdin only, keeps a space a header needs, and --clear removes it", async () => {
const stored = await cli(["secret", "mcp", "--dot", "fare-watch", "web", "Authorization"], { stdin: "Bearer tok-1\n" });
expect(stored.code).toBe(EXIT.ok);
expect(stored.stdout).toContain("Authorization of the MCP server web stored");
expect(requests.at(-1)).toMatchObject({ method: "PUT", body: { value: "Bearer tok-1" } });
expect(stored.stdout).not.toContain("tok-1");
const cleared = await cli(["secret", "mcp", "--dot", "fare-watch", "web", "Authorization", "--clear"]);
expect(cleared.stdout).toContain("cleared");
expect(requests.at(-1)?.method).toBe("DELETE");
const inArgs = await cli(["secret", "mcp", "--dot", "fare-watch", "web", "Authorization", "tok-1"]);
expect(inArgs.code).toBe(EXIT.usage);
expect(inArgs.stderr).toMatch(/never from arguments/);
expect((await cli(["secret", "mcp", "web", "Authorization"], { stdin: "x" })).stderr).toContain("missing --dot");
});
it("mcp lists the declared servers with where each is and which secrets are set, and still the secrets of a stopped Dot", async () => {
const listed = await cli(["mcp", "fare-watch"]);
expect(listed.code).toBe(EXIT.ok);
expect(listed.stdout).toMatch(/time\s+connected, 2 tools\s+-/);
expect(listed.stdout).toMatch(/web\s+failed: its secret Authorization is not set\s+Authorization \(not set\)/);
stopped = true;
try {
const off = await cli(["mcp", "fare-watch"]);
expect(off.code).toBe(EXIT.ok);
expect(off.stdout).toMatch(/web\s+computer stopped\s+Authorization \(not set\)/);
} finally {
stopped = false;
}
});
it("secret openrouter in a terminal reads one line, which Enter ends on every host", async () => {
// A terminal never reaches end of input by itself, and the key that ends it differs by host:
// only the line is read, so the command never waits for it.
const typed = await cli(["secret", "openrouter"], {
tty: true,
line: "sk-or-v1-typed",
stdin: "never read: a terminal does not end",
});
expect(typed.code).toBe(EXIT.ok);
expect(typed.stderr).toBe("paste the OpenRouter API key, then press Enter:\n");
expect(typed.stderr).not.toMatch(/Ctrl/);
expect(requests.at(-1)?.body).toEqual({ value: "sk-or-v1-typed" });
const empty = await cli(["secret", "openrouter"], { tty: true, line: "" });
expect(empty.code).toBe(EXIT.usage);
// The hint is a command that runs as printed in PowerShell too: no "<" redirection.
expect(empty.stderr).toContain('run "invisible-dots secret openrouter" in a terminal');
expect(empty.stderr).not.toContain("<");
});
it("channel add reads the token from stdin or one line of a terminal, never from arguments, and never prints it", async () => {
const piped = await cli(["channel", "add", "telegram", "--dot", "fare-watch"], { stdin: "123456:SECRET-TOKEN-VALUE\n" });
expect(piped.code).toBe(EXIT.ok);
expect(requests.at(-1)).toMatchObject({ method: "PUT", path: `/api/dots/fare-watch/channels/telegram`, body: { token: "123456:SECRET-TOKEN-VALUE" } });
expect(piped.stdout).toContain("linked Telegram bot @dot_helper_bot to Dot fare-watch\n");
expect(piped.stdout).toContain("next: invisible-dots channel pair telegram --dot fare-watch\n");
expect(piped.stdout).toMatch(/not end-to-end encrypted/);
expect(piped.stdout + piped.stderr).not.toContain("SECRET-TOKEN");
const typed = await cli(["channel", "add", "telegram", "--dot", "fare-watch"], { tty: true, line: "123456:TYPED-TOKEN-VALUE", stdin: "never read" });
expect(typed.code).toBe(EXIT.ok);
expect(typed.stderr).toBe("paste the Telegram bot token from @BotFather, then press Enter:\n");
expect(requests.at(-1)?.body).toEqual({ token: "123456:TYPED-TOKEN-VALUE" });
const inArgs = await cli(["channel", "add", "telegram", "123456:SECRET-TOKEN-VALUE", "--dot", "fare-watch"]);
expect(inArgs.code).toBe(EXIT.usage);
expect(inArgs.stderr).toMatch(/never from arguments/);
expect(inArgs.stderr).not.toContain("SECRET-TOKEN");
const empty = await cli(["channel", "add", "telegram", "--dot", "fare-watch"], { tty: true, line: "" });
expect(empty.code).toBe(EXIT.usage);
expect(empty.stderr).toContain('run "invisible-dots channel add telegram --dot fare-watch" in a terminal');
expect((await cli(["channel", "add", "telegram"], { stdin: "123456:SECRET-TOKEN-VALUE" })).stderr).toMatch(/missing --dot <dot>/);
expect((await cli(["channel", "add", "whatsapp", "--dot", "fare-watch"])).stderr).toMatch(/only telegram is supported/);
const json = await cli(["channel", "add", "telegram", "--dot", "fare-watch", "--json"], { stdin: "123456:SECRET-TOKEN-VALUE" });
expect(JSON.parse(json.stdout)).toMatchObject({ kind: "telegram", account: "dot_helper_bot" });
});
it("channel add shows what the server said about a refused token, without the token", async () => {
const refused = await cli(["channel", "add", "telegram", "--dot", "fare-watch"], { stdin: "9:REFUSED-TOKEN-VALUE" });
expect(refused.code).toBe(EXIT.failed);
expect(refused.stderr).toMatch(/refused the bot token.*\[400 invalid_credentials\]/);
expect(refused.stderr).not.toContain("REFUSED-TOKEN-VALUE");
});
it("channel list prints a table of every Dot's channels, or one Dot's, and --json the data", async () => {
const all = await cli(["channel", "list"]);
expect(all.code).toBe(EXIT.ok);
expect(all.stdout.split("\n")[0]).toMatch(/^DOT\s+CHANNEL\s+STATUS\s+ACCOUNT\s+PEOPLE\s+NOTE/);
expect(all.stdout).toMatch(/fare-watch\s+telegram\s+connected\s+@dot_helper_bot\s+Ann \(@ann\)/);
const one = await cli(["channel", "list", "--dot", "fare-watch"]);
expect(one.stdout).toBe(all.stdout);
expect(JSON.parse((await cli(["channel", "list", "--json"])).stdout)).toEqual([{ dot: "fare-watch", channel }]);
expect((await cli(["channel", "list", "extra"])).code).toBe(EXIT.usage);
});
it("channel pair prints the link and the code, or only the code where a channel has no link", async () => {
const paired = await cli(["channel", "pair", "telegram", "--dot", "fare-watch"]);
expect(paired.code).toBe(EXIT.ok);
expect(paired.stdout).toBe(
"open this link on the device where you use telegram, then press Start (valid until 2026-10-02T08:10:00.000Z):\n https://t.me/dot_helper_bot?start=ABCD2345\nor send the bot: /start ABCD2345\n",
);
expect((await cli(["channel", "pair", "whatsapp", "--dot", "fare-watch"])).stdout).toBe("pairing code WXYZ6789, valid until 2026-10-02T08:10:00.000Z\n");
expect((await cli(["channel", "pair", "carrier-pigeon", "--dot", "fare-watch"])).stderr).toMatch(/unknown channel "carrier-pigeon": use telegram or whatsapp/);
expect((await cli(["channel", "pair", "telegram"])).stderr).toMatch(/missing --dot <dot>/);
});
it("channel remove unlinks, and an unknown subcommand is a usage error", async () => {
const removed = await cli(["channel", "remove", "telegram", "--dot", "fare-watch"]);
expect(removed.code).toBe(EXIT.ok);
expect(removed.stdout).toBe("unlinked telegram from Dot fare-watch: its token and paired people are deleted\n");
expect(requests.at(-1)).toMatchObject({ method: "DELETE", path: "/api/dots/fare-watch/channels/telegram" });
expect((await cli(["channel", "remove", "whatsapp", "--dot", "fare-watch"])).stdout).toBe("unlinked whatsapp from Dot fare-watch: the linked device's keys and paired people are deleted\n");
expect((await cli(["channel", "remove", "telegram"])).code).toBe(EXIT.usage);
expect((await cli(["channel", "mute"])).stderr).toMatch(/unknown channel subcommand "mute": use add, link, list, pair or remove/);
expect((await cli(["channel"])).stderr).toMatch(/missing add\|link\|list\|pair\|remove/);
});
it("channel link whatsapp warns of the risk, shows each code as a QR for the terminal and ends when the number is linked", async () => {
linkFrames = [{ state: "waiting" }, { state: "code", code: "2@first-code" }, { state: "code", code: "2@second-code" }, { state: "linked", account: "15550001111" }];
const linked = await cli(["channel", "link", "whatsapp", "--dot", "fare-watch"]);
expect(linked.code).toBe(EXIT.ok);
expect(requests.map((r) => `${r.method} ${r.path}`).slice(-2)).toEqual(["POST /api/dots/fare-watch/channels/whatsapp/link", "GET /api/dots/fare-watch/channels/whatsapp/qr"]);
expect(linked.stderr).toContain("can answer by banning the account");
expect(linked.stderr).toContain("Linked devices");
// Two codes, drawn with the block characters of a terminal QR, and never printed as text.
expect(linked.stdout.match(/█|▀|▄/g)?.length ?? 0).toBeGreaterThan(100);
expect(linked.stdout).not.toContain("2@first-code");
expect(linked.stdout).toContain("linked WhatsApp number +15550001111 to Dot fare-watch\nnext: invisible-dots channel pair whatsapp --dot fare-watch\n");
});
it("channel link whatsapp exits with the reason when the link fails, and says what the server said when WhatsApp is off", async () => {
linkFrames = [{ state: "waiting" }, { state: "failed", detail: "The link was not completed: the code expired. Start linking again." }];
const failed = await cli(["channel", "link", "whatsapp", "--dot", "fare-watch"]);
expect(failed.code).toBe(EXIT.failed);
expect(failed.stderr).toContain("invisible-dots: The link was not completed: the code expired. Start linking again.\n");
whatsappOff = true;
const off = await cli(["channel", "link", "whatsapp", "--dot", "fare-watch"]);
expect(off.code).toBe(EXIT.failed);
expect(off.stderr).toContain("INVISIBLE_DOTS_WHATSAPP=1");
});
it("channel link whatsapp --json prints the frames, code included, as lines of JSON", async () => {
linkFrames = [{ state: "code", code: "2@first-code" }, { state: "linked", account: "15550001111" }];
const json = await cli(["channel", "link", "whatsapp", "--dot", "fare-watch", "--json"]);
expect(json.code).toBe(EXIT.ok);
expect(json.stdout.trim().split("\n").map((line) => JSON.parse(line))).toEqual(linkFrames);
});
it("channel link takes only whatsapp and a Dot, and channel add points WhatsApp to link", async () => {
expect((await cli(["channel", "link", "telegram", "--dot", "fare-watch"])).stderr).toMatch(/only whatsapp is linked that way/);
expect((await cli(["channel", "link", "whatsapp"])).stderr).toMatch(/missing --dot <dot>/);
expect((await cli(["channel", "link", "whatsapp", "extra", "--dot", "fare-watch"])).code).toBe(EXIT.usage);
expect((await cli(["channel", "add", "whatsapp", "--dot", "fare-watch"])).stderr).toContain('invisible-dots channel link whatsapp --dot <dot>');
});
it("channel pair gives WhatsApp's link and words once the number is known, and list shows the number with a plus", async () => {
whatsappLinked = true;
const paired = await cli(["channel", "pair", "whatsapp", "--dot", "fare-watch"]);
expect(paired.stdout).toBe(
"open this link on the device where you use whatsapp, then press Send (valid until 2026-10-02T08:10:00.000Z):\n https://wa.me/15550001111?text=pair%20WXYZ6789\nor send the number: pair WXYZ6789\n",
);
const list = await cli(["channel", "list", "--dot", "fare-watch"]);
expect(list.stdout).toMatch(/fare-watch\s+telegram\s+connected\s+@dot_helper_bot/);
expect(list.stdout).toMatch(/fare-watch\s+whatsapp\s+connected\s+\+15550001111/);
});
it("logs prints the tail without follow, and follows the stream until interrupted", async () => {
const tail = await cli(["logs", "fare-watch", "--tail", "2", "--no-follow"]);
expect(tail.code).toBe(EXIT.ok);
const lines = tail.stdout.trim().split("\n");
expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(/#4 agent\.state \{"state":"IDLE"\}$/);
// The log is read from its end, the newest two in one request, not paged from its start.
const reads = requests.filter((r) => r.path === `/api/dots/${dot.id}/events`);
expect(reads.map((r) => [r.query.get("order"), r.query.get("limit"), r.query.get("after")])).toEqual([["desc", "2", null]]);
const controller = new AbortController();
let output = "";
const io: CliIo = {
stdout: (t) => {
output += t;
if (t.includes("task.completed")) controller.abort();
},
stderr: () => {},
readStdin: async () => "",
readSecret: async () => "",
stdinIsTTY: false,
env: { INVISIBLE_DOTS_URL: base, INVISIBLE_DOTS_TOKEN: TOKEN },
cwd: configDir,
signal: controller.signal,
};
expect(await run(["logs", "fare-watch", "--tail", "1"], io)).toBe(EXIT.ok);
expect(output).toMatch(/#5 automation\.next_run/);
expect(output).toMatch(/#6 task\.completed/);
const stream = requests.find((r) => r.path === "/api/stream");
expect(stream?.query.get("after")).toBe("5");
expect(stream?.query.get("dot_id")).toBe(dot.id);
});
it("logs --tail 0 prints nothing old and follows from the newest event, which it reads alone", async () => {
const quiet = await cli(["logs", "fare-watch", "--tail", "0", "--no-follow"]);
expect(quiet.code).toBe(EXIT.ok);
expect(quiet.stdout).toBe("");
expect(requests.filter((r) => r.path === `/api/dots/${dot.id}/events`).map((r) => r.query.get("limit"))).toEqual(["1"]);
});
it("logs --tail beyond a page reads the newest pages from the end, older one by one, and stops at the start of the log", async () => {
const saved = events;
events = Array.from({ length: 2500 }, (_, i) => ({ ...saved[0]!, id: i + 1 }));
try {
const tail = await cli(["logs", "fare-watch", "--tail", "2200", "--no-follow"]);
const lines = tail.stdout.trim().split("\n");
expect(lines).toHaveLength(2200);
expect(lines[0]).toContain("#301 ");
expect(lines.at(-1)).toContain("#2500 ");
const reads = requests.filter((r) => r.path === `/api/dots/${dot.id}/events`);
expect(reads.map((r) => [r.query.get("limit"), r.query.get("before")])).toEqual([
["1000", null],
["1000", "1501"],
["200", "501"],
]);
requests.length = 0;
expect((await cli(["logs", "fare-watch", "--tail", "9000", "--no-follow"])).stdout.trim().split("\n")).toHaveLength(2500);
expect(requests.filter((r) => r.path === `/api/dots/${dot.id}/events`)).toHaveLength(3);
} finally {
events = saved;
}
});
it("cancel cancels a task, and shows the server's 409 for one that has finished", async () => {
const cancelled = await cli(["cancel", "task_02"]);
expect(cancelled.code).toBe(EXIT.ok);
expect(cancelled.stdout).toBe("task task_02 CANCELLED\n");
expect(requests.at(-1)).toMatchObject({ method: "POST", path: "/api/tasks/task_02/cancel" });
const finished = await cli(["cancel", "task_01"]);
expect(finished.code).toBe(EXIT.failed);
expect(finished.stderr).toMatch(/already COMPLETED \[409 task_finished\]/);
expect((await cli(["cancel"])).stderr).toMatch(/missing <task-id>/);
});
it("delete refuses without --yes and says what it destroys, and deletes with it", async () => {
const refused = await cli(["delete", "fare-watch"]);
expect(refused.code).toBe(EXIT.usage);
expect(refused.stderr).toContain("cannot be undone");
expect(refused.stderr).toContain("invisible-dots delete fare-watch --yes");
expect(requests).toHaveLength(0);
const deleted = await cli(["delete", "fare-watch", "--yes"]);
expect(deleted.code).toBe(EXIT.ok);
expect(requests.at(-1)).toMatchObject({ method: "DELETE", path: `/api/dots/${dot.id}` });
expect(deleted.stdout).toBe(`deleting Dot fare-watch; what it did stays in its log: invisible-dots logs ${dot.id}\n`);
});
it("update sends the YAML with the config version it read, so a change made meanwhile is a 409 and nothing is saved", async () => {
await writeFile(join(configDir, "changed.yaml"), SAMPLE_DOT);
configVersion = 4;
const updated = await cli(["update", "fare-watch", "changed.yaml"]);
expect(updated.code).toBe(EXIT.ok);
expect(updated.stdout).toBe("updated Dot fare-watch (config version 5)\n");
expect(requests.at(-1)).toMatchObject({ method: "PATCH", path: `/api/dots/${dot.id}`, body: { config: SAMPLE_DOT, expected_config_version: 4 } });
// The web client saved between this command's read and its write.
savedMeanwhile = true;
const raced = await cli(["update", "fare-watch", "changed.yaml"]);
expect(raced.code).toBe(EXIT.failed);
expect(raced.stderr).toMatch(/\[409 dot_changed\]/);
expect((await cli(["update", "fare-watch", "missing.yaml"])).code).toBe(EXIT.usage);
expect((await cli(["update", "fare-watch"])).stderr).toMatch(/missing <file.yaml>/);
});
});
describe("host commands", () => {
function fakeHost() {
const calls: string[] = [];
const host: HostCommands = {
doctor: async (options, io) => {
calls.push(`doctor json=${options.json}`);
io.stdout("all 10 checks ok\n");
return EXIT.ok;
},
setup: async () => {
calls.push("setup");
return EXIT.restart;
},
setupAll: async () => {
calls.push("setup --all");
return EXIT.ok;
},
imageBuild: async () => {
calls.push("image build");
return EXIT.failed;
},
server: async (_io, options) => {
calls.push(`server web=${options.web}`);
return EXIT.ok;
},
};
return { host, calls };
}
it("routes setup, doctor, image build and server, and returns their exit codes", async () => {
const { host, calls } = fakeHost();
expect((await cli(["doctor"], { host })).stdout).toBe("all 10 checks ok\n");
expect((await cli(["doctor", "--json"], { host })).code).toBe(EXIT.ok);
expect((await cli(["setup"], { host })).code).toBe(EXIT.restart);
expect((await cli(["setup", "--all"], { host })).code).toBe(EXIT.ok);
expect((await cli(["image", "build"], { host })).code).toBe(EXIT.failed);
expect((await cli(["server"], { host })).code).toBe(EXIT.ok);
expect((await cli(["server", "--no-web"], { host })).code).toBe(EXIT.ok);
expect(calls).toEqual(["doctor json=false", "doctor json=true", "setup", "setup --all", "image build", "server web=true", "server web=false"]);
// None of them needs the API or its token.
expect(requests).toHaveLength(0);
});
it("refuses stray arguments with exit 2, and reports a host command that throws with exit 1", async () => {
const { host, calls } = fakeHost();
expect((await cli(["image"], { host })).stderr).toMatch(/missing build/);
expect((await cli(["image", "pull"], { host })).stderr).toMatch(/unknown image subcommand "pull"/);
expect((await cli(["image", "build", "now"], { host })).code).toBe(EXIT.usage);
expect((await cli(["setup", "x"], { host })).code).toBe(EXIT.usage);
expect((await cli(["setup", "--all", "x"], { host })).code).toBe(EXIT.usage);
expect((await cli(["doctor", "all"], { host })).stderr).toMatch(/doctor takes no arguments/);
expect(calls).toEqual([]);
host.server = async () => {
throw new Error("another invisible-dots server (pid 42) is already running");
};
const failed = await cli(["server"], { host });
expect(failed.code).toBe(EXIT.failed);
expect(failed.stderr).toBe("invisible-dots: another invisible-dots server (pid 42) is already running\n");
});
it("lists both channels where a command takes either", async () => {
const help = (await cli(["--help"])).stdout;
expect(help).toContain("invisible-dots channel pair telegram|whatsapp --dot <dot>");
expect(help).toContain("invisible-dots channel remove telegram|whatsapp --dot <dot>");
});
it("lists setup --all in the help, first among the host commands", async () => {
const help = (await cli(["--help"])).stdout;
expect(help).toMatch(/invisible-dots setup --all .*everything in one run/);
expect(help.indexOf("setup --all")).toBeLessThan(help.indexOf("invisible-dots setup "));
});
it("asks the process to stop on Ctrl-C only for logs, image build and setup --all", () => {
expect(interruptIsAsked(["logs", "fare-watch"])).toBe(true);
expect(interruptIsAsked(["image", "build"])).toBe(true);
expect(interruptIsAsked(["setup", "--all"])).toBe(true);
expect(interruptIsAsked(["--all", "setup"])).toBe(true);
expect(interruptIsAsked(["setup"])).toBe(false);
expect(interruptIsAsked(["server"])).toBe(false);
expect(interruptIsAsked(["approvals", "--all"])).toBe(false);
});
it("finds the command word for main.ts wherever the flags are", () => {
expect(commandOf(["--json", "doctor"])).toBe("doctor");
expect(commandOf(["logs", "fare-watch", "--tail", "3"])).toBe("logs");
expect(commandOf(["--help"])).toBeUndefined();
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { DoctorCheck } from "@invisible-dots/shared";
import { describe, expect, it } from "vitest";
import { doctorAnswer } from "@invisible-dots/vm-manager";
import { FOUND, healthyDoctor, QEMU_IMG } from "../../vm-manager/test/doctor-fakes.js";
import { doctorCommand } from "../src/doctor/command.js";
import { renderReport } from "../src/doctor/render.js";
import { EXIT } from "../src/exit.js";
const find = (results: DoctorCheck[], id: DoctorCheck["id"]) => results.find((r) => r.id === id)!;
describe("doctor report", () => {
const sample: DoctorCheck[] = [
{ id: "node", label: "Node", status: "ok", detail: "24.19.0" },
{ id: "qemu", label: "QEMU", status: "missing", detail: "qemu-system-x86_64 not found", fix: "invisible-dots setup" },
{ id: "disk", label: "data directory", status: "failed", detail: "1.0 GiB free at /data", fix: "free some space" },
{ id: "openrouter", label: "OpenRouter key", status: "ok", detail: "stored" },
];
it("prints one aligned line per check, a fix line under each that is not ok, and a count", () => {
expect(renderReport(sample)).toBe(
[
"ok Node 24.19.0",
"missing QEMU qemu-system-x86_64 not found",
" fix: invisible-dots setup",
"failed data directory 1.0 GiB free at /data",
" fix: free some space",
"ok OpenRouter key stored",
"4 checks: 2 ok, 1 missing, 1 failed",
"",
].join("\n"),
);
expect(renderReport([sample[0]!, sample[3]!]).endsWith("all 2 checks ok\n")).toBe(true);
});
it("exits 0 only when every check is ok, and prints JSON with --json", async () => {
let text = "";
expect(await doctorCommand(healthyDoctor().deps, { json: false }, (t) => (text += t))).toBe(EXIT.ok);
expect(text.endsWith("all 9 checks ok\n")).toBe(true);
let json = "";
const missing = healthyDoctor({ findQemu: async () => ({ ...FOUND, img: undefined }) }).deps;
expect(await doctorCommand(missing, { json: true }, (t) => (json += t))).toBe(EXIT.failed);
const parsed = JSON.parse(json) as { ok: boolean; checks: DoctorCheck[] };
expect(parsed.ok).toBe(false);
expect(find(parsed.checks, "qemu-img")).toMatchObject({ status: "missing", fix: "invisible-dots setup" });
expect(QEMU_IMG).toBe(FOUND.img);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { describe, expect, it } from "vitest";
import {
installHostPrerequisites,
interpretElevatedResult,
packageManagerFor,
parseOsRelease,
setupRefusal,
type InstallDeps,
} from "../src/setup/install.js";
import { encodeCommand, powershellArgs } from "@invisible-dots/vm-manager";
import { elevatedSetupScript, elevationLauncherScript } from "../src/setup/powershell.js";
import type { WindowsQemuPin } from "../src/setup/qemu-pin.js";
import { fakeRunner, type Answer } from "../../vm-manager/test/doctor-fakes.js";
const PS = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
const PIN: WindowsQemuPin = {
version: "10.0.0",
url: "https://qemu.weilnetz.de/w64/2025/qemu-w64-setup-20250422.exe",
sha256: "b".repeat(64),
silentArgs: ["/S"],
};
const UBUNTU = 'PRETTY_NAME="Ubuntu 24.04.3 LTS"\nNAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n';
function errno(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(`${code}: /dev/kvm`), { code });
}
interface HostOptions {
platform: NodeJS.Platform;
respond?: (command: string, args: readonly string[]) => Answer;
kvm?: string;
osRelease?: string;
isRoot?: boolean;
/** What the elevated session leaves in its result file; undefined writes nothing. */
elevatedResult?: string;
}
function fakeHost(options: HostOptions) {
const logs: string[] = [];
const downloads: { pin: WindowsQemuPin; dest: string }[] = [];
const removed: string[] = [];
const work = "C:\\Users\\someone\\AppData\\Local\\Temp\\invisible-dots-setup-x1";
const elevatedDir = "C:\\ProgramData\\invisible-dots-setup-0123abcd";
const runner = fakeRunner((command, args) => options.respond?.(command, args) ?? {});
const deps: InstallDeps = {
platform: options.platform,
env: { SystemRoot: "C:\\Windows", ProgramData: "C:\\ProgramData", ProgramW6432: "C:\\Program Files" },
run: runner.run,
openReadWrite: async () => {
if (options.kvm) throw errno(options.kvm);
},
log: (line) => logs.push(line),
readText: async (path) => {
if (path === "/etc/os-release" && options.osRelease !== undefined) return options.osRelease;
if (path === `${elevatedDir}\\result.json` && options.elevatedResult !== undefined) return options.elevatedResult;
throw errno("ENOENT");
},
isRoot: options.isRoot ?? false,
windowsQemuPin: () => PIN,
makeTempDir: async () => work,
removeDir: async (path) => {
removed.push(path);
},
download: async (pin, dest) => {
downloads.push({ pin, dest });
},
userSid: async () => "S-1-5-21-1-2-3-1001",
uniqueName: () => "0123abcd",
};
return { deps, calls: runner.calls, logs, downloads, removed, work, elevatedDir };
}
describe("an unsupported host", () => {
it("is refused by setup's install step", async () => {
expect((await installHostPrerequisites({ installQemu: true, enableAccelerator: false }, fakeHost({ platform: "darwin" }).deps)).kind).toBe("failed");
});
});
describe("Linux install", () => {
it("runs sudo apt-get install on apt systems with the terminal handed over, then checks /dev/kvm", async () => {
const host = fakeHost({ platform: "linux", osRelease: UBUNTU });
const outcome = await installHostPrerequisites({ installQemu: true, enableAccelerator: false }, host.deps);
expect(outcome).toEqual({ kind: "done", lines: [] });
expect(host.calls).toEqual([{ command: "sudo", args: ["apt-get", "install", "-y", "qemu-system-x86", "qemu-utils"], options: { inheritStdio: true } }]);
expect(host.logs).toEqual(["$ sudo apt-get install -y qemu-system-x86 qemu-utils"]);
});
it("reports a failed apt-get", async () => {
const host = fakeHost({ platform: "linux", osRelease: "ID=debian\n", respond: () => ({ code: 100 }) });
const outcome = await installHostPrerequisites({ installQemu: true, enableAccelerator: false }, host.deps);
expect(host.calls[0]).toMatchObject({ command: "sudo", args: ["apt-get", "install", "-y", "qemu-system-x86", "qemu-utils"] });
expect(outcome).toEqual({ kind: "failed", lines: ["sudo apt-get install -y qemu-system-x86 qemu-utils failed (exit code 100)"] });
});
it("refuses root on Linux only: Windows setup never runs as an administrator", () => {
expect(setupRefusal({ platform: "linux", isRoot: true })).toMatch(/not as root/);
expect(setupRefusal({ platform: "linux", isRoot: false })).toBeNull();
expect(setupRefusal({ platform: "win32", isRoot: false })).toBeNull();
});
it("only prints the dnf or pacman line elsewhere, and still checks /dev/kvm", async () => {
const host = fakeHost({ platform: "linux", osRelease: 'ID=fedora\nPRETTY_NAME="Fedora Linux 42"\n', kvm: "EACCES" });
const outcome = await installHostPrerequisites({ installQemu: true, enableAccelerator: false }, host.deps);
expect(host.calls).toEqual([]);
expect(outcome).toEqual({
kind: "manual",
lines: [
"QEMU is not installed by setup on Fedora Linux 42; run:",
" sudo dnf install -y qemu-system-x86 qemu-img",
"/dev/kvm exists but this user cannot open it read-write; fix:",
" sudo usermod -aG kvm $USER, then log out and in again (a new login is needed for the group to apply)",
],
});
});
it("prints usermod and the new login when only /dev/kvm is not accessible", async () => {
const outcome = await installHostPrerequisites({ installQemu: false, enableAccelerator: true }, fakeHost({ platform: "linux", kvm: "EACCES" }).deps);
expect(outcome.kind).toBe("manual");
expect(outcome.lines.join("\n")).toContain("sudo usermod -aG kvm $USER");
expect(outcome.lines.join("\n")).toContain("a new login is needed");
});
it("reads the package manager from ID and ID_LIKE", () => {
expect(parseOsRelease(UBUNTU)).toEqual({ id: "ubuntu", idLike: ["debian"], prettyName: "Ubuntu 24.04.3 LTS" });
expect(packageManagerFor(parseOsRelease("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"))).toEqual({ kind: "apt" });
expect(packageManagerFor(parseOsRelease("ID=rocky\nID_LIKE=\"rhel centos fedora\"\n"))).toMatchObject({ line: "sudo dnf install -y qemu-system-x86 qemu-img" });
expect(packageManagerFor(parseOsRelease("ID=endeavouros\nID_LIKE=arch\n"))).toMatchObject({ line: "sudo pacman -S --needed qemu-system-x86 qemu-img" });
expect(packageManagerFor(parseOsRelease("ID=gentoo\n"))).toMatchObject({ kind: "print" });
expect(packageManagerFor(parseOsRelease(""))).toMatchObject({ kind: "print" });
});
});
describe("Windows install", () => {
it("downloads and verifies the pinned installer, then runs dism and the installer in ONE elevated session; 3010 means restart", async () => {
const host = fakeHost({ platform: "win32", elevatedResult: '\uFEFF{"dism_exit_code":3010,"installer_exit_code":0,"error":null}' });
const outcome = await installHostPrerequisites({ installQemu: true, enableAccelerator: true }, host.deps);
const installer = `${host.work}\\qemu-w64-setup-20250422.exe`;
expect(host.downloads).toEqual([{ pin: PIN, dest: installer }]);
const elevated = elevatedSetupScript({
enableHypervisorPlatform: true,
installer: { path: installer, sha256: PIN.sha256, silentArgs: ["/S"] },
workDir: host.elevatedDir,
userSid: "S-1-5-21-1-2-3-1001",
});
expect(host.calls).toEqual([{ command: PS, args: powershellArgs(elevationLauncherScript(PS, elevated)), options: { timeoutMs: 3_600_000 } }]);
const launcher = Buffer.from(host.calls[0]!.args.at(-1)!, "base64").toString("utf16le");
expect(launcher).toContain(`-Verb RunAs -Wait -PassThru`);
expect(launcher).toContain(encodeCommand(elevated));
expect(outcome).toEqual({
kind: "restart",
lines: [
"QEMU installed",
"the HypervisorPlatform feature is enabled; Windows must restart before it works",
"restart Windows, then run: invisible-dots doctor",
],
});
// The elevated session's own directory goes too, once its result was read.
expect(host.removed).toEqual([host.elevatedDir, host.work]);
expect(host.logs).toEqual([
`downloading the official QEMU 10.0.0 installer: ${PIN.url}`,
`verified its SHA-256 (${PIN.sha256})`,
"asking for administrator rights once, to enable the HypervisorPlatform feature (dism) and run the QEMU installer silently",
]);
});
it("enables only the feature without downloading anything when QEMU is already installed", async () => {
const host = fakeHost({ platform: "win32", elevatedResult: '{"dism_exit_code":0,"installer_exit_code":null,"error":null}' });
const outcome = await installHostPrerequisites({ installQemu: false, enableAccelerator: true }, host.deps);
expect(host.downloads).toEqual([]);
const launcher = Buffer.from(host.calls[0]!.args.at(-1)!, "base64").toString("utf16le");
const elevated = Buffer.from(/'-EncodedCommand','([^']+)'/.exec(launcher)![1]!, "base64").toString("utf16le");
expect(elevated).toContain("/featurename:HypervisorPlatform");
expect(elevated).not.toContain("$installer");
expect(outcome).toEqual({ kind: "done", lines: ["the HypervisorPlatform feature is enabled"] });
});
it("installs only QEMU when the feature is already on", async () => {
const host = fakeHost({ platform: "win32", elevatedResult: '{"dism_exit_code":null,"installer_exit_code":0,"error":null}' });
expect(await installHostPrerequisites({ installQemu: true, enableAccelerator: false }, host.deps)).toEqual({ kind: "done", lines: ["QEMU installed"] });
expect(host.downloads).toHaveLength(1);
});
it("reports a declined UAC prompt, a failure inside the elevated session, and a download that does not verify", async () => {
const declined = fakeHost({ platform: "win32", respond: () => ({ code: 1, stderr: "This operation was canceled by the user.\r\n" }) });
expect(await installHostPrerequisites({ installQemu: false, enableAccelerator: true }, declined.deps)).toEqual({
kind: "failed",
lines: ["administrator rights were not granted (the prompt was declined); nothing was changed"],
});
expect(declined.removed).toEqual([declined.elevatedDir, declined.work]);
const inside = fakeHost({ platform: "win32", elevatedResult: '{"dism_exit_code":50,"installer_exit_code":null,"error":"dism exited with code 50"}' });
expect(await installHostPrerequisites({ installQemu: false, enableAccelerator: true }, inside.deps)).toEqual({
kind: "failed",
lines: ["the elevated step failed: dism exited with code 50"],
});
const tampered = fakeHost({ platform: "win32" });
tampered.deps.download = async () => {
throw new Error("https://example.invalid/q.exe hashes to 00, but the pin is bb");
};
await expect(installHostPrerequisites({ installQemu: true, enableAccelerator: true }, tampered.deps)).rejects.toThrow(/but the pin is/);
expect(tampered.calls).toEqual([]);
expect(tampered.removed).toEqual([tampered.elevatedDir, tampered.work]);
});
it("interprets the result file", () => {
expect(interpretElevatedResult("not json").kind).toBe("failed");
expect(interpretElevatedResult('{"dism_exit_code":3010,"installer_exit_code":null,"error":null}').kind).toBe("restart");
});
it("names the directory to set when the installer reused an earlier one invisible-dots does not search", () => {
const env = { ProgramW6432: "C:\\Program Files" };
const installedIn = (dir: string) => JSON.stringify({ dism_exit_code: null, installer_exit_code: 0, install_dir: dir, error: null });
// The official directory (with the trailing separator the registry value has) needs nothing more.
expect(interpretElevatedResult(installedIn("C:\\Program Files\\qemu\\"), env)).toEqual({
kind: "done",
lines: ["QEMU installed"],
});
expect(interpretElevatedResult(installedIn("D:\\Tools\\qemu"), env)).toEqual({
kind: "done",
lines: ["QEMU installed", "it went to D:\\Tools\\qemu, where an earlier QEMU was installed: set INVISIBLE_DOTS_QEMU_DIR=D:\\Tools\\qemu so invisible-dots uses it"],
});
});
it("says the elevated step ran nothing when it could not make its own directory safely", async () => {
const host = fakeHost({ platform: "win32", respond: () => ({ code: 2 }) });
expect(await installHostPrerequisites({ installQemu: false, enableAccelerator: true }, host.deps)).toEqual({
kind: "failed",
lines: [`the elevated step could not create its own directory ${host.elevatedDir} for administrators only, so it ran nothing; run setup again`],
});
});
});
+162
View File
@@ -0,0 +1,162 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { encodeCommand, HYPERVISOR_PLATFORM_STATE_SCRIPT, powershellArgs, powershellPath, runProcess } from "@invisible-dots/vm-manager";
import { elevatedSetupScript, elevationLauncherScript, parseElevatedResult, psQuote } from "../src/setup/powershell.js";
const SHA = "a".repeat(64);
const WORK = "C:\\ProgramData\\invisible-dots-setup-0123abcd";
const SID = "S-1-5-21-1-2-3-1001";
describe("generated PowerShell", () => {
it("runs dism and a verified COPY of the installer in one elevated session, from a directory only administrators can change", () => {
const script = elevatedSetupScript({
enableHypervisorPlatform: true,
installer: { path: "C:\\Users\\O'Brien\\AppData\\Local\\Temp\\idots-1\\qemu-w64-setup-20250422.exe", sha256: SHA.toUpperCase(), silentArgs: ["/S"] },
workDir: WORK,
userSid: SID,
});
expect(script.split("\r\n")).toEqual([
"# invisible-dots setup: the one step that needs administrator rights (architecture section 11.2).",
"$ErrorActionPreference = 'Stop'",
"$result = [ordered]@{ dism_exit_code = $null; installer_exit_code = $null; install_dir = $null; error = $null }",
"$work = 'C:\\ProgramData\\invisible-dots-setup-0123abcd'",
"$user = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-21-1-2-3-1001'",
"function New-Rule($sid, $rights) { New-Object System.Security.AccessControl.FileSystemAccessRule($sid, $rights, 'ContainerInherit,ObjectInherit', 'None', 'Allow') }",
// Under the real ProgramData (not one an environment variable names), new, protected and empty.
"if ((Split-Path -Parent $work) -ne [Environment]::GetFolderPath('CommonApplicationData')) { exit 2 }",
"$acl = New-Object System.Security.AccessControl.DirectorySecurity",
"$acl.SetAccessRuleProtection($true, $false)",
"$acl.AddAccessRule((New-Rule (New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544') 'FullControl'))",
"$acl.AddAccessRule((New-Rule (New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18') 'FullControl'))",
"$acl.AddAccessRule((New-Rule $user 'ReadAndExecute'))",
"if (Test-Path -LiteralPath $work) { exit 2 }",
"[void][System.IO.Directory]::CreateDirectory($work, $acl)",
"if (-not ((Get-Acl -LiteralPath $work).AreAccessRulesProtected) -or (@(Get-ChildItem -LiteralPath $work -Force).Count -ne 0)) { exit 2 }",
"try {",
// dism through the system directory Windows reports, never $env:SystemRoot.
" $dism = Join-Path ([Environment]::SystemDirectory) 'dism.exe'",
" & $dism /online /enable-feature /featurename:HypervisorPlatform /all /norestart",
" $result.dism_exit_code = $LASTEXITCODE",
' if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { throw "dism exited with code $LASTEXITCODE" }',
// The copy is what is hashed and what runs, in the directory nobody else can write.
" $installer = Join-Path $work 'qemu-setup.exe'",
" Copy-Item -LiteralPath 'C:\\Users\\O''Brien\\AppData\\Local\\Temp\\idots-1\\qemu-w64-setup-20250422.exe' -Destination $installer",
" $hash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant()",
` if ($hash -ne '${SHA}') { throw "the QEMU installer is not the one that was verified (its SHA-256 is $hash)" }`,
" $setup = Start-Process -FilePath $installer -ArgumentList '/S' -WorkingDirectory $work -Wait -PassThru",
" $result.installer_exit_code = $setup.ExitCode",
' if ($setup.ExitCode -ne 0) { throw "the QEMU installer exited with code $($setup.ExitCode)" }',
" $result.install_dir = (Get-ItemProperty -LiteralPath 'HKLM:\\Software\\qemu64' -ErrorAction SilentlyContinue).Install_Dir",
"} catch {",
" $result.error = $_.Exception.Message",
"}",
"Remove-Item -LiteralPath (Join-Path $work 'qemu-setup.exe') -Force -ErrorAction SilentlyContinue",
// The result goes into the same private directory, never into one the user can redirect.
"[System.IO.File]::WriteAllText((Join-Path $work 'result.json'), ($result | ConvertTo-Json -Compress))",
"# Nothing in it runs any more: setup, as the normal user, reads the result and removes the directory.",
"$done = Get-Acl -LiteralPath $work",
"$done.AddAccessRule((New-Rule $user 'FullControl'))",
"Set-Acl -LiteralPath $work -AclObject $done",
"",
]);
expect(script).not.toContain("$env:");
});
it("leaves out the step that is not needed", () => {
const onlyFeature = elevatedSetupScript({ enableHypervisorPlatform: true, workDir: WORK, userSid: SID });
expect(onlyFeature).toContain("dism.exe");
expect(onlyFeature).not.toContain("$installer =");
const onlyQemu = elevatedSetupScript({ enableHypervisorPlatform: false, installer: { path: "C:\\t\\q.exe", sha256: SHA, silentArgs: ["/S"] }, workDir: WORK, userSid: SID });
expect(onlyQemu).not.toContain("dism.exe");
expect(onlyQemu).toContain("-ArgumentList '/S'");
});
it("asks for elevation with one Start-Process -Verb RunAs -Wait and passes the script encoded", () => {
const elevated = "Write-Output 'hi'";
const launcher = elevationLauncherScript("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", elevated);
expect(launcher.split("\r\n")).toEqual([
"$ErrorActionPreference = 'Stop'",
"try {",
` $elevated = Start-Process -FilePath 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -Verb RunAs -Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','${encodeCommand(elevated)}'`,
" exit $elevated.ExitCode",
"} catch {",
" [Console]::Error.WriteLine($_.Exception.Message)",
" exit 1",
"}",
"",
]);
expect(launcher.match(/Start-Process/g)).toHaveLength(1);
expect(Buffer.from(encodeCommand(elevated), "base64").toString("utf16le")).toBe(elevated);
});
it("quotes every kind of single quote PowerShell recognises", () => {
expect(psQuote("plain")).toBe("'plain'");
expect(psQuote("it's")).toBe("'it''s'");
expect(psQuote("a\u2019b\u2018c")).toBe("'a\u2019\u2019b\u2018\u2018c'");
});
it("reads the result file, with or without a byte order mark", () => {
expect(parseElevatedResult('\uFEFF{"dism_exit_code":3010,"installer_exit_code":0,"error":null}')).toEqual({
dism_exit_code: 3010,
installer_exit_code: 0,
install_dir: null,
error: null,
});
expect(parseElevatedResult('{"dism_exit_code":null,"installer_exit_code":0,"install_dir":"D:\\\\q","error":null}').install_dir).toBe("D:\\q");
expect(parseElevatedResult('{"dism_exit_code":null,"installer_exit_code":null,"error":"boom"}').error).toBe("boom");
expect(() => parseElevatedResult('{"dism_exit_code":"x"}')).toThrow(/not an exit code/);
});
});
/*
* The same scripts run through the real Windows PowerShell where it exists:
* the parser must accept every generated script, and the non-elevated parts
* (quoting, the hash check, the result file) must behave as the code above
* assumes. Nothing here elevates or changes the system.
*/
const windowsPowerShell = powershellPath(process.env);
const hasPowerShell = existsSync(windowsPowerShell);
// Each test starts Windows PowerShell a few times; under a parallel suite one start can take seconds.
describe.skipIf(!hasPowerShell)("generated PowerShell on a real Windows PowerShell", { timeout: 120_000 }, () => {
let dir: string;
beforeAll(async () => {
// A quote and a space in the path: the cases quoting has to survive.
dir = await mkdtemp(join(tmpdir(), "idots ps'q-"));
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
const ps = (script: string) => runProcess(windowsPowerShell, powershellArgs(script), { timeoutMs: 60_000 });
it("parses every generated script without errors", async () => {
const elevated = elevatedSetupScript({ enableHypervisorPlatform: true, installer: { path: join(dir, "q.exe"), sha256: SHA, silentArgs: ["/S"] }, workDir: WORK, userSid: SID });
for (const script of [elevated, elevationLauncherScript(windowsPowerShell, elevated), HYPERVISOR_PLATFORM_STATE_SCRIPT]) {
const check = `$errors = $null; [void][System.Management.Automation.Language.Parser]::ParseInput(${psQuote(script)}, [ref]$null, [ref]$errors); $errors.Count`;
const result = await ps(check);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("0");
}
});
it("runs nothing when its directory would not be directly under the real ProgramData", async () => {
// A directory the normal user chose (here a temporary one): the session stops before it creates or runs anything.
const installer = join(dir, "qemu-setup.exe");
await writeFile(installer, "not the verified bytes");
const workDir = join(dir, "elevated");
const run = await ps(elevatedSetupScript({ enableHypervisorPlatform: false, installer: { path: installer, sha256: SHA, silentArgs: ["/S"] }, workDir, userSid: SID }));
expect(run.code).toBe(2);
expect(existsSync(workDir)).toBe(false);
});
it("reads the HypervisorPlatform state without administrator rights", async () => {
const result = await ps(HYPERVISOR_PLATFORM_STATE_SCRIPT);
expect(result.code).toBe(0);
expect(result.stdout.trim()).toMatch(/^[1-4]?$/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { installerFileName, parseWindowsQemuPin } from "../src/setup/qemu-pin.js";
describe("Windows QEMU pin", () => {
const good = { version: "10.0.0", url: "https://qemu.weilnetz.de/w64/2025/qemu-w64-setup-20250422.exe", sha256: "AB".repeat(32), silent_args: ["/S"] };
it("accepts a version, an https URL and a SHA-256, and normalises the hash", () => {
expect(parseWindowsQemuPin(good)).toEqual({ version: good.version, url: good.url, sha256: "ab".repeat(32), silentArgs: ["/S"] });
});
it("accepts the pin file in this repository", async () => {
const pin = parseWindowsQemuPin(JSON.parse(await readFile(new URL("../../../virtualization/qemu/windows.json", import.meta.url), "utf8")));
expect(pin.url.startsWith("https://")).toBe(true);
expect(installerFileName(pin)).toMatch(/\.exe$/);
});
it("refuses a pin without its silent switch", () => {
expect(() => parseWindowsQemuPin({ ...good, silent_args: [] })).toThrow(/"silent_args" must be a non-empty list/);
expect(() => parseWindowsQemuPin({ ...good, silent_args: undefined })).toThrow(/silent_args/);
});
it("refuses anything that would make setup download the wrong bytes", () => {
expect(() => parseWindowsQemuPin(null)).toThrow(/expected a JSON object/);
expect(() => parseWindowsQemuPin({ ...good, url: "http://qemu.weilnetz.de/x.exe" })).toThrow(/must use https/);
expect(() => parseWindowsQemuPin({ ...good, sha256: "abc" })).toThrow(/64 hexadecimal/);
expect(() => parseWindowsQemuPin({ ...good, version: "" })).toThrow(/"version" must be a non-empty string/);
expect(() => parseWindowsQemuPin({ ...good, version: "latest" })).toThrow(/major\.minor/);
expect(() => parseWindowsQemuPin({ ...good, url: "not a url" })).toThrow(/not a URL/);
});
it("saves the installer under its own plain name, or a fixed one", () => {
expect(installerFileName(parseWindowsQemuPin(good))).toBe("qemu-w64-setup-20250422.exe");
expect(installerFileName(parseWindowsQemuPin({ ...good, url: "https://example.org/download?id=7" }))).toBe("qemu-10.0.0-setup.exe");
expect(installerFileName(parseWindowsQemuPin({ ...good, url: "https://example.org/a%27b.exe" }))).toBe("qemu-10.0.0-setup.exe");
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* The line a person pastes at a prompt (an OpenRouter key, a bot token) is read with the echo off. The tests play the
* terminal: a stream that says it is a TTY, takes raw mode and records what is written to the screen.
*/
import { createInterface } from "node:readline";
import { PassThrough, Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { readSecretLine, SecretInputCancelled, type TerminalInput } from "../src/secret-input.js";
function terminal() {
const input = new PassThrough() as PassThrough & TerminalInput & { raw: boolean[] };
input.isTTY = true;
input.raw = [];
input.setRawMode = (mode: boolean) => void input.raw.push(mode);
const screen: string[] = [];
const output = { write: (text: string) => (screen.push(String(text)), true) };
return { input, output, screen: () => screen.join("") };
}
const KEY = "sk-or-v1-0123456789abcdef0123456789abcdef";
describe("a secret typed at a prompt", () => {
it("would show on the screen if the line were read by a readline that writes there (so the checks below can fail)", async () => {
const t = terminal();
const echoed: string[] = [];
const screen = new Writable({
write(chunk, _encoding, done) {
echoed.push(String(chunk));
done();
},
});
const lines = createInterface({ input: t.input, output: screen, terminal: true });
const line = new Promise<string>((resolve) => lines.once("line", resolve));
t.input.write(`${KEY}\r`);
await line;
lines.close();
expect(echoed.join("")).toContain(KEY);
});
it("is read as typed, and nothing of it is written to the screen, not as it is typed and not after", async () => {
const t = terminal();
const read = readSecretLine(t.input, t.output);
t.input.write(KEY);
t.input.write("\r");
expect(await read).toBe(KEY);
// Only the newline that Enter would have echoed: the next line of output does not follow the prompt.
expect(t.screen()).toBe("\n");
expect(t.screen()).not.toContain("sk-or");
});
it("is read when it arrives in pieces, as a paste does, and keeps what backspace did not take", async () => {
const t = terminal();
const read = readSecretLine(t.input, t.output);
t.input.write("123456:AA");
t.input.write("BBxx\u007f\u007fCC");
t.input.write("\n");
expect(await read).toBe("123456:AABBCC");
expect(t.screen()).toBe("\n");
});
it("is given up at Ctrl-C, with nothing of what was typed shown", async () => {
const t = terminal();
const read = readSecretLine(t.input, t.output);
t.input.write("half of a key");
t.input.write("\u0003");
await expect(read).rejects.toBeInstanceOf(SecretInputCancelled);
expect(t.screen()).not.toContain("half");
});
it("returns what was typed when the input ends before Enter", async () => {
const t = terminal();
const read = readSecretLine(t.input, t.output);
t.input.write("abc");
t.input.end();
expect(await read).toBe("abc");
});
it("puts the terminal back as it found it", async () => {
const t = terminal();
const read = readSecretLine(t.input, t.output);
t.input.write("x\r");
await read;
expect(t.input.raw.at(-1)).toBe(false);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseListen } from "@invisible-dots/api";
import type { HostPaths } from "@invisible-dots/shared";
import { serve, type ServeDeps } from "../src/serve.js";
import type { WebServer, WebServerOptions } from "../src/web.js";
let repoRoot: string;
beforeEach(async () => {
repoRoot = await mkdtemp(join(tmpdir(), "idots-serve-"));
});
afterEach(() => rm(repoRoot, { recursive: true, force: true }));
async function buildWeb() {
const app = join(repoRoot, "apps", "web", ".next", "standalone", "apps", "web");
await mkdir(join(app, ".next", "static"), { recursive: true });
await writeFile(join(app, "server.js"), "");
}
/** The control plane and the web server as fakes that record what happened to them, in order. */
function harness(over: { webStart?: (options: WebServerOptions) => Promise<WebServer> } = {}) {
const log: string[] = [];
const lines: string[] = [];
const signals = new EventEmitter();
const webOptions: WebServerOptions[] = [];
const logger = {
debug() {},
info: (m: string) => lines.push(`info ${m}`),
warn: (m: string) => lines.push(`warn ${m}`),
error: (m: string) => lines.push(`error ${m}`),
};
let stopped!: () => void;
const deps: ServeDeps = {
startServer: async (options) => {
log.push(`server start env=${options.env ? "given" : "none"}`);
return {
url: "http://127.0.0.1:8787",
paths: { apiTokenPath: "/home/x/.invisible-dots/config/api.token" } as HostPaths,
close: async () => {
log.push("server close");
},
};
},
untilStopSignal: (close, _logger, emitter) =>
new Promise<void>((resolve) => {
stopped = resolve;
emitter?.once("SIGINT", () => void close().then(resolve));
}),
parseListen,
startWebServer:
over.webStart ??
(async (options) => {
webOptions.push(options);
log.push("web start");
return {
url: `http://${options.listen.host}:${options.listen.port}`,
stop: async () => {
log.push("web stop");
},
};
}),
};
const run = (options: { web: boolean; env?: Record<string, string> }) =>
serve({ env: options.env ?? {}, logger, web: options.web, repoRoot, signals }, deps);
return { run, log, lines, signals, webOptions, release: () => stopped?.() };
}
/** Waits until the check holds: serve starts its parts asynchronously, and a fixed wait loses that race on a loaded machine. */
const until = (check: () => void) => vi.waitFor(check, { timeout: 5_000, interval: 5 });
describe("serve", () => {
it("starts the control plane, then the web client on the default address pointed at it, and stops them in the other order", async () => {
await buildWeb();
const h = harness();
const running = h.run({ web: true });
await until(() => expect(h.log).toEqual(["server start env=given", "web start"]));
expect(h.webOptions[0]).toMatchObject({ listen: { host: "127.0.0.2", port: 3000 }, apiUrl: "http://127.0.0.1:8787" });
expect(h.webOptions[0]!.build.missing).toBeUndefined();
expect(h.lines).toContain("info API token in /home/x/.invisible-dots/config/api.token; stop with Ctrl+C (Dots keep running)");
expect(h.lines).toContain("info web client at http://127.0.0.2:3000");
h.signals.emit("SIGINT");
await running;
expect(h.log).toEqual(["server start env=given", "web start", "web stop", "server close"]);
});
it("listens where INVISIBLE_DOTS_WEB_LISTEN says", async () => {
await buildWeb();
const h = harness();
const running = h.run({ web: true, env: { INVISIBLE_DOTS_WEB_LISTEN: "127.0.0.1:3100" } });
await until(() => expect(h.webOptions[0]?.listen).toEqual({ host: "127.0.0.1", port: 3100 }));
h.signals.emit("SIGINT");
await running;
});
it("fails before starting anything when INVISIBLE_DOTS_WEB_LISTEN is malformed", async () => {
const h = harness();
await expect(h.run({ web: true, env: { INVISIBLE_DOTS_WEB_LISTEN: "three-thousand" } })).rejects.toThrow(/INVISIBLE_DOTS_WEB_LISTEN must look like/);
expect(h.log).toEqual([]);
});
it("names the web client's own default, not the API's, when INVISIBLE_DOTS_WEB_LISTEN is malformed", async () => {
const h = harness();
await expect(h.run({ web: true, env: { INVISIBLE_DOTS_WEB_LISTEN: "three-thousand" } })).rejects.toThrow(
'INVISIBLE_DOTS_WEB_LISTEN must look like "127.0.0.2:3000", got "three-thousand"',
);
});
it("fails before starting anything when INVISIBLE_DOTS_WEB_LISTEN names port 0", async () => {
const h = harness();
await expect(h.run({ web: true, env: { INVISIBLE_DOTS_WEB_LISTEN: "127.0.0.1:0" } })).rejects.toThrow(
'INVISIBLE_DOTS_WEB_LISTEN needs a fixed port, got "127.0.0.1:0"',
);
expect(h.log).toEqual([]);
});
it("does not start the web client with --no-web, and does not read INVISIBLE_DOTS_WEB_LISTEN", async () => {
await buildWeb();
const h = harness();
const running = h.run({ web: false, env: { INVISIBLE_DOTS_WEB_LISTEN: "garbage" } });
// Running: it waits for the stop signal.
await until(() => expect(h.signals.listenerCount("SIGINT")).toBe(1));
h.signals.emit("SIGINT");
await running;
expect(h.log).toEqual(["server start env=given", "server close"]);
});
it("keeps the control plane running, and says why, when the web client cannot start", async () => {
const h = harness({
webStart: async () => {
throw new Error("http://127.0.0.1:3000 is already in use");
},
});
const running = h.run({ web: true });
await until(() => expect(h.lines).toContain("warn web client not started: http://127.0.0.1:3000 is already in use; the control plane keeps running"));
expect(h.log).toEqual(["server start env=given"]);
h.signals.emit("SIGINT");
await running;
expect(h.log).toEqual(["server start env=given", "server close"]);
});
it("closes the control plane on Ctrl+C while the web client is still starting, and ends that start", async () => {
let aborted = false;
let starting = false;
const h = harness({
webStart: (options) =>
new Promise((_resolve, reject) => {
starting = true;
options.signal!.addEventListener("abort", () => {
aborted = true;
reject(new Error("stopped while the web client was starting"));
});
}),
});
await buildWeb();
const running = h.run({ web: true });
await until(() => expect(starting).toBe(true));
h.signals.emit("SIGINT");
await running;
expect(aborted).toBe(true);
expect(h.log).toEqual(["server start env=given", "server close"]);
// Ending the start is what the person asked for, not a failure to report.
expect(h.lines.filter((line) => line.startsWith("warn"))).toEqual([]);
});
it("names the missing build in the web start it hands over, so the warning carries the fix", async () => {
const h = harness();
const running = h.run({ web: true });
await until(() => expect(h.webOptions).toHaveLength(1));
expect(h.webOptions[0]!.build.missing).toBe(h.webOptions[0]!.build.entry);
h.signals.emit("SIGINT");
await running;
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_WEB_LISTEN, ENV } from "@invisible-dots/shared";
import { EXIT } from "../src/exit.js";
import { runSetupAll, SETUP_ALL_COMMAND, webAddress, type SetupAllDeps } from "../src/setup/all.js";
import type { StepResult } from "../src/setup/build.js";
import type { HostOutcome } from "../src/setup/setup.js";
const DONE: StepResult = { ok: true, lines: ["done"] };
/** A run of `setup --all` against fake steps; `calls` is the order they ran in. */
async function all(overrides: Partial<SetupAllDeps> = {}) {
let text = "";
const calls: string[] = [];
const deps: SetupAllDeps = {
out: (t) => (text += t),
agent: async () => (calls.push("agent"), { ok: true, lines: ["built /repo/guest/dot-agentd/bin/dot-agentd"] }),
host: async () => (calls.push("host"), { ready: true, results: [] } satisfies HostOutcome),
web: async () => (calls.push("web"), DONE),
images: async () => (calls.push("images"), EXIT.ok),
env: {},
...overrides,
};
const code = await runSetupAll(deps);
return { code, text, calls };
}
describe("setup --all", () => {
it("runs the guest daemon, QEMU, the web client and the images in that order, then says how to open the web client", async () => {
const result = await all();
expect(result.code).toBe(EXIT.ok);
expect(result.calls).toEqual(["agent", "host", "web", "images"]);
expect(result.text).toMatch(/\[1\/4\] the guest daemon[\s\S]*\[2\/4\] QEMU[\s\S]*\[3\/4\] the web client[\s\S]*\[4\/4\] the guest images/);
expect(result.text).toContain("built /repo/guest/dot-agentd/bin/dot-agentd");
expect(result.text).toContain("1. start the server and leave it running: invisible-dots server");
expect(result.text).toContain(`2. open http://${DEFAULT_WEB_LISTEN} in your browser\n`);
expect(result.text).not.toMatch(/sign in|api\.token/);
expect(result.text).toContain("OpenRouter key");
});
it("names the address the web client is configured to listen on", async () => {
expect(webAddress({})).toBe("http://127.0.0.2:3000");
expect(webAddress({ [ENV.WEB_LISTEN]: " 127.0.0.1:4100 " })).toBe("http://127.0.0.1:4100");
expect(webAddress({ [ENV.WEB_LISTEN]: "" })).toBe("http://127.0.0.2:3000");
const result = await all({ env: { [ENV.WEB_LISTEN]: "127.0.0.1:4100" } });
expect(result.text).toContain("open http://127.0.0.1:4100 in your browser");
});
it("stops at a missing Go before it changes anything on the host, and says to run the same command again", async () => {
const result = await all({ agent: async () => ({ ok: false, lines: ["Go is not installed", "install it with: winget install -e --id GoLang.Go"] }) });
expect(result.code).toBe(EXIT.failed);
expect(result.calls).toEqual([]);
expect(result.text).toContain(" install it with: winget install -e --id GoLang.Go\n");
expect(result.text).toContain(`run ${SETUP_ALL_COMMAND} again: it skips what is done`);
expect(result.text).not.toContain("This computer is ready");
});
it("stops with exit code 5 where Windows must restart, runs no later step, and says the same command carries on", async () => {
const result = await all({ host: async () => ({ ready: false, code: EXIT.restart }) });
expect(result.code).toBe(EXIT.restart);
expect(result.text).toContain(`Restart the computer now, then run ${SETUP_ALL_COMMAND} again from this folder: it carries on with step 3.`);
expect(result.text).not.toContain("[3/4]");
expect(result.text).not.toContain("This computer is ready");
});
it("stops where QEMU or the accelerator cannot be made ready (a new login on Linux), keeping setup's own exit code", async () => {
const result = await all({ host: async () => ({ ready: false, code: EXIT.failed }) });
expect(result.code).toBe(EXIT.failed);
expect(result.calls).toEqual(["agent"]);
expect(result.text).toContain("log out and in again if it says so");
expect(result.text).toContain(`run ${SETUP_ALL_COMMAND} again`);
});
it("adds nothing to setup's refusal to run as root", async () => {
const result = await all({ host: async () => ({ ready: false, code: EXIT.usage }) });
expect(result.code).toBe(EXIT.usage);
expect(result.text).not.toContain("Restart");
expect(result.text).not.toContain("Stopped");
});
it("stops at a web build that failed, before the long image build", async () => {
const calls: string[] = [];
const result = await all({
web: async () => ({ ok: false, lines: ["building the web client failed (exit code 1; its output is above)"] }),
images: async () => (calls.push("images"), EXIT.ok),
});
expect(result.code).toBe(EXIT.failed);
expect(calls).toEqual([]);
expect(result.text).toContain("building the web client failed");
});
it("reports an image build that failed or threw, and how to continue", async () => {
const failed = await all({ images: async () => EXIT.failed });
expect(failed.code).toBe(EXIT.failed);
expect(failed.text).toContain("image build ended with exit code 1");
const threw = await all({
images: async () => {
throw new Error("the download of the Ubuntu image failed");
},
});
expect(threw.code).toBe(EXIT.failed);
expect(threw.text).toContain("image build stopped: the download of the Ubuntu image failed");
expect(threw.text).toContain(`run ${SETUP_ALL_COMMAND} again`);
expect(threw.text).not.toContain("This computer is ready");
});
it("stops between steps once Ctrl-C was pressed", async () => {
const controller = new AbortController();
const result = await all({
signal: controller.signal,
host: async () => {
controller.abort();
return { ready: true, results: [] };
},
});
expect(result.code).toBe(EXIT.failed);
expect(result.calls).toEqual(["agent"]);
expect(result.text).toContain("interrupted");
});
});
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import type { DoctorCheck } from "@invisible-dots/shared";
import { WEB_BUILD_COMMAND, type DoctorDeps } from "@invisible-dots/vm-manager";
import { EXIT } from "../src/exit.js";
import type { InstallDeps, InstallOutcome, InstallRequest } from "../src/setup/install.js";
import { nextSteps, prepareHost, runSetup } from "../src/setup/setup.js";
import { FOUND, healthyDoctor } from "../../vm-manager/test/doctor-fakes.js";
const MISSING_QEMU = async () => ({ searched: ["PATH"] });
const FEATURE_OFF: DoctorCheck = { id: "accelerator", label: "accelerator", status: "missing", detail: "the HypervisorPlatform feature is disabled", fix: "invisible-dots setup" };
/**
* A setup run against fakes. `install` stands in for the platform module:
* it records the request and may change what the second doctor run sees,
* the way a real installation would.
*/
async function setup(options: {
doctor: Partial<DoctorDeps>;
outcome?: InstallOutcome;
afterInstall?: Partial<DoctorDeps>;
}) {
let text = "";
const requests: InstallRequest[] = [];
const { deps: doctor } = healthyDoctor(options.doctor);
const install = {
platform: "win32",
} as unknown as InstallDeps;
const code = await runSetup({
doctor,
install,
out: (t) => (text += t),
installPrerequisites: async (request) => {
requests.push(request);
Object.assign(doctor, options.afterInstall ?? {});
return options.outcome ?? { kind: "done", lines: [] };
},
});
return { code, text, requests };
}
describe("setup", () => {
it("refuses to run as root on Linux, before it checks or installs anything", async () => {
let text = "";
let asked = false;
const { deps: doctor, calls } = healthyDoctor();
const code = await runSetup({
doctor,
install: { platform: "linux", isRoot: true } as unknown as InstallDeps,
out: (t) => (text += t),
installPrerequisites: async () => {
asked = true;
return { kind: "done", lines: [] };
},
});
expect(code).toBe(EXIT.usage);
expect(text).toMatch(/as the user who runs the server, not as root: it calls sudo itself/);
expect(asked).toBe(false);
expect(calls).toEqual([]);
});
it("installs nothing when QEMU and the accelerator are ready, and names what is left", async () => {
const result = await setup({
doctor: { images: async () => [{ id: "golden-image", label: "golden image", status: "missing", detail: "none", fix: "invisible-dots image build" }, { id: "runtime-image", label: "runtime ISO", status: "ok", detail: "ok" }] },
});
expect(result.code).toBe(EXIT.ok);
expect(result.requests).toEqual([]);
expect(result.text).toContain("QEMU and its accelerator are ready; nothing to install.");
expect(result.text).toContain(" invisible-dots image build\n");
});
it("asks for QEMU and the feature together, then checks again and succeeds", async () => {
const result = await setup({
doctor: { findQemu: MISSING_QEMU, acceleratorAccess: async () => FEATURE_OFF },
outcome: { kind: "done", lines: ["QEMU installed", "the HypervisorPlatform feature is enabled"] },
afterInstall: { findQemu: async () => FOUND, acceleratorAccess: async () => ({ id: "accelerator", label: "accelerator", status: "ok", detail: "enabled" }) },
});
expect(result.requests).toEqual([{ installQemu: true, enableAccelerator: true }]);
expect(result.code).toBe(EXIT.ok);
expect(result.text).toContain("QEMU installed\n");
expect(result.text).toContain("checking again:");
expect(result.text).toContain("this host is ready: invisible-dots server\n");
});
it("leaves the feature alone when the probe shows WHPX already works", async () => {
const result = await setup({ doctor: { acceleratorAccess: async () => FEATURE_OFF } });
expect(result.requests).toEqual([]);
expect(result.code).toBe(EXIT.ok);
});
it("stops with exit 5 when Windows must restart", async () => {
const { deps } = healthyDoctor();
const versions = deps.run;
const result = await setup({
doctor: {
acceleratorAccess: async () => FEATURE_OFF,
run: async (command, args, options) =>
args.includes("-accel") ? { code: 1, signal: null, stdout: "", stderr: "WHPX: No accelerator found, hr=80370102\n", timedOut: false } : versions(command, args, options),
},
outcome: { kind: "restart", lines: ["restart Windows, then run: invisible-dots doctor"] },
});
expect(result.requests).toEqual([{ installQemu: false, enableAccelerator: true }]);
expect(result.code).toBe(EXIT.restart);
expect(result.text).not.toContain("checking again");
expect(result.text.trimEnd().endsWith("restart Windows, then run: invisible-dots doctor")).toBe(true);
});
it("exits 1 on a failed or manual step, and when the second check still fails", async () => {
const failed = await setup({ doctor: { findQemu: MISSING_QEMU }, outcome: { kind: "failed", lines: ["the elevated step failed: boom"] } });
expect(failed.code).toBe(EXIT.failed);
expect(failed.text).toContain("the elevated step failed: boom\nsetup stopped");
const manual = await setup({ doctor: { findQemu: MISSING_QEMU }, outcome: { kind: "manual", lines: [" sudo dnf install -y qemu-system-x86 qemu-img"] } });
expect(manual.code).toBe(EXIT.failed);
expect(manual.text).toContain("then run: invisible-dots doctor");
const still = await setup({ doctor: { findQemu: MISSING_QEMU } });
expect(still.code).toBe(EXIT.failed);
expect(still.text).toContain("QEMU or its accelerator is still not usable");
});
it("does not try to install anything when only the probe fails", async () => {
const { deps } = healthyDoctor();
const versions = deps.run;
const result = await setup({
doctor: {
run: async (command, args, options) =>
args.includes("-accel") ? { code: 1, signal: null, stdout: "", stderr: "WHPX: No accelerator found, hr=00000000\n", timedOut: false } : versions(command, args, options),
},
});
expect(result.code).toBe(EXIT.failed);
expect(result.requests).toEqual([]);
expect(result.text).toContain("setup cannot fix this by installing something:\n accelerator probe: qemu-system-x86_64 -accel kvm -cpu host,-vmx,-svm -machine q35 failed: WHPX: No accelerator found");
});
it("names the server and the key as the next steps while they are missing", () => {
const missingKey: DoctorCheck[] = [
{ id: "golden-image", label: "golden image", status: "ok", detail: "" },
{ id: "runtime-image", label: "runtime ISO", status: "ok", detail: "" },
{ id: "web", label: "web client", status: "ok", detail: "" },
{ id: "openrouter", label: "OpenRouter key", status: "failed", detail: "" },
];
expect(nextSteps(missingKey)).toEqual([
"invisible-dots server (keep it running, then in another terminal:)",
// One command for both hosts: "<" is a parser error in Windows PowerShell.
"invisible-dots secret openrouter",
]);
});
it("names the web build as a next step while the web client is not built", () => {
const notBuilt: DoctorCheck[] = [
{ id: "golden-image", label: "golden image", status: "ok", detail: "" },
{ id: "runtime-image", label: "runtime ISO", status: "ok", detail: "" },
{ id: "web", label: "web client", status: "missing", detail: "", fix: WEB_BUILD_COMMAND },
{ id: "openrouter", label: "OpenRouter key", status: "ok", detail: "" },
];
expect(nextSteps(notBuilt)).toEqual(["npm run build --workspace @invisible-dots/web", "invisible-dots server"]);
});
it("names no web build when the report has no web row: the host is not asked to check a client", () => {
const noWebRow: DoctorCheck[] = [
{ id: "golden-image", label: "golden image", status: "ok", detail: "" },
{ id: "runtime-image", label: "runtime ISO", status: "ok", detail: "" },
{ id: "openrouter", label: "OpenRouter key", status: "ok", detail: "" },
];
expect(nextSteps(noWebRow)).toEqual(["invisible-dots server"]);
});
describe("prepareHost, the part of setup that setup --all runs as its second step", () => {
const install = { platform: "win32" } as unknown as InstallDeps;
it("hands back the report once QEMU is ready and leaves what to do next to its caller", async () => {
let text = "";
const { deps: doctor } = healthyDoctor();
const outcome = await prepareHost({ doctor, install, out: (t) => (text += t) });
expect(outcome.ready).toBe(true);
expect(outcome.ready && outcome.results.map((r) => r.id)).toContain("accelerator");
expect(text).toContain("QEMU and its accelerator are ready; nothing to install.");
expect(text).not.toContain("this host is ready");
expect(text).not.toContain("Next:");
});
it("hands back setup's exit code when it cannot get the host ready", async () => {
const { deps: doctor } = healthyDoctor({ findQemu: MISSING_QEMU, acceleratorAccess: async () => FEATURE_OFF });
const restart = await prepareHost({ doctor, install, out: () => undefined, installPrerequisites: async () => ({ kind: "restart", lines: [] }) });
expect(restart).toEqual({ ready: false, code: EXIT.restart });
const failed = await prepareHost({ doctor, install, out: () => undefined, installPrerequisites: async () => ({ kind: "failed", lines: [] }) });
expect(failed).toEqual({ ready: false, code: EXIT.failed });
const root = await prepareHost({ doctor, install: { platform: "linux", isRoot: true } as unknown as InstallDeps, out: () => undefined });
expect(root).toEqual({ ready: false, code: EXIT.usage });
});
});
});
+234
View File
@@ -0,0 +1,234 @@
import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { locateWebBuild, startWebServer, webEnvironment, WebStartError, type WebServer, type WebServerOptions } from "../src/web.js";
let root: string;
let started: WebServer[] = [];
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "idots-web-"));
});
afterEach(async () => {
await Promise.all(started.map((web) => web.stop()));
started = [];
await rm(root, { recursive: true, force: true });
});
/** A server script that answers every request with the environment it was started with. */
const ECHO_SERVER = `
import { createServer } from "node:http";
createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ env: process.env, cwd: process.cwd() }));
}).listen(Number(process.env.PORT), process.env.HOSTNAME);
`;
async function script(source: string): Promise<string> {
const entry = join(root, "server.mjs");
await writeFile(entry, source);
return entry;
}
async function freePort(): Promise<number> {
return new Promise((resolve) => {
const probe = createServer();
probe.listen(0, "127.0.0.1", () => {
const port = (probe.address() as { port: number }).port;
probe.close(() => resolve(port));
});
});
}
async function start(entry: string, over: Partial<WebServerOptions> = {}) {
const port = await freePort();
const exits: string[] = [];
const web = await startWebServer({
build: { entry, missing: undefined },
listen: { host: "127.0.0.1", port },
apiUrl: "http://127.0.0.1:8787",
env: { PATH: process.env.PATH },
onUnexpectedExit: (message) => exits.push(message),
...over,
});
started.push(web);
return { web, port, exits };
}
describe("locateWebBuild", () => {
it("names the server script and the first file of the build that is missing", async () => {
const empty = await locateWebBuild(root);
expect(empty.entry).toBe(join(root, "apps", "web", ".next", "standalone", "apps", "web", "server.js"));
expect(empty.missing).toBe(empty.entry);
const app = join(root, "apps", "web", ".next", "standalone", "apps", "web");
await mkdir(app, { recursive: true });
await writeFile(join(app, "server.js"), "");
expect((await locateWebBuild(root)).missing).toBe(join(app, ".next", "static"));
await mkdir(join(app, ".next", "static"), { recursive: true });
expect((await locateWebBuild(root)).missing).toBeUndefined();
});
});
describe("webEnvironment", () => {
it("passes on where to listen and the API, the data directory and what the Host check allows, and nothing else", () => {
const env = webEnvironment({
env: {
PATH: "/bin",
INVISIBLE_DOTS_HOME: "/data",
INVISIBLE_DOTS_WEB_ALLOWED_HOSTS: "dots.example",
DATABASE_URL: "postgres://user:secret@db/dots",
OPENROUTER_API_KEY: "sk-secret",
INVISIBLE_DOTS_URL: "http://elsewhere:1",
},
listen: { host: "127.0.0.1", port: 3000 },
apiUrl: "http://127.0.0.1:8787",
parentPid: 4242,
});
expect(env).toEqual({
PATH: "/bin",
INVISIBLE_DOTS_HOME: "/data",
INVISIBLE_DOTS_WEB_ALLOWED_HOSTS: "dots.example",
INVISIBLE_DOTS_URL: "http://127.0.0.1:8787",
INVISIBLE_DOTS_WEB_PARENT_PID: "4242",
PORT: "3000",
HOSTNAME: "127.0.0.1",
});
});
it("passes the API token only when the server was given it in its environment", () => {
const env = webEnvironment({
env: { INVISIBLE_DOTS_TOKEN: "token-from-the-environment" },
listen: { host: "127.0.0.1", port: 3000 },
apiUrl: "http://127.0.0.1:8787",
parentPid: 4242,
});
expect(env.INVISIBLE_DOTS_TOKEN).toBe("token-from-the-environment");
});
});
describe("startWebServer", { timeout: 30_000 }, () => {
it("starts the script on the listen address with the web environment and answers at its url", async () => {
const entry = await script(ECHO_SERVER);
const { web, port } = await start(entry, { env: { PATH: process.env.PATH, DATABASE_URL: "postgres://user:secret@db/dots" } });
expect(web.url).toBe(`http://127.0.0.1:${port}`);
const body = (await (await fetch(web.url)).json()) as { env: Record<string, string>; cwd: string };
expect(body.env.PORT).toBe(String(port));
expect(body.env.HOSTNAME).toBe("127.0.0.1");
expect(body.env.INVISIBLE_DOTS_URL).toBe("http://127.0.0.1:8787");
expect(body.env.INVISIBLE_DOTS_WEB_PARENT_PID).toBe(String(process.pid));
expect(body.env.DATABASE_URL).toBeUndefined();
// It runs in its own directory, never the server's.
expect(realpathSync.native(body.cwd)).toBe(realpathSync.native(root));
});
it("stops the child: nothing answers afterwards, and a second stop is harmless", async () => {
const entry = await script(ECHO_SERVER);
const { web, exits } = await start(entry);
await web.stop();
await web.stop();
await expect(fetch(web.url, { signal: AbortSignal.timeout(1000) })).rejects.toThrow();
expect(exits).toEqual([]);
});
it("reports a child that exits later, once", async () => {
const entry = await script(`
import { createServer } from "node:http";
createServer((req, res) => res.end("ok")).listen(Number(process.env.PORT), process.env.HOSTNAME);
setTimeout(() => { console.error("out of memory"); process.exit(7); }, 1500);
`);
const { exits } = await start(entry);
await expect.poll(() => exits.length, { timeout: 10_000 }).toBe(1);
expect(exits[0]).toBe("the web server exited (exit code 7): out of memory");
});
it("fails with the child's own words when it exits before it answers", async () => {
const entry = await script(`console.error("Error: listen EADDRINUSE"); process.exit(1);`);
await expect(start(entry)).rejects.toThrow(new WebStartError("the web server exited (exit code 1): Error: listen EADDRINUSE"));
});
it("refuses a port something already answers on, instead of waiting on the wrong server", async () => {
const other = await script(ECHO_SERVER);
const { web, port } = await start(other);
await expect(start(other, { listen: { host: "127.0.0.1", port } })).rejects.toThrow(
`${web.url} is already in use; free the port or set INVISIBLE_DOTS_WEB_LISTEN to another host:port`,
);
});
it("fails and kills the child when it does not answer in time", async () => {
const entry = await script(`setInterval(() => {}, 1000);`);
await expect(start(entry, { readyTimeoutMs: 600, stopTimeoutMs: 2000 })).rejects.toThrow(/did not answer at http:\/\/127\.0\.0\.1:\d+ within 0\.6 s/);
});
it("stops waiting when the signal aborts, and leaves no child", async () => {
const entry = await script(`setInterval(() => {}, 1000);`);
const controller = new AbortController();
setTimeout(() => controller.abort(), 300);
await expect(start(entry, { signal: controller.signal })).rejects.toThrow("stopped while the web client was starting");
});
it("does not start at all when the build is incomplete", async () => {
await expect(start("/nowhere/server.js", { build: { entry: "/nowhere/server.js", missing: "/nowhere/server.js" } })).rejects.toThrow(
"the web client is not built (/nowhere/server.js does not exist); build it with: npm run build --workspace @invisible-dots/web",
);
});
});
describe("a server that is killed outright", { timeout: 60_000 }, () => {
const here = dirname(fileURLToPath(import.meta.url));
const watcher = pathToFileURL(join(here, "..", "..", "web", "src", "lib", "parent.ts")).href;
const webModule = pathToFileURL(join(here, "..", "src", "web.ts")).href;
it("does not leave the web server holding its port: it exits once its parent is gone", async () => {
// Windows ends a Node child with its parent on its own (libuv puts children in a job object), so the
// orphan this guards against is a POSIX one; the web server's own exit is what keeps both the same.
// What the web build's instrumentation.ts does with the pid it is given, around a plain HTTP server.
const entry = await script(`
import { createServer } from "node:http";
import { exitWhenParentGone, parentPidFrom } from ${JSON.stringify(watcher)};
createServer((req, res) => res.end("ok")).listen(Number(process.env.PORT), process.env.HOSTNAME);
const exists = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } };
const parentPid = parentPidFrom(process.env.INVISIBLE_DOTS_WEB_PARENT_PID);
if (parentPid !== undefined) exitWhenParentGone(parentPid, { exists, onGone: () => process.exit(0), intervalMs: 100 });
`);
const port = await freePort();
// The server stand-in: a process that starts the web server and then waits to be killed.
const parentScript = join(root, "parent.mjs");
await writeFile(
parentScript,
`
import { startWebServer } from ${JSON.stringify(webModule)};
const web = await startWebServer({
build: { entry: ${JSON.stringify(entry)}, missing: undefined },
listen: { host: "127.0.0.1", port: ${port} },
apiUrl: "http://127.0.0.1:8787",
env: { PATH: process.env.PATH },
onUnexpectedExit: () => undefined,
});
console.log(web.url);
setInterval(() => {}, 1000);
`,
);
const parent = spawn(process.execPath, ["--import", "tsx", parentScript], { stdio: ["ignore", "pipe", "inherit"], cwd: join(here, "..") });
try {
const url = await new Promise<string>((resolve, reject) => {
parent.once("exit", () => reject(new Error("the stand-in server exited before the web server answered")));
parent.stdout.once("data", (chunk: Buffer) => resolve(chunk.toString().trim()));
});
expect((await fetch(url)).status).toBe(200);
parent.kill("SIGKILL");
await expect
.poll(() => fetch(url, { signal: AbortSignal.timeout(500) }).then(() => "still answering", () => "gone"), { timeout: 15_000, interval: 200 })
.toBe("gone");
} finally {
parent.kill("SIGKILL");
}
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@invisible-dots/scheduler",
"version": "0.0.0",
"private": true,
"description": "Task dispatcher: wakes Dots on work and puts them to sleep when idle.",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@invisible-dots/shared": "0.0.0",
"@invisible-dots/database": "0.0.0",
"@invisible-dots/events": "0.0.0"
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Hands PENDING tasks to their Dots (section 9.2): claim one task per idle
* Dot with SKIP LOCKED. The claim stores the task's `task.created` in the
* inbound outbox in the same transaction; sending it, waking the Dot first
* when it sleeps, and failing the task when it cannot be delivered, is the
* inbound delivery's job (inbound.ts).
*/
import type { Database } from "@invisible-dots/database";
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
// A claim skips a row another transaction holds (SKIP LOCKED); such holds last as long as one guest event's
// transaction. A pass that claimed nothing while a task could be claimed comes back this soon, this many times;
// past that the scheduler's timer finds the task.
const SKIPPED_RETRY_MS = 25;
const SKIPPED_RETRIES = 40;
export class Dispatcher {
#running: Promise<void> | null = null;
#again = false;
constructor(
private readonly db: Database,
private readonly clock: Clock,
private readonly log: Logger,
/** Called with the Dot of every claimed task: its task.created is waiting in the outbox. */
private readonly deliver: (dotId: string) => void,
) {}
/**
* Claim every task that can run now. Calls that arrive while a pass is
* running schedule one more pass instead of running concurrently, so one
* dispatcher never races itself. Resolves when the pass is over.
*/
dispatch(): Promise<void> {
if (this.#running) {
this.#again = true;
return this.#running;
}
this.#running = (async () => {
try {
let skipped = 0;
do {
this.#again = false;
for (;;) {
const ts = this.clock.now().toISOString();
const claimed = await this.db.transaction((tx) => tx.tasks.claimNext(ts));
if (!claimed) break;
skipped = 0;
this.log.info("task claimed", { taskId: claimed.task.id, dotId: claimed.task.dot_id });
this.deliver(claimed.task.dot_id);
}
if (!this.#again && skipped < SKIPPED_RETRIES && (await this.db.tasks.hasClaimable())) {
skipped++;
this.#again = true;
await new Promise((resolve) => setTimeout(resolve, SKIPPED_RETRY_MS));
}
} while (this.#again);
} catch (error) {
this.log.error("dispatch pass failed", { error: errorMessage(error) });
} finally {
this.#running = null;
}
})();
return this.#running;
}
/** Whether a pass is running. */
get busy(): boolean {
return this.#running !== null;
}
/** Wait for the current pass (tests and shutdown). */
async settle(): Promise<void> {
while (this.#running) await this.#running;
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* What the control plane needs from the VM layer. The real implementation
* is an adapter over @invisible-dots/vm-manager (apps/api/src/vm-driver.ts);
* tests use the in-process fakes of `./testing.js`.
*/
import type {
AgentStateAnswer,
BrowserIdentity,
BrowserIdentityListAnswer,
ComputerResources,
CreateBrowserIdentityRequest,
DotRuntimeConfig,
FileListAnswer,
HealthAnswer,
InboundEvent,
OutboundEvent,
RefusedEvent,
SecretsRequest,
SystemAnswer,
SkillListAnswer,
ToolListAnswer,
VmState,
} from "@invisible-dots/shared";
/**
* The guest routes the control plane calls (sections 5.2 and 5.3). The
* method names and shapes are those of vm-manager's GuestClient, which
* satisfies this interface as it is. Failed calls throw an error carrying a
* numeric `status` (0 when the guest could not be reached) and, when the
* guest sent one, its `{ error }` code as `code`.
*/
export interface GuestApi {
health(options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<HealthAnswer>;
system(): Promise<SystemAnswer>;
pushSecrets(secrets: SecretsRequest): Promise<void>;
putConfig(config: DotRuntimeConfig): Promise<void>;
postEvent(event: InboundEvent): Promise<unknown>;
state(): Promise<AgentStateAnswer>;
listBrowserIdentities(): Promise<BrowserIdentityListAnswer>;
createBrowserIdentity(body: CreateBrowserIdentityRequest): Promise<BrowserIdentity>;
getBrowserIdentity(id: string): Promise<BrowserIdentity>;
deleteBrowserIdentity(id: string): Promise<void>;
/** The JPEG of an open identity's window; 409 `not_open` when it is closed, 503 `busy` while a call holds it. */
getBrowserIdentityFrame(id: string): Promise<Uint8Array>;
/** End the identity's browser and keep its profile; closing a closed identity is not an error. */
closeBrowserIdentity(id: string): Promise<void>;
listTools(): Promise<ToolListAnswer>;
listSkills(): Promise<SkillListAnswer>;
prepareSleep(timeoutMs?: number): Promise<void>;
screenshot(): Promise<Uint8Array>;
/** The bytes of a file; a file larger than `maxBytes` is refused with status 413 and code `FILE_TOO_LARGE`. */
readFile(path: string, options?: { maxBytes?: number }): Promise<Uint8Array>;
listFiles(path: string): Promise<FileListAnswer>;
/**
* The outbound event stream after `after`. It reconnects by itself on
* network errors and ends only when `signal` aborts or it hits an error
* that waiting cannot fix (a refused token). A message that is not an
* event the host knows is not yielded: `onRefused` is awaited for it, in
* order with the events, and the stream goes on past it.
*/
events(options: { after?: number; signal?: AbortSignal; onRefused?: (info: RefusedEvent) => void | Promise<void> }): AsyncIterable<OutboundEvent>;
}
export interface ComputerSpecInput {
dotId: string;
/** The Dot token in clear; it goes into the seed (section 4.2). */
token: string;
/** The Dot's VM proxy in clear, absent for a direct exit; it goes into the seed with the token. */
proxy?: string;
resources: ComputerResources;
}
export interface CreatedComputer {
/** The golden image the overlay is backed by; recorded, because it can never change for this disk. */
goldenImage: string;
runtimeImage: string;
}
export interface StartedComputer {
/** The host port on 127.0.0.1 that QEMU forwards to the guest's port 1024 (section 3.5). */
guestPort: number;
/** The QEMU process. */
pid: number;
runtimeImage: string;
/** The VM was already running; nothing was spawned and `guestPort` is the one it runs with. */
alreadyRunning: boolean;
}
/** What the VM layer sees of one Dot's VM: its disk, its pid file and its QEMU process (section 3.4). */
export interface ComputerState {
/** Whether the Dot's disk exists, that is whether the VM was ever created and not destroyed. */
exists: boolean;
/**
* RUNNING while the Dot's QEMU process runs, STOPPED when it does not,
* ERROR when a live pid cannot be proven to be that QEMU. Whether the guest
* inside is up is guest health, which the READY procedure checks.
*/
state: VmState;
/** The pid of the live QEMU process, null when there is none. */
pid: number | null;
/**
* The host port forwarded to the guest, from the pid file written when
* QEMU was spawned, so it is right even when the database row is not;
* null when not running.
*/
guestPort: number | null;
/** Why the state is ERROR, for logs and error messages. */
detail: string | null;
}
/** Where a guest is reached: the forwarded port recorded in the `computers` row (section 5.1). */
export interface GuestEndpoint {
dotId: string;
port: number;
}
export interface WaitForHealthOptions {
timeoutMs: number;
intervalMs: number;
/** Timeout of each health request. */
requestTimeoutMs: number;
}
export interface ComputerDriver {
/** Overlay and seed (section 9.4 up to `write seed.iso`). Safe to retry. */
create(spec: ComputerSpecInput): Promise<CreatedComputer>;
/** Pick a guest port and spawn QEMU detached (sections 3.4 and 3.5). */
start(spec: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer>;
/**
* Poll `GET /v1/health` until dot-agentd and the agent both report ok.
* Fails at once on a refused token (status 401) and when the VM's QEMU
* process exits, and after `timeoutMs` otherwise.
*/
waitForHealth(endpoint: GuestEndpoint, token: string, options: WaitForHealthOptions): Promise<HealthAnswer>;
/**
* `POST /v1/system/poweroff` through the guest channel with the Dot's
* token, then the QEMU process is killed after the grace period (section 9.5).
*/
stop(dotId: string, token: string): Promise<{ forced: boolean }>;
/**
* A full stop and start, so the guest shuts down cleanly and new resources
* and a new runtime ISO apply. The guest port changes.
*/
reboot(spec: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer>;
/** Stop the VM if it runs and remove its directory with the disk. */
destroy(dotId: string): Promise<void>;
/** From the pid file and the process, never from the database: this is what reconciliation compares against. */
state(dotId: string): Promise<ComputerState>;
guest(endpoint: GuestEndpoint, token: string): GuestApi;
/** Release what this process holds. VMs keep running. */
close(): Promise<void>;
}
/** The HTTP status a failed guest call carried, or 0 when the guest was not reached. */
export function guestErrorStatus(error: unknown): number {
const status = (error as { status?: unknown })?.status;
return typeof status === "number" ? status : 0;
}
/** The `{ error }` code of a failed guest call, when there was one. */
export function guestErrorCode(error: unknown): string | undefined {
const code = (error as { code?: unknown })?.code;
return typeof code === "string" && !/^E[A-Z]+$/.test(code) ? code : undefined;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* What a guest event changes on the host besides the event log: task rows (state, summary, error, spend),
* approval rows, the Dot's status, the time of its next automation, and the MCP servers of its config.
*/
import type { Repositories } from "@invisible-dots/database";
import {
isMcpServerName,
parseDotConfig,
parseMcpServerEntry,
resolvePermission,
TERMINAL_TASK_STATES,
type AgentState,
type DotRecord,
type DotState,
type HostEventDataMap,
type OutboundEvent,
type PermissionName,
} from "@invisible-dots/shared";
/**
* The Dot status for an agent state. READY means the computer is up and the
* agent is IDLE; DONE is the short step before IDLE (section 8.1) and still
* counts as working.
*/
export function dotStatusForAgent(state: AgentState): DotState {
switch (state) {
case "IDLE":
return "READY";
case "WAITING_APPROVAL":
return "WAITING_APPROVAL";
default:
return "RUNNING";
}
}
/** Dot states the agent's own reports never override: they are set by the control plane. */
const STICKY_DOT_STATES: readonly DotState[] = ["CREATING", "ERROR", "IDLE"];
export interface AppliedGuestEvent {
/** A task reached a terminal state, so the Dot may take the next one. */
taskSettled: boolean;
/** The Dot's config changed (an MCP server it added): it is pushed to the guest once the transaction commits. */
reconfigured?: DotRecord;
/** What the host logs of it, in the same transaction: `dot.updated`, or why it refused (`mcp.server.refused`). */
hostEvents?: HostEventOf[];
}
type HostEventOf = { [K in keyof HostEventDataMap]: { type: K; data: HostEventDataMap[K] } }[keyof HostEventDataMap];
/** The tool of the engine that asks to add an MCP server; its approvals are the person's consent to one. */
export const ADD_MCP_SERVER_TOOL = "add_mcp_server";
const ADD_PERMISSION: PermissionName = "mcp.servers.add";
/**
* A Dot added an MCP server to its own config. The config is the host's, so the host decides again what the engine
* decided: the permission allows it, or the person approved a call of exactly these arguments, and that approval is
* used up here, so it adds one server once. The entry is checked by the config's own schema first (a call refused for
* it keeps its approval), and a name already declared is not replaced. The server's own permission is left to its
* default, ask, so each of its tools still asks until the person allows it.
*/
async function addMcpServer(tx: Repositories, dotId: string, args: Record<string, unknown>): Promise<AppliedGuestEvent> {
const name = typeof args.name === "string" ? args.name : "";
const refuse = (reason: string): AppliedGuestEvent => ({ taskSettled: false, hostEvents: [{ type: "mcp.server.refused", data: { name, reason } }] });
const dot = await tx.dots.get(dotId);
if (!dot) return refuse("the Dot no longer exists");
const decision = resolvePermission(dot.config, ADD_PERMISSION);
if (decision === "deny") return refuse(`the Dot's config denies ${ADD_PERMISSION}`);
if (!isMcpServerName(name)) return refuse(`"${name}" is not an MCP server's name`);
if (name in dot.config.mcp_servers) return refuse(`the Dot's config already declares an MCP server "${name}"`);
const { name: _name, reason: _reason, ...entry } = args;
const parsed = parseMcpServerEntry(entry);
if (!parsed.ok) return refuse(parsed.problem);
const config = parseDotConfig({ ...dot.config, mcp_servers: { ...dot.config.mcp_servers, [name]: parsed.server } });
let approvalId: string | undefined;
if (decision === "ask") {
const approval = await tx.approvals.useApproved(dotId, ADD_MCP_SERVER_TOOL, args);
if (!approval) return refuse(`${ADD_PERMISSION} asks, and no unused approval of exactly these arguments was given`);
approvalId = approval.id;
}
const reconfigured = await tx.dots.updateConfig(dotId, config);
if (!reconfigured) return refuse("the Dot no longer exists");
const added = { name: dot.name, mcp_server_added: name, ...(approvalId && { approval_id: approvalId }) };
return { taskSettled: false, reconfigured, hostEvents: [{ type: "dot.updated", data: added }] };
}
/** The spend a task event reports (architecture section 5.4), onto the task's row; events without it change nothing. */
async function recordTaskSpend(tx: Repositories, dotId: string, data: { task_id: string; spent_usd?: number }): Promise<void> {
if (data.spent_usd !== undefined) await tx.tasks.recordSpend(data.task_id, dotId, data.spent_usd);
}
/** Apply one newly stored guest event inside the transaction that stored it. */
export async function applyGuestEvent(tx: Repositories, dotId: string, event: OutboundEvent): Promise<AppliedGuestEvent> {
switch (event.type) {
case "agent.state": {
const dot = await tx.dots.get(dotId);
if (dot && !STICKY_DOT_STATES.includes(dot.status)) {
const next = dotStatusForAgent(event.data.state);
if (next !== dot.status) await tx.dots.setStatus(dotId, next);
}
return { taskSettled: false };
}
case "task.started":
await tx.tasks.transition(event.data.task_id, "RUNNING", { dotId });
return { taskSettled: false };
case "task.progress":
await recordTaskSpend(tx, dotId, event.data);
return { taskSettled: false };
case "task.completed":
await recordTaskSpend(tx, dotId, event.data);
return { taskSettled: (await tx.tasks.transition(event.data.task_id, "COMPLETED", { summary: event.data.summary, dotId })) !== null };
case "task.failed":
await recordTaskSpend(tx, dotId, event.data);
return { taskSettled: (await tx.tasks.transition(event.data.task_id, "FAILED", { error: event.data.error, dotId })) !== null };
case "automation.next_run":
// The guest's report of when its earliest automation is due, so a stopped computer is started shortly before it.
await tx.computers.setNextAutomation(dotId, event.data.next_run_at_ms);
return { taskSettled: false };
case "approval.requested": {
// A request for a task that already ended (cancelled while the guest
// was asking) is kept for the record but never listed as pending.
const task = event.data.task_id ? await tx.tasks.get(event.data.task_id) : null;
const ended = task !== null && task.dot_id === dotId && TERMINAL_TASK_STATES.includes(task.status);
await tx.approvals.insertRequested(dotId, event.data, ended ? "expired" : "pending");
if (event.data.task_id && !ended) await tx.tasks.transition(event.data.task_id, "WAITING_APPROVAL", { dotId });
return { taskSettled: false };
}
case "mcp.server.added":
return addMcpServer(tx, dotId, event.data.arguments);
default:
return { taskSettled: false };
}
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Delivery of host to guest events (sections 5.4 and 9.5) from the durable
* outbox in `inbound_events`. Whatever the control plane decides to tell a
* guest (a claimed task, a message, an approval, a cancel) is stored first,
* in the transaction that decided it; this class only sends what is stored.
*
* Per Dot, one flush runs at a time and sends the pending rows in order,
* stopping at the first failure so a later event never overtakes an earlier
* one. A Dot that is not READY is woken first. A row is marked delivered
* only on the guest's 202; the guest keeps every event id it accepted, so a
* row whose send had an unknown outcome (a timeout, a reset connection) is
* simply sent again. Every READY transition flushes the Dot again, so
* nothing waits for a timer once the guest is back.
*/
import { TASK_ENDED_BEFORE_DELIVERY, type Database, type InboundRecord } from "@invisible-dots/database";
import type { EventLog } from "@invisible-dots/events";
import { guestErrorCode, guestErrorStatus } from "./driver.js";
import type { Lifecycle } from "./lifecycle.js";
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
export interface InboundDeliveryOptions {
/** Failed sends with a known outcome before a task.created gives its task up as FAILED. */
maxDeliveryAttempts: number;
/** Delay before a failed row is retried, multiplied by its number of failures. */
retryDelayMs: number;
}
export const DEFAULT_INBOUND_OPTIONS: InboundDeliveryOptions = {
maxDeliveryAttempts: 3,
retryDelayMs: 30_000,
};
export interface InboundDeliveryDeps {
db: Database;
events: EventLog;
lifecycle: Lifecycle;
clock: Clock;
logger: Logger;
/** Resolves once a Dot that is still being provisioned is (successfully or not). */
provisioned: (dotId: string) => Promise<void>;
/** A task reached a terminal state here: the dispatcher may have work to hand out. */
onTaskSettled: () => void;
options?: Partial<InboundDeliveryOptions>;
}
/**
* Whether a failed send certainly did NOT hand the event to the guest: the
* guest answered with an error, or nothing listened on the guest port. A
* timeout or a dropped connection may have delivered it, so it is not
* counted against the row.
*/
function knownUndelivered(error: unknown): boolean {
return guestErrorStatus(error) >= 400 || (error as { code?: unknown })?.code === "ECONNREFUSED";
}
/** Rolls back a give-up whose task ended before it: the task keeps that end and logs no failure. */
class TaskAlreadyEnded extends Error {}
export class InboundDelivery {
readonly #db: Database;
readonly #events: EventLog;
readonly #lifecycle: Lifecycle;
readonly #clock: Clock;
readonly #log: Logger;
readonly #provisioned: (dotId: string) => Promise<void>;
readonly #onTaskSettled: () => void;
readonly #opts: InboundDeliveryOptions;
readonly #flushing = new Map<string, Promise<void>>();
readonly #again = new Set<string>();
readonly #timers = new Set<NodeJS.Timeout>();
/** Dots whose deliveries wait for something that has to reach the guest first (`hold`). */
readonly #holds = new Map<string, { holders: number; open: Promise<void>; release: () => void }>();
#closed = false;
constructor(deps: InboundDeliveryDeps) {
this.#db = deps.db;
this.#events = deps.events;
this.#lifecycle = deps.lifecycle;
this.#clock = deps.clock;
this.#log = deps.logger;
this.#provisioned = deps.provisioned;
this.#onTaskSettled = deps.onTaskSettled;
this.#opts = { ...DEFAULT_INBOUND_OPTIONS, ...deps.options };
}
get maxDeliveryAttempts(): number {
return this.#opts.maxDeliveryAttempts;
}
/**
* Send nothing to the Dot's guest until the returned function is called. For a caller that stores a row and
* then has to do something to the guest that the row's meaning depends on: the config an approval's "always
* allow" changed has to be there before the guest hears the approval, or the approved call's next use of the
* permission is asked again. Take the hold before the row is stored: a flush that is already running
* waits for it before every row it sends, so no later flush or timer can overtake the work. Several holds
* on one Dot add up. The hold is in memory: after a restart, the guest's next READY does what the caller
* would have done.
*/
hold(dotId: string): () => void {
let entry = this.#holds.get(dotId);
if (!entry) {
let release!: () => void;
const open = new Promise<void>((resolve) => {
release = resolve;
});
entry = { holders: 0, open, release };
this.#holds.set(dotId, entry);
}
entry.holders++;
const held = entry;
let released = false;
return () => {
if (released) return;
released = true;
if (--held.holders === 0) {
this.#holds.delete(dotId);
held.release();
}
};
}
async #unheld(dotId: string): Promise<void> {
for (let entry = this.#holds.get(dotId); entry; entry = this.#holds.get(dotId)) await entry.open;
}
/**
* Send the Dot's pending rows. A call while a flush runs makes that flush
* look once more when it is done, so nothing stored meanwhile is missed.
* Resolves when no flush of the Dot is running any more.
*/
kick(dotId: string): Promise<void> {
if (this.#closed) return Promise.resolve();
const running = this.#flushing.get(dotId);
if (running) {
this.#again.add(dotId);
return running;
}
const flush = (async () => {
try {
do {
this.#again.delete(dotId);
await this.#flush(dotId);
} while (this.#again.has(dotId) && !this.#closed);
} catch (error) {
this.#log.error("inbound delivery failed", { dotId, error: errorMessage(error) });
} finally {
this.#flushing.delete(dotId);
}
})();
this.#flushing.set(dotId, flush);
return flush;
}
/** Kick every Dot whose oldest pending row is due for another try (the periodic pass and the start). */
async kickDue(): Promise<void> {
for (const dotId of await this.#db.inbound.dueDots(this.#opts.maxDeliveryAttempts)) void this.kick(dotId);
}
/** Whether any flush is running. */
get busy(): boolean {
return this.#flushing.size > 0;
}
/** Whether a flush of the Dot is running. */
isFlushing(dotId: string): boolean {
return this.#flushing.has(dotId);
}
/** Wait for every flush running now (tests and shutdown). */
async settle(): Promise<void> {
while (this.#flushing.size > 0) await Promise.allSettled([...this.#flushing.values()]);
}
close(): void {
this.#closed = true;
for (const timer of this.#timers) clearTimeout(timer);
this.#timers.clear();
}
async #flush(dotId: string): Promise<void> {
await this.#unheld(dotId);
let rows = await this.#db.inbound.pending(dotId);
if (rows.length === 0) return;
await this.#provisioned(dotId);
try {
await this.#lifecycle.ensureReady(dotId);
} catch (error) {
await this.#notReady(dotId, rows, error);
return;
}
// Rows stored while the Dot woke up are sent in the same pass.
rows = await this.#db.inbound.pending(dotId);
for (const row of rows) {
await this.#unheld(dotId);
if (this.#closed) return;
if ((await this.#db.inbound.beginSend(row.event.id)) === "skip") {
this.#log.info("inbound event not sent: its task ended first", { dotId, id: row.event.id, type: row.event.type });
continue;
}
try {
await (await this.#lifecycle.guest(dotId)).postEvent(row.event);
} catch (error) {
this.#lifecycle.markSuspect(dotId);
await this.#sendFailed(row, error);
return;
}
await this.#db.inbound.markDelivered(row.event.id);
await this.#db.computers.touch(dotId, this.#clock.now());
this.#log.info("inbound event delivered", { dotId, id: row.event.id, type: row.event.type });
}
}
/** The Dot could not be made READY: every pending row had one more failed attempt. */
async #notReady(dotId: string, rows: InboundRecord[], error: unknown): Promise<void> {
const reason = errorMessage(error);
this.#log.warn("inbound events wait: the Dot is not READY", { dotId, pending: rows.length, error: reason });
for (const row of rows) await this.#countFailure(row, reason);
}
async #sendFailed(row: InboundRecord, error: unknown): Promise<void> {
const reason = errorMessage(error);
if (!knownUndelivered(error)) {
// The guest may hold it: never counted, sent again with the same id.
this.#log.warn("inbound event outcome unknown, will send it again", { dotId: row.dot_id, id: row.event.id, error: reason });
const retryAt = this.#retryAt(1);
await this.#db.inbound.postpone(row.event.id, reason, retryAt);
this.#schedule(row.dot_id, this.#opts.retryDelayMs);
return;
}
const status = guestErrorStatus(error);
if (status >= 400 && status < 500) {
// The guest refused this event as such: sending it again cannot change that.
const code = guestErrorCode(error) ?? String(status);
this.#log.error("the guest refused an inbound event; it is dropped", { dotId: row.dot_id, id: row.event.id, error: reason });
await this.#giveUp(row, `refused by the guest (${code}): ${reason}`);
return;
}
await this.#countFailure(row, reason);
}
async #countFailure(row: InboundRecord, reason: string): Promise<void> {
const failures = await this.#db.inbound.recordFailure(row.event.id, reason, this.#retryAt(row.failures + 1));
if (failures >= this.#opts.maxDeliveryAttempts) {
if (row.event.type === "task.created") {
await this.#giveUp(row, `could not be delivered to the Dot after ${failures} attempts: ${reason}`);
} else {
// A message or a decision is never given up: the next READY sends it.
this.#log.warn("inbound event waits for the next READY", { dotId: row.dot_id, id: row.event.id, failures });
}
return;
}
this.#schedule(row.dot_id, this.#opts.retryDelayMs * failures);
}
/**
* Drop a row for good; a task.created takes its task with it, logged as `task.failed` in the same transaction like
* every task state change. A task that ended meanwhile (cancelled) keeps its end: only the row is dropped.
*/
async #giveUp(row: InboundRecord, reason: string): Promise<void> {
const taskId = row.event.type === "task.created" ? row.task_id : null;
if (taskId) {
const logged = await this.#db
.transaction(async (tx) => {
// The event first: its insert takes the event-order lock (database events.ts).
const event = await this.#events.appendHostIn(tx, row.dot_id, "task.failed", { task_id: taskId, error: reason });
if (!(await tx.tasks.transition(taskId, "FAILED", { error: reason }))) throw new TaskAlreadyEnded();
await tx.inbound.drop(row.event.id, reason);
return event;
})
.catch((error: unknown) => {
if (error instanceof TaskAlreadyEnded) return null;
throw error;
});
if (logged) {
this.#events.publish(logged);
this.#log.error("task delivery failed for good", { taskId, error: reason });
this.#onTaskSettled();
return;
}
}
await this.#db.inbound.drop(row.event.id, reason);
}
/** scheduled_at style: compared with the database's now(), so a zero delay is "no date" rather than this host's now. */
#retryAt(failures: number): Date | null {
const delay = this.#opts.retryDelayMs * failures;
return delay > 0 ? new Date(Date.now() + delay) : null;
}
#schedule(dotId: string, delayMs: number): void {
if (this.#closed) return;
const timer = setTimeout(() => {
this.#timers.delete(timer);
void this.kick(dotId);
}, delayMs);
timer.unref();
this.#timers.add(timer);
}
}
export { TASK_ENDED_BEFORE_DELIVERY };
+7
View File
@@ -0,0 +1,7 @@
export * from "./dispatcher.js";
export * from "./driver.js";
export * from "./guest-events.js";
export * from "./inbound.js";
export * from "./lifecycle.js";
export * from "./scheduler.js";
export * from "./support.js";
+878
View File
@@ -0,0 +1,878 @@
/**
* The life of a Dot's computer on the host: create (section 9.4), the READY
* procedure (9.3), sleep and wake (9.5), reboot, delete, the guest event
* pump, and reconciliation with the running QEMU processes (found through
* their pid files) after a control plane restart.
*
* Every operation that changes a computer's state runs under a per-Dot lock,
* so a wake and an idle sleep of the same Dot can never interleave. A Dot is
* in `#ready` only between a completed READY procedure and the moment an
* operation under that lock takes it out again, so anything that skips the
* lock because the Dot is READY (a delivery, a push) can only meet a stop
* that has not started yet: every stop takes the Dot out of `#ready` first.
*/
import { VM_PROXY_NAME, type Database, type Repositories } from "@invisible-dots/database";
import type { EventLog } from "@invisible-dots/events";
import {
COMPUTER_STOPPED,
computerIsUp,
computerResources,
PREPARE_SLEEP_TIMEOUT_MS,
toRuntimeConfig,
type DotState,
type HealthAnswer,
type HostEventDataMap,
type HostEventType,
type OutboundEvent,
type RefusedEvent,
type StopReason,
type StoredEvent,
type VmState,
} from "@invisible-dots/shared";
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type ComputerSpecInput, type ComputerState, type GuestApi } from "./driver.js";
import { applyGuestEvent, dotStatusForAgent, type AppliedGuestEvent } from "./guest-events.js";
import { abortableSleep, ControlPlaneError, errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
import { BackgroundWork, KeyedMutex } from "./support.js";
export interface LifecycleOptions {
/** How long a started VM may take to pass the READY procedure. Default 10 minutes (a first boot runs cloud-init). */
readyTimeoutMs: number;
/** Interval between health polls while waiting for READY. Default 2 s. */
healthPollMs: number;
/** Per-request timeout of a health poll. Default 5 s. */
healthRequestTimeoutMs: number;
/** First retry delay of the event pump after a failure; doubles up to `pumpMaxRetryMs`. */
pumpRetryMs: number;
pumpMaxRetryMs: number;
/** Time the agent gets to flush its state before a shutdown (section 9.5). Default PREPARE_SLEEP_TIMEOUT_MS of packages/shared, which the engine's own steps fit inside. */
prepareSleepTimeoutMs: number;
/**
* How long before an automation is due a stopped computer is started, and how close to one a running computer is
* kept awake (section 9.5). It is the time a start takes to reach READY with some to spare; the passes that look
* for the Dots to start run every few seconds, so the computer is up between this long and a pass before the run.
* Default 90 s.
*/
automationWakeLeadMs: number;
}
export const DEFAULT_LIFECYCLE_OPTIONS: LifecycleOptions = {
readyTimeoutMs: 10 * 60_000,
healthPollMs: 2_000,
healthRequestTimeoutMs: 5_000,
pumpRetryMs: 1_000,
pumpMaxRetryMs: 30_000,
prepareSleepTimeoutMs: PREPARE_SLEEP_TIMEOUT_MS,
automationWakeLeadMs: 90_000,
};
export interface LifecycleDeps {
db: Database;
events: EventLog;
driver: ComputerDriver;
clock: Clock;
logger: Logger;
options?: Partial<LifecycleOptions>;
/** Called when a task reached a terminal state or a Dot became READY: the dispatcher may have work to hand out. */
onWorkPossible?: () => void;
/** Called every time a Dot becomes READY: what waits in its inbound outbox can go now. */
onReady?: (dotId: string) => void;
}
/** Why a stop is asked for: the idle sleep, or the person (`exited` is only ever recorded, never asked for). */
export type StopRequest = Exclude<StopReason, "exited">;
/** Part of the error a Dot gets when READY failed only for want of a key; setting a key retries those Dots. */
export const MISSING_KEY_MESSAGE = "no OpenRouter API key is configured";
/** The READY procedure failed; the message says which step and why. */
export class NotReadyError extends ControlPlaneError {
constructor(dotId: string, message: string) {
super(503, "computer_not_ready", `Dot ${dotId}: ${message}`);
this.name = "NotReadyError";
}
}
/**
* A failed guest call described by its status and code only. Used where the
* request carried a secret: the error text of a guest or a proxy may echo
* the body, and this message goes into logs, `dots.error` and the event log.
*/
function describeWithoutBody(error: unknown): string {
const status = guestErrorStatus(error);
const code = guestErrorCode(error) ?? (error as { code?: unknown })?.code;
return status === 0 ? `the guest was not reached${typeof code === "string" ? ` (${code})` : ""}` : `status ${status}${typeof code === "string" ? `, ${code}` : ""}`;
}
/** Store a host event through the transaction a change is made in (`Lifecycle.#commit`). */
type HostLog = <K extends HostEventType>(dotId: string, type: K, data: HostEventDataMap[K]) => Promise<void>;
export class Lifecycle {
readonly #db: Database;
readonly #events: EventLog;
readonly #driver: ComputerDriver;
readonly #clock: Clock;
readonly #log: Logger;
readonly #opts: LifecycleOptions;
readonly #onWorkPossible: () => void;
readonly #onReady: (dotId: string) => void;
readonly #mutex = new KeyedMutex();
/** Pushes of the key and the config to a READY guest, one at a time per Dot, so the last one sent is the newest. */
readonly #pushes = new KeyedMutex();
/** Bumped by every change of what a guest must hold; READY completes only on an unchanged generation. */
readonly #generation = new Map<string, number>();
/** Dots whose READY procedure completed since their computer last started. */
readonly #ready = new Set<string>();
readonly #pumps = new Map<string, AbortController>();
readonly #pumpDone = new Map<string, Promise<void>>();
readonly #background: BackgroundWork;
#closed = false;
constructor(deps: LifecycleDeps) {
this.#db = deps.db;
this.#events = deps.events;
this.#driver = deps.driver;
this.#clock = deps.clock;
this.#log = deps.logger;
this.#background = new BackgroundWork(this.#log);
this.#opts = { ...DEFAULT_LIFECYCLE_OPTIONS, ...deps.options };
this.#onWorkPossible = deps.onWorkPossible ?? (() => {});
this.#onReady = deps.onReady ?? (() => {});
}
isReady(dotId: string): boolean {
return this.#ready.has(dotId);
}
/** Whether a lifecycle operation is queued or running for the Dot. */
isBusy(dotId: string): boolean {
return this.#mutex.isBusy(dotId);
}
/**
* The guest of a running computer, reached through the port recorded at its
* start. The port is read for every call: it changes with every start.
*/
async guest(dotId: string): Promise<GuestApi> {
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (computer.guest_port === null) {
throw new ControlPlaneError(409, COMPUTER_STOPPED, `the computer of Dot ${dotId} is ${computer.state}, it has no guest port`);
}
return this.#driver.guest({ dotId, port: computer.guest_port }, await this.#db.computers.token(dotId));
}
/**
* Run `write` in one transaction, and publish what it logged after COMMIT, in order. A change of state and the host
* events that tell it commit together or not at all: a control plane killed between two writes leaves the state it
* was in before the change (which recovery knows how to finish), never a state nobody was told of. `log` stores a host
* event through the transaction. Like every transaction that inserts events and changes other rows, `write` logs first
* and writes its rows after (the event insert takes the event-order lock, database events.ts).
*/
async #commit<T>(write: (tx: Repositories, log: HostLog) => Promise<T>): Promise<T> {
const { value, logged } = await this.#db.transaction(async (tx) => {
const logged: StoredEvent[] = [];
const log: HostLog = async (dotId, type, data) => {
logged.push(await this.#events.appendHostIn(tx, dotId, type, data));
};
return { value: await write(tx, log), logged };
});
for (const event of logged) this.#events.publish(event);
return value;
}
/** Log `computer.state` when the computer's row holds another state now; the caller writes the row after its events. */
async #logState(tx: Repositories, log: HostLog, dotId: string, state: VmState): Promise<void> {
if ((await tx.computers.get(dotId))?.state !== state) await log(dotId, "computer.state", { state });
}
/** Log `dot.updated` for the status ERROR (the other statuses are told by the events of what caused them); the caller writes the row after its events. */
async #logDotStatus(tx: Repositories, log: HostLog, dotId: string, status: DotState, error: string | null): Promise<void> {
const dot = status === "ERROR" ? await tx.dots.get(dotId) : null;
if (dot) await log(dotId, "dot.updated", { name: dot.name, status, error });
}
async #setVmState(dotId: string, state: VmState, lastError?: string | null, stopReason?: StopReason): Promise<void> {
await this.#commit(async (tx, log) => {
await this.#logState(tx, log, dotId, state);
await tx.computers.setState(dotId, state, lastError, stopReason);
});
}
async #setDotStatus(dotId: string, status: DotState, error: string | null = null): Promise<void> {
await this.#commit(async (tx, log) => {
await this.#logDotStatus(tx, log, dotId, status, error);
await tx.dots.setStatus(dotId, status, error);
});
}
/**
* The computer is up: its process, its images and its state RUNNING, with `computer.state` and `computer.started`,
* in one write. From here on the guest is reached through this port.
*/
async #recordStarted(
dotId: string,
started: { guestPort: number; pid: number; runtimeImage: string },
goldenImage: string,
extra: Record<string, unknown>,
): Promise<void> {
await this.#commit(async (tx, log) => {
await this.#logState(tx, log, dotId, "RUNNING");
await log(dotId, "computer.started", { guest_port: started.guestPort, runtime_image: started.runtimeImage, ...extra });
await tx.computers.setProcess(dotId, { guestPort: started.guestPort, pid: started.pid });
await tx.computers.setImages(dotId, goldenImage, started.runtimeImage);
await tx.computers.setState(dotId, "RUNNING", null);
});
}
/** The computer and the Dot are in ERROR for `message`, with `computer.state` and `dot.updated`, in one write. */
async #recordError(dotId: string, message: string): Promise<void> {
await this.#commit(async (tx, log) => {
await this.#logState(tx, log, dotId, "ERROR");
await this.#logDotStatus(tx, log, dotId, "ERROR", message);
await tx.computers.setState(dotId, "ERROR", message);
await tx.dots.setStatus(dotId, "ERROR", message);
});
}
/**
* The computer is off: no process, the state STOPPED with the reason, `computer.state` and `computer.stopped`, and
* the Dot idle, in one write. Recovery, which finds a computer off that was not recorded as such, leaves a Dot that
* is disabled or in ERROR as it is (`keepFailedDot`).
*/
async #recordStopped(dotId: string, reason: StopReason, forced: boolean, keepFailedDot = false): Promise<void> {
await this.#commit(async (tx, log) => {
await this.#logState(tx, log, dotId, "STOPPED");
await log(dotId, "computer.stopped", { reason, forced });
const dot = await tx.dots.get(dotId);
await tx.computers.setProcess(dotId, null);
await tx.computers.setState(dotId, "STOPPED", null, reason);
if (dot && !(keepFailedDot && dot.status === "ERROR")) await tx.dots.setStatus(dotId, "IDLE", null);
});
}
/** Record a lifecycle failure on both rows, log it, and rethrow it. */
async #fail(dotId: string, step: string, error: unknown): Promise<never> {
const message = `${step} failed: ${errorMessage(error)}`;
this.#log.error(message, { dotId });
await this.#recordError(dotId, message).catch(() => {});
throw error instanceof ControlPlaneError ? error : new ControlPlaneError(502, "computer_error", `Dot ${dotId}: ${message}`);
}
async #spec(dotId: string): Promise<ComputerSpecInput & { goldenImage: string | null; state: VmState }> {
const [dot, computer] = await Promise.all([this.#db.dots.get(dotId), this.#db.computers.get(dotId)]);
if (!dot || !computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
const proxy = await this.#db.secrets.get(dotId, VM_PROXY_NAME);
return {
dotId,
token: await this.#db.computers.token(dotId),
...(proxy ? { proxy } : {}),
resources: computerResources(dot.config),
goldenImage: computer.golden_image,
state: computer.state,
};
}
/** Section 9.4: provision the computer of a Dot inserted as CREATING, start it and bring it to READY. */
provision(dotId: string): Promise<void> {
return this.#mutex.run(dotId, async () => {
const spec = await this.#spec(dotId);
this.#log.info("provisioning computer", { dotId });
let created;
try {
created = await this.#driver.create(spec);
} catch (error) {
return this.#fail(dotId, "create", error);
}
await this.#db.computers.setImages(dotId, created.goldenImage, created.runtimeImage);
await this.#startLocked(dotId);
});
}
/**
* Make sure the Dot can take work: start a stopped computer, finish the
* READY procedure of a running one that is not READY yet. Resolves once
* READY; throws (and leaves the Dot in ERROR) when that fails.
*/
async ensureReady(dotId: string): Promise<void> {
if (this.#ready.has(dotId)) return;
await this.#mutex.run(dotId, () => this.#ensureReadyLocked(dotId));
}
async #ensureReadyLocked(dotId: string): Promise<void> {
if (this.#ready.has(dotId)) return;
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (computer.state === "DELETING") throw new ControlPlaneError(409, "dot_deleting", `Dot ${dotId} is being deleted`);
if (computer.state === "PROVISIONING") {
throw new ControlPlaneError(409, "dot_provisioning", `Dot ${dotId} is still being provisioned`);
}
const vm = await this.#driver.state(dotId);
if (vm.state === "RUNNING" && vm.guestPort !== null) {
await this.#adoptRunning(dotId, { ...vm, guestPort: vm.guestPort });
} else {
await this.#startLocked(dotId);
}
}
/**
* Start a stopped computer for an automation that is due, if it still is one: the pass that chose it read the
* database before this turn of the per-Dot lock came, and the person may have stopped the computer since (a stop
* that is not started for its automations), or the guest reported another time. The rule is read again, under the
* lock, from the same place the pass read it (`stoppedDotsWithAutomationDue`); true when the computer was started.
*/
async wakeForAutomation(dotId: string): Promise<boolean> {
if (this.#ready.has(dotId)) return false;
return this.#mutex.run(dotId, async () => {
if (!(await this.stoppedDotsWithAutomationDue()).includes(dotId)) return false;
await this.#ensureReadyLocked(dotId);
return true;
});
}
async #startLocked(dotId: string): Promise<void> {
const spec = await this.#spec(dotId);
if (!spec.goldenImage) {
return this.#fail(dotId, "start", new Error("the computer has no golden image recorded; it was never created"));
}
await this.#setVmState(dotId, "STARTING");
let started;
try {
started = await this.#driver.start({ ...spec, goldenImage: spec.goldenImage });
} catch (error) {
await this.#db.computers.setProcess(dotId, null).catch(() => {});
return this.#fail(dotId, "start", error);
}
await this.#recordStarted(dotId, started, spec.goldenImage, started.alreadyRunning ? { already_running: true } : {});
await this.#readyProcedure(dotId);
}
/**
* A running VM this process did not start (after a control plane restart,
* or after a failed READY). Its port and pid are what QEMU and its pid
* file say now; the row is brought in line with them when it differs.
*/
async #adoptRunning(dotId: string, vm: ComputerState & { guestPort: number }): Promise<void> {
const computer = await this.#db.computers.get(dotId);
const pid = vm.pid ?? computer?.pid ?? null;
if (pid === null) {
return this.#fail(dotId, "attach to the running computer", new Error("QEMU runs but neither its pid file nor the database names its pid"));
}
if (computer?.guest_port !== vm.guestPort || computer?.pid !== pid) {
this.#log.info("recording the running VM's process", { dotId, guestPort: vm.guestPort, pid });
await this.#db.computers.setProcess(dotId, { guestPort: vm.guestPort, pid });
}
await this.#setVmState(dotId, "RUNNING", null);
await this.#readyProcedure(dotId);
}
#generationOf(dotId: string): number {
return this.#generation.get(dotId) ?? 0;
}
/**
* Push what the guest must hold: the OpenRouter key and the secrets the
* config's MCP servers name (memory only in the guest, so lost with every
* agent restart), then the runtime config, all read from the database now.
* The one place that does this, for READY, for a changed key, secret or
* config, and after an agent restart.
*/
async #push(dotId: string, guest: GuestApi): Promise<void> {
const key = await this.#db.secrets.openRouterKey(dotId);
if (!key) throw new NotReadyError(dotId, `${MISSING_KEY_MESSAGE}; set one with PUT /api/secrets/openrouter`);
const dot = await this.#db.dots.get(dotId);
if (!dot) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} not found`);
const mcpSecrets = await this.#db.secrets.mcpSecrets(dotId, dot.config.mcp_servers);
try {
await guest.pushSecrets({ openrouter_api_key: key, mcp_secrets: mcpSecrets });
} catch (error) {
// Never the guest's own words here: whatever answered may have echoed a secret.
throw new Error(`the guest did not take the OpenRouter key and the MCP secrets (${describeWithoutBody(error)})`);
}
await guest.putConfig(toRuntimeConfig(dot.config));
}
/**
* Section 9.3: wait for dot-agentd and the agent, push the OpenRouter key,
* push the runtime config, then require the agent to report the key and
* every self-check. Only then is the Dot READY and its events pumped.
*/
async #readyProcedure(dotId: string): Promise<void> {
const computer = await this.#db.computers.get(dotId);
if (computer?.guest_port == null) {
return this.#fail(dotId, "READY", new Error("the computer has no guest port recorded"));
}
// A pump of an earlier start reads a port that is gone and never comes back on its own.
await this.#stopPump(dotId);
const endpoint = { dotId, port: computer.guest_port };
const token = await this.#db.computers.token(dotId);
const guest = this.#driver.guest(endpoint, token);
let health: HealthAnswer;
try {
health = await this.#driver.waitForHealth(endpoint, token, {
timeoutMs: this.#opts.readyTimeoutMs,
intervalMs: this.#opts.healthPollMs,
requestTimeoutMs: this.#opts.healthRequestTimeoutMs,
});
} catch (error) {
if ((error as { status?: unknown }).status === 401) {
return this.#fail(dotId, "READY", new Error("the guest refused the Dot token (401)"));
}
return this.#fail(dotId, "READY (waiting for the guest)", error);
}
let agentState;
for (;;) {
const generation = this.#generationOf(dotId);
try {
await this.#push(dotId, guest);
health = await guest.health({ timeoutMs: this.#opts.healthRequestTimeoutMs });
} catch (error) {
return this.#fail(dotId, error instanceof NotReadyError ? "READY" : "READY (secret and config push)", error);
}
const problems: string[] = [];
if (health.agent.status !== "ok") problems.push(`agent status ${health.agent.status}`);
else {
if (!health.agent.openrouter_configured) problems.push("the agent does not report the OpenRouter key as configured");
const checks = health.agent.checks;
if (!checks) problems.push("the agent reports no self-checks");
else {
if (!checks.filesystem_writable) problems.push("filesystem not writable");
if (!checks.network_reachable) problems.push("network not reachable");
if (!checks.browser_installed) problems.push("browser layer not installed");
}
}
if (problems.length > 0) {
return this.#fail(dotId, "READY", new NotReadyError(dotId, problems.join("; ")));
}
try {
agentState = await guest.state();
} catch (error) {
return this.#fail(dotId, "READY (agent state)", error);
}
// A key or a config stored while this procedure pushed was refused by
// syncGuest (the Dot was not READY yet): push again, then decide.
// The comparison and the add below run without an await between them.
if (this.#generationOf(dotId) === generation) break;
this.#log.info("key or config changed during READY, pushing again", { dotId });
}
this.#ready.add(dotId);
await this.#db.computers.touch(dotId, this.#clock.now());
// The status comes from the state snapshot before the pump starts, so a
// pumped agent.state, which is newer than the snapshot, always wins.
await this.#setDotStatus(dotId, dotStatusForAgent(agentState.state));
this.#startPump(dotId);
this.#log.info("dot ready", { dotId, agent: agentState.state });
this.#onReady(dotId);
this.#onWorkPossible();
}
/**
* Take the Dot out of READY before anything that ends its computer's run:
* from here on every delivery and every push goes through the per-Dot
* lock, which the caller holds. A push already on its way is waited for.
* Returns whether the Dot was READY.
*/
async #leaveReady(dotId: string): Promise<boolean> {
const wasReady = this.#ready.delete(dotId);
await this.#pushes.run(dotId, async () => {});
return wasReady;
}
/**
* Whether an idle sleep may still go ahead, read again under the per-Dot
* lock: the Dot is READY with an IDLE agent and has no work. Work that
* arrived after the idle check (a claimed task, a message, an approval)
* is pending in the database by now, also when its send is in flight.
*/
async #stillIdle(dotId: string): Promise<boolean> {
const [dot, computer] = await Promise.all([this.#db.dots.get(dotId), this.#db.computers.get(dotId)]);
if (dot?.status !== "READY" || computer?.state !== "RUNNING") return false;
return !(await this.keepsAwake(dotId));
}
/**
* Whether the Dot has something that makes an idle sleep pointless (section 9.5): work (`tasks.hasWork`), or an
* automation due within the wake lead time, past ones included (the guest makes a missed run when it starts, and a
* computer put to sleep now would be started again at once).
*/
async keepsAwake(dotId: string): Promise<boolean> {
const now = this.#clock.now();
if (await this.#db.tasks.hasWork(dotId, now)) return true;
const dueAt = (await this.#db.computers.get(dotId))?.next_automation_at;
return dueAt != null && Date.parse(dueAt) <= now.getTime() + this.#opts.automationWakeLeadMs;
}
/**
* The Dots whose computer is stopped and has an automation due within the wake lead time: the ones to start so
* their guest is ready when the run is due (or, for a run already missed, makes it as it starts).
*/
stoppedDotsWithAutomationDue(): Promise<string[]> {
return this.#db.computers.stoppedWithAutomationBy(new Date(this.#clock.now().getTime() + this.#opts.automationWakeLeadMs));
}
/**
* Put the Dot to sleep, or stop it on the user's request (section 9.5). The reason is recorded with the computer: a
* computer the person stopped is not started for its automations until the person starts it again, also when it was
* already asleep.
*/
stop(dotId: string, reason: StopRequest): Promise<void> {
return this.#mutex.run(dotId, () => this.#stopLocked(dotId, reason, true));
}
/**
* The stop, with the per-Dot lock held. An idle stop that is asked for now checks again that the Dot is idle
* (`recheckIdle`); one that recovery finds half done does not: the shutdown began before the control plane went
* down, the guest may already have taken prepare-sleep, and calling it off would leave a computer that runs without
* being READY. It is finished, and the pass wakes the computer if the work that came meanwhile needs it.
*/
async #stopLocked(dotId: string, reason: StopRequest, recheckIdle: boolean): Promise<void> {
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (computer.state === "STOPPED") {
// The person's stop of a computer that is asleep: it is not started for its automations from now on, which every
// view of the computer (its automations note) learns from the event, as it learns of any other stop.
if (reason === "user" && computer.stop_reason !== "user") {
await this.#commit(async (tx, log) => {
await log(dotId, "computer.stopped", { reason, forced: false });
await tx.computers.setState(dotId, "STOPPED", undefined, "user");
});
}
return;
}
if (computer.state === "DELETING" || computer.state === "PROVISIONING") {
throw new ControlPlaneError(409, "invalid_state", `Dot ${dotId} cannot be stopped while ${computer.state}`);
}
const wasReady = await this.#leaveReady(dotId);
if (reason === "idle" && recheckIdle && !(await this.#stillIdle(dotId))) {
if (wasReady) this.#ready.add(dotId);
this.#log.info("idle sleep called off: work arrived meanwhile", { dotId });
return;
}
this.#log.info("stopping computer", { dotId, reason });
await this.#setVmState(dotId, "STOPPING", undefined, reason);
if (wasReady) {
try {
const guest = await this.guest(dotId);
await guest.prepareSleep(this.#opts.prepareSleepTimeoutMs);
} catch (error) {
// The shutdown still happens: the guest flushes its own state on SIGTERM too.
this.#log.warn("prepare-sleep failed, shutting down anyway", { dotId, error: errorMessage(error) });
}
}
await this.#stopPump(dotId);
let result;
try {
result = await this.#driver.stop(dotId, await this.#db.computers.token(dotId));
} catch (error) {
return this.#fail(dotId, "stop", error);
}
await this.#recordStopped(dotId, reason, result.forced);
}
/**
* A reboot is a clean stop and a start of the same VM (the agent flushes
* its state first, as before a sleep), so a new runtime ISO and new
* resources apply. The port changes; the READY procedure runs against the
* new one.
*/
reboot(dotId: string): Promise<void> {
return this.#mutex.run(dotId, async () => {
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (!computerIsUp(computer.state)) {
throw new ControlPlaneError(409, COMPUTER_STOPPED, `Dot ${dotId} cannot be rebooted while ${computer.state}`);
}
const spec = await this.#spec(dotId);
if (!spec.goldenImage) {
return this.#fail(dotId, "reboot", new Error("the computer has no golden image recorded; it was never created"));
}
if (await this.#leaveReady(dotId)) {
try {
await (await this.guest(dotId)).prepareSleep(this.#opts.prepareSleepTimeoutMs);
} catch (error) {
this.#log.warn("prepare-sleep before reboot failed, rebooting anyway", { dotId, error: errorMessage(error) });
}
}
await this.#stopPump(dotId);
await this.#setVmState(dotId, "STARTING");
let started;
try {
started = await this.#driver.reboot({ ...spec, goldenImage: spec.goldenImage });
} catch (error) {
await this.#db.computers.setProcess(dotId, null).catch(() => {});
return this.#fail(dotId, "reboot", error);
}
await this.#recordStarted(dotId, started, spec.goldenImage, { reboot: true });
await this.#readyProcedure(dotId);
});
}
/** Destroy the VM and its disk, then remove the Dot's rows (the event log keeps its history). */
remove(dotId: string): Promise<void> {
return this.#mutex.run(dotId, async () => {
const dot = await this.#db.dots.get(dotId);
if (!dot) return;
await this.#setVmState(dotId, "DELETING");
await this.#leaveReady(dotId);
await this.#stopPump(dotId);
try {
await this.#driver.destroy(dotId);
} catch (error) {
return this.#fail(dotId, "delete", error);
}
await this.#commit(async (tx, log) => {
await log(dotId, "dot.deleted", { name: dot.name });
await tx.dots.delete(dotId);
});
this.#log.info("dot deleted", { dotId, name: dot.name });
});
}
/**
* The key or the config the guest must hold changed (a PATCH, a new key,
* an agent that restarted and lost its key): push both to a READY guest
* now. A Dot that is not READY gets them from its READY procedure, which
* sees the change through the generation and pushes again if it was
* already past its own push. True when this call pushed. A push that fails
* may mean the guest is gone, so the Dot is suspect (`markSuspect`): its next
* delivery checks READY again, which pushes both again.
*/
async syncGuest(dotId: string): Promise<boolean> {
this.#generation.set(dotId, this.#generationOf(dotId) + 1);
if (!this.#ready.has(dotId)) return false;
return this.#pushes.run(dotId, async () => {
if (!this.#ready.has(dotId)) return false;
try {
await this.#push(dotId, await this.guest(dotId));
} catch (error) {
this.markSuspect(dotId);
throw error;
}
return true;
});
}
readyDots(): string[] {
return [...this.#ready];
}
/** A failed call means the guest may be gone: the next delivery re-checks READY instead of trusting it. */
markSuspect(dotId: string): void {
this.#ready.delete(dotId);
}
/** Whether work this class started in the background is still running. */
get busy(): boolean {
return this.#background.busy;
}
/** Wait for the work this class started in the background (tests and shutdown). */
async settle(): Promise<void> {
await this.#background.settle();
}
#startPump(dotId: string): void {
if (this.#pumps.has(dotId) || this.#closed) return;
const controller = new AbortController();
this.#pumps.set(dotId, controller);
const done = this.#pump(dotId, controller.signal).finally(() => {
if (this.#pumps.get(dotId) === controller) this.#pumps.delete(dotId);
if (this.#pumpDone.get(dotId) === done) this.#pumpDone.delete(dotId);
});
this.#pumpDone.set(dotId, done);
}
async #stopPump(dotId: string): Promise<void> {
this.#pumps.get(dotId)?.abort();
await this.#pumpDone.get(dotId);
}
/**
* Consume the guest's outbound stream from the stored cursor. Every event
* is stored, applied and the cursor advanced in ONE transaction, so a crash
* between two events loses nothing and applies nothing twice. On a failure
* the loop starts over from the cursor in the database, unless the VM's
* QEMU is gone: then the pump ends and the computer is recorded as stopped.
*/
async #pump(dotId: string, signal: AbortSignal): Promise<void> {
let delay = this.#opts.pumpRetryMs;
while (!signal.aborted) {
try {
const computer = await this.#db.computers.get(dotId);
if (!computer) return;
const guest = await this.guest(dotId);
this.#log.debug("event pump connecting", { dotId, after: computer.event_cursor });
for await (const event of guest.events({ after: computer.event_cursor, signal, onRefused: (info) => this.#refused(dotId, info) })) {
if (signal.aborted) return;
await this.handleGuestEvent(dotId, event);
delay = this.#opts.pumpRetryMs;
}
if (signal.aborted) return;
this.#log.warn("guest event stream ended", { dotId });
} catch (error) {
if (signal.aborted) return;
this.#log.warn("event pump failed, retrying", { dotId, error: errorMessage(error), retryMs: delay });
}
if (await this.#qemuGone(dotId)) {
this.#background.run("recording a VM that stopped by itself", dotId, () => this.#exited(dotId));
return;
}
await abortableSleep(delay, signal);
delay = Math.min(delay * 2, this.#opts.pumpMaxRetryMs);
}
}
async #qemuGone(dotId: string): Promise<boolean> {
try {
return (await this.#driver.state(dotId)).state === "STOPPED";
} catch {
return false;
}
}
/**
* The QEMU of a READY Dot exited without a stop from the control plane: the
* guest powered itself off, or QEMU crashed or was killed. The computer is
* recorded as stopped; when the Dot still has work (`keepsAwake`: a task, or
* an automation due within the wake lead time), it is started again so its
* guest picks that work up (section 9.5).
*/
async #exited(dotId: string): Promise<void> {
const recorded = await this.#mutex.run(dotId, async () => {
const computer = await this.#db.computers.get(dotId);
// A stop, reboot or start under the lock already dealt with it.
if (computer?.state !== "RUNNING" || !(await this.#qemuGone(dotId))) return false;
this.#ready.delete(dotId);
await this.#stopPump(dotId);
this.#log.warn("the VM stopped without being asked to", { dotId });
await this.#recordStopped(dotId, "exited", false);
return true;
});
if (recorded && (await this.keepsAwake(dotId))) {
this.#log.info("the Dot still has work, starting its computer again", { dotId });
this.#background.run("restart after an unexpected stop", dotId, () => this.ensureReady(dotId));
}
}
/**
* The guest sent a message the host's schema refused. It is not stored as the guest's event (it is not one), but it is
* not lost with a log line either: a `guest.event.refused` host event says that it happened and what was wrong, and
* the cursor moves past it in the same transaction, so a reconnect does not read it again. An engine and a host that
* disagree on an event would otherwise leave the Dot with a state nobody can see (an approval that is never shown).
*/
async #refused(dotId: string, info: RefusedEvent): Promise<void> {
const now = this.#clock.now();
const stored = await this.#db.transaction(async (tx) => {
const logged = await this.#events.appendHostIn(tx, dotId, "guest.event.refused", info);
if (info.seq !== null) await tx.computers.advanceCursor(dotId, info.seq, now);
return logged;
});
this.#events.publish(stored);
}
/** Store and apply one guest event; exported for recovery tools and tests. */
async handleGuestEvent(dotId: string, event: OutboundEvent): Promise<StoredEvent | null> {
const now = this.#clock.now();
const { stored, applied, logged } = await this.#db.transaction(async (tx) => {
// The event first: its insert takes the event-order lock (events.ts).
const stored = await this.#events.appendGuest(tx, dotId, event);
await tx.computers.advanceCursor(dotId, event.seq, now);
const applied: AppliedGuestEvent = stored ? await applyGuestEvent(tx, dotId, event) : { taskSettled: false };
const logged: StoredEvent[] = [];
for (const host of applied.hostEvents ?? []) logged.push(await this.#events.appendHostIn(tx, dotId, host.type, host.data));
return { stored, applied, logged };
});
if (stored) this.#events.publish(stored);
for (const event of logged) this.#events.publish(event);
if (applied.taskSettled) this.#onWorkPossible();
if (applied.reconfigured) {
// The Dot added an MCP server to its config: the guest gets the config (and the secrets it names) now.
this.#background.run("pushing the config with the MCP server the Dot added", dotId, () => this.syncGuest(dotId));
}
if (stored && event.type === "agent.started") {
// The agent process restarted inside a running VM: it lost the key it keeps in memory.
this.#background.run("pushing the key again after an agent restart", dotId, () => this.syncGuest(dotId));
}
return stored;
}
/**
* After a control plane restart: bring the database in line with the QEMU
* processes that are actually running (pid files and process liveness,
* never the rows),
* finish interrupted operations, and run the READY procedure for every
* computer that is still running. Returns once each Dot's recovery has
* started; the slow parts continue in the background.
*/
async recover(): Promise<Promise<void>[]> {
const background: Promise<void>[] = [];
const track = (dotId: string, what: string, work: Promise<void>) =>
background.push(work.catch((error) => this.#log.error(`recovery: ${what} failed`, { dotId, error: errorMessage(error) })));
for (const computer of await this.#db.computers.list()) {
const dotId = computer.dot_id;
if (computer.state === "PROVISIONING") {
this.#log.info("recovery: resuming provisioning", { dotId });
track(dotId, "provisioning", this.provision(dotId));
continue;
}
if (computer.state === "DELETING") {
this.#log.info("recovery: resuming deletion", { dotId });
track(dotId, "deletion", this.remove(dotId));
continue;
}
let vm;
try {
vm = await this.#driver.state(dotId);
} catch (error) {
this.#log.error("recovery: cannot read the VM state", { dotId, error: errorMessage(error) });
continue;
}
if (!vm.exists) {
await this.#db.computers.setProcess(dotId, null);
await this.#recordError(dotId, "the VM disk is missing");
continue;
}
if (vm.state === "RUNNING") {
if (computer.state === "STOPPING") {
this.#log.info("recovery: finishing an interrupted stop", { dotId });
// The reason the stop was asked for was recorded with STOPPING; setting RUNNING clears it.
await this.#db.computers.setState(dotId, "RUNNING");
const reason = computer.stop_reason === "idle" ? "idle" : "user";
track(dotId, "stop", this.#mutex.run(dotId, () => this.#stopLocked(dotId, reason, false)));
} else if (vm.guestPort === null) {
await this.#recordError(dotId, "QEMU runs but has no forward to the guest port, so the guest cannot be reached");
} else {
// The port comes from the pid file, so a row that missed it (the
// control plane stopped between the spawn and the update) is
// corrected here; the READY procedure then checks the guest.
const running = { ...vm, guestPort: vm.guestPort };
this.#log.info("recovery: reattaching to a running computer", { dotId, was: computer.state, pid: vm.pid });
track(dotId, "reattach", this.#mutex.run(dotId, () => this.#adoptRunning(dotId, running)));
}
} else if (vm.state === "STOPPED") {
if (computer.state !== "STOPPED") {
this.#log.info("recovery: computer is off", { dotId, was: computer.state });
// A stop that was under way keeps its reason; a VM that went off with the control plane down exited by itself.
// Either way the stop is told now: the control plane went down before it could.
await this.#recordStopped(dotId, computer.stop_reason ?? "exited", false, true);
} else if (computer.guest_port !== null || computer.pid !== null) {
await this.#db.computers.setProcess(dotId, null);
}
} else {
await this.#recordError(dotId, `the VM is ${vm.state}${vm.detail ? `: ${vm.detail}` : ""}`);
}
}
return background;
}
/** Stop every pump and release the driver's host resources. VMs keep running. */
async close(): Promise<void> {
this.#closed = true;
this.#background.close();
for (const controller of this.#pumps.values()) controller.abort();
await Promise.all(this.#pumpDone.values());
await this.settle();
await this.#driver.close();
}
}
+922
View File
@@ -0,0 +1,922 @@
/**
* The control plane's operations, one method per thing the API can ask for.
* The API is a thin HTTP layer over this class; everything that touches the
* database, the VM layer or a guest happens here.
*/
import { randomBytes } from "node:crypto";
import { DotChangedError, DotNameTakenError, GLOBAL_SCOPE, mcpSecretName, OPENROUTER_KEY_NAME, VM_PROXY_NAME, type Database } from "@invisible-dots/database";
import { EventLog, USER_MESSAGE_EVENT } from "@invisible-dots/events";
import type {
AcceptedAnswer,
ApprovalRecord,
ComputerAnswer,
ConversationMessage,
CreateTaskRequest,
DotRecord,
DotSummary,
MessageAnswer,
TaskRecord,
UsageAnswer,
} from "@invisible-dots/shared";
import {
APPROVAL_NOTE_MAX,
checkHomePath,
checkMcpSecret,
checkOpenRouterKey,
COMPUTER_STOPPED,
ControlPlaneError,
computerIsUp,
computerResources,
CONVERSATION_LIST_LIMIT,
DotConfigError,
errorMessage,
isIdentityAnswer,
isStoredEventType,
MAX_HOST_FILE_BYTES,
newId,
notFound,
parseDotConfig,
parseMessageOrigin,
mcpServerNames,
parseSize,
permissionNames,
silentLogger,
systemClock,
TASK_CANCELLED_SYSTEM_EVENT,
TERMINAL_TASK_STATES,
vmName,
type ApprovalStatus,
type BrowserIdentity,
type Clock,
type CreateBrowserIdentityRequest,
type DotConfig,
type FilesListAnswer,
type InboundEvent,
type ListOrder,
type Logger,
type McpSecretsAnswer,
type MessageOrigin,
type StoredEvent,
type SystemAnswer,
type Skill,
type TaskState,
type ToolListAnswer,
} from "@invisible-dots/shared";
import { Dispatcher } from "./dispatcher.js";
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type GuestApi } from "./driver.js";
import { InboundDelivery, type InboundDeliveryOptions } from "./inbound.js";
import { Lifecycle, MISSING_KEY_MESSAGE, type LifecycleOptions } from "./lifecycle.js";
import { BackgroundWork } from "./support.js";
export interface SchedulerOptions {
db: Database;
driver: ComputerDriver;
events?: EventLog;
clock?: Clock;
logger?: Logger;
/** How often PENDING tasks are looked for besides the immediate pass on every change. Default 5 s. */
dispatchIntervalMs?: number;
/** How often idle Dots are looked for. Default 30 s. */
idleCheckIntervalMs?: number;
lifecycle?: Partial<LifecycleOptions>;
/** How inbound events (tasks, messages, approvals) are retried, and when a task's delivery gives up. */
dispatcher?: Partial<InboundDeliveryOptions>;
}
const TOKEN_BYTES = 32;
/** A path the files routes may read, normalized; the 400 of the rule of `checkHomePath` otherwise. */
function homePath(raw: unknown): string {
const checked = checkHomePath(raw);
if (!checked.ok) throw new ControlPlaneError(400, "invalid_path", checked.problem);
return checked.path;
}
export class Scheduler {
readonly db: Database;
readonly events: EventLog;
readonly lifecycle: Lifecycle;
readonly dispatcher: Dispatcher;
/** Sends what is stored for the guests in `inbound_events`, waking Dots that sleep. */
readonly inbound: InboundDelivery;
readonly #clock: Clock;
readonly #log: Logger;
readonly #dispatchIntervalMs: number;
readonly #idleCheckIntervalMs: number;
readonly #background: BackgroundWork;
readonly #provisioning = new Map<string, Promise<void>>();
readonly #timers: NodeJS.Timeout[] = [];
#started = false;
constructor(options: SchedulerOptions) {
this.db = options.db;
this.#clock = options.clock ?? systemClock;
this.#log = options.logger ?? silentLogger;
this.#background = new BackgroundWork(this.#log);
this.events =
options.events ?? new EventLog(options.db.events, (line) => this.#log.warn(line));
this.#dispatchIntervalMs = options.dispatchIntervalMs ?? 5_000;
this.#idleCheckIntervalMs = options.idleCheckIntervalMs ?? 30_000;
this.lifecycle = new Lifecycle({
db: options.db,
events: this.events,
driver: options.driver,
clock: this.#clock,
logger: this.#log,
options: options.lifecycle,
onWorkPossible: () => void this.dispatcher.dispatch(),
onReady: (dotId) => void this.inbound.kick(dotId),
});
this.inbound = new InboundDelivery({
db: options.db,
events: this.events,
lifecycle: this.lifecycle,
clock: this.#clock,
logger: this.#log,
provisioned: async (dotId) => {
await this.#provisioning.get(dotId)?.catch(() => {});
},
onTaskSettled: () => void this.dispatcher.dispatch(),
options: options.dispatcher,
});
this.dispatcher = new Dispatcher(options.db, this.#clock, this.#log, (dotId) => void this.inbound.kick(dotId));
}
/**
* Recover from the previous run, then start the timers. Nothing is
* requeued: a task claimed before the restart still has its task.created
* in the outbox, and the guest ignores it if an earlier send reached it.
*/
async start(): Promise<void> {
if (this.#started) return;
this.#started = true;
for (const work of await this.lifecycle.recover()) this.#background.track(work);
this.#background.track(this.pass());
const every = (ms: number, fn: () => Promise<unknown>) => {
const timer = setInterval(() => void fn().catch((e) => this.#log.error("timer failed", { error: errorMessage(e) })), ms);
timer.unref();
this.#timers.push(timer);
};
every(this.#dispatchIntervalMs, () => this.pass());
every(this.#idleCheckIntervalMs, () => this.idleCheck());
}
/**
* One round of looking for work (section 9.5): claim due tasks, send the
* inbound rows whose retry time came, wake stopped Dots whose new work
* waits behind a task their guest has not finished, and wake the ones whose
* next automation is due within the wake lead time.
*/
async pass(): Promise<void> {
await this.dispatcher.dispatch();
await this.inbound.kickDue();
for (const dotId of await this.db.tasks.stoppedDotsWithBlockedWork()) {
if (this.lifecycle.isBusy(dotId)) continue;
this.#log.info("waking a stopped Dot: new work waits behind its unfinished task", { dotId });
this.#background.run("wake for blocked work", dotId, () => this.lifecycle.ensureReady(dotId));
}
for (const dotId of await this.lifecycle.stoppedDotsWithAutomationDue()) {
if (this.lifecycle.isBusy(dotId)) continue;
this.#log.info("waking a stopped Dot: an automation is due", { dotId });
this.#background.run("wake for an automation", dotId, () => this.lifecycle.wakeForAutomation(dotId));
}
}
/** Stop the timers and the event pumps and wait for in-flight work. VMs keep running. */
async close(): Promise<void> {
for (const timer of this.#timers.splice(0)) clearInterval(timer);
this.inbound.close();
await this.settle().catch(() => {});
await this.lifecycle.close();
}
/** Wait until every background operation started so far has finished (used by tests and shutdown). */
async settle(): Promise<void> {
for (;;) {
await this.dispatcher.settle();
await this.inbound.settle();
await this.lifecycle.settle();
if (!this.#background.busy) {
// Each of the three may have started work in another while it settled.
if (!this.dispatcher.busy && !this.inbound.busy && !this.lifecycle.busy) return;
continue;
}
await this.#background.settle();
}
}
// Dots
async requireDot(idOrName: string): Promise<DotSummary> {
const dot = await this.db.dots.resolve(idOrName);
if (!dot) throw notFound("Dot", idOrName);
return dot;
}
listDots(): Promise<DotSummary[]> {
return this.db.dots.list();
}
#parseConfig(input: unknown): DotConfig {
if (input === undefined || input === null) {
throw new ControlPlaneError(400, "invalid_request", "the body needs a config: YAML text or an object");
}
try {
return parseDotConfig(input);
} catch (error) {
if (error instanceof DotConfigError) throw new ControlPlaneError(400, "invalid_config", error.message, error.issues);
throw error;
}
}
/**
* Section 9.4: insert the Dot as CREATING with its computer row and a new
* token in one transaction, then provision it in the background (overlay,
* seed, port, QEMU, READY).
*/
async createDot(configInput: unknown): Promise<DotRecord> {
const config = this.#parseConfig(configInput);
const id = newId("dot");
const token = randomBytes(TOKEN_BYTES).toString("base64url");
let dot: DotRecord;
try {
const created = await this.db.transaction(async (tx) => {
// The events first: their insert takes the event-order lock (database events.ts). They commit with the rows.
const logged = [
await this.events.appendHostIn(tx, id, "dot.created", { name: config.name }),
await this.events.appendHostIn(tx, id, "computer.state", { state: "PROVISIONING" }),
];
const record = await tx.dots.insert({ id, config, status: "CREATING" });
await tx.computers.insert({ dotId: id, vmName: vmName(id), state: "PROVISIONING", token });
return { record, logged };
});
dot = created.record;
for (const event of created.logged) this.events.publish(event);
} catch (error) {
if (error instanceof DotNameTakenError) throw new ControlPlaneError(409, "name_taken", error.message);
throw error;
}
this.#log.info("dot created", { dotId: id, name: config.name });
const provisioning = this.lifecycle.provision(id);
this.#provisioning.set(id, provisioning);
this.#background.run("provisioning", id, () =>
provisioning.finally(() => {
this.#provisioning.delete(id);
}),
);
return dot;
}
/**
* Replace the Dot's config. `expectedConfigVersion` (the `config_version` of the Dot as the caller read it) makes the
* save conditional: when the config changed since (the person answered "Always allow" in another view, another
* save), it is a 409 `dot_changed` and nothing is written, so a form opened before cannot silently undo what
* happened after. A change of the Dot's status (a task turn, an approval waiting) is not a change of the config.
*/
async updateDot(idOrName: string, configInput: unknown, expectedConfigVersion?: unknown): Promise<DotRecord> {
if (expectedConfigVersion !== undefined && (typeof expectedConfigVersion !== "number" || !Number.isInteger(expectedConfigVersion) || expectedConfigVersion < 1)) {
throw new ControlPlaneError(400, "invalid_request", "expected_config_version must be the config_version of the Dot, a positive integer");
}
const current = await this.requireDot(idOrName);
const config = this.#parseConfig(configInput);
if (parseSize(config.computer.disk) < parseSize(current.config.computer.disk)) {
throw new ControlPlaneError(
409,
"disk_shrink",
`computer.disk cannot shrink from ${current.config.computer.disk} to ${config.computer.disk}: the filesystem on it would be destroyed`,
);
}
let saved: { updated: DotRecord; logged: StoredEvent };
try {
saved = await this.db.transaction(async (tx) => {
// The event first (database events.ts); it commits with the config it tells of, or neither does.
const logged = await this.events.appendHostIn(tx, current.id, "dot.updated", { name: config.name });
const updated = await tx.dots.updateConfig(current.id, config, expectedConfigVersion);
if (!updated) throw notFound("Dot", idOrName);
// A secret of a server the config no longer declares, or no longer names, goes with it.
await tx.secrets.deleteUndeclaredMcpSecrets(current.id, config.mcp_servers);
return { updated, logged };
});
} catch (error) {
if (error instanceof DotChangedError) {
throw new ControlPlaneError(409, "dot_changed", `Dot ${current.name} changed after you read it: read it again and apply the change to what it is now`);
}
if (error instanceof DotNameTakenError) throw new ControlPlaneError(409, "name_taken", error.message);
throw error;
}
this.events.publish(saved.logged);
await this.#pushToGuest(saved.updated);
return saved.updated;
}
/**
* The Dot's saved config changed (a PATCH, an "always allow"; `dot.updated` was logged with the change), or a secret of
* its MCP servers did: push the secrets and the config to the guest. A failed push does not fail the change: the Dot
* is suspect then (`syncGuest`), and both are pushed again on its next READY.
*/
async #pushToGuest(dot: DotRecord): Promise<void> {
try {
await this.lifecycle.syncGuest(dot.id);
} catch (error) {
this.#log.warn("saved, but the push to the guest failed; it is pushed again on the next READY", {
dotId: dot.id,
error: errorMessage(error),
});
}
}
async deleteDot(idOrName: string): Promise<AcceptedAnswer> {
const dot = await this.requireDot(idOrName);
this.#background.run("deletion", dot.id, () => this.lifecycle.remove(dot.id));
return { accepted: true };
}
// Messages
/**
* Log the message and store it for the guest in one transaction, so a
* message the person saw accepted always reaches the Dot, also across a
* failed wake or a control plane restart. `origin` names the channel chat
* the message came from; it is kept in the event log (the one place a reply
* is routed back by) and never sent to the guest. Only code inside the
* control plane passes one: the HTTP route does not take it.
*/
async sendMessage(idOrName: string, text: unknown, origin?: MessageOrigin): Promise<MessageAnswer> {
if (typeof text !== "string" || text.trim() === "") {
throw new ControlPlaneError(400, "invalid_request", "text must be a non-empty string");
}
if (origin !== undefined && parseMessageOrigin(origin) === null) {
throw new ControlPlaneError(400, "invalid_request", "origin must be a channel, a binding id, a chat id and an external id");
}
const dot = await this.requireDot(idOrName);
const messageId = newId("msg");
const event: InboundEvent<"user.message"> = {
id: messageId,
type: "user.message",
ts: this.#clock.now().toISOString(),
data: { text },
};
const stored = await this.db.transaction(async (tx) => {
// The event first: its insert takes the event-order lock (database events.ts). A channel message
// already logged is not stored again, so what a redelivery finds is exactly what the first delivery
// committed: the message and its queue row together, or neither.
const logged = await this.events.appendUserMessageIn(tx, dot.id, { message_id: messageId, text, ...(origin && { origin }) });
if (logged) await tx.inbound.enqueue(dot.id, event);
return logged;
});
if (!stored) return this.#answerRedelivery(dot.id, origin!);
this.events.publish(stored);
const delivery = await this.#deliver(dot.id, event.id);
return { message_id: messageId, event_id: stored.id, delivery };
}
/** The answer for a channel message that was already handed to the Dot: the same message, delivered if it is not yet. */
async #answerRedelivery(dotId: string, origin: MessageOrigin): Promise<MessageAnswer> {
const logged = await this.events.userMessageOfOrigin(dotId, origin.binding_id, origin.external_id);
const messageId = logged?.data.message_id;
if (!logged || typeof messageId !== "string") throw new Error(`a channel message is stored twice but cannot be found: ${origin.binding_id}`);
return { message_id: messageId, event_id: logged.id, delivery: await this.#deliver(dotId, messageId) };
}
/**
* The Dot's one conversation, oldest first; `order: "desc"` the newest first, and `before` (the event id of the
* oldest message of the previous page) pages on, older, from there. A page holds at most CONVERSATION_LIST_LIMIT.
*/
async conversation(idOrName: string, page: { limit?: number; order?: ListOrder; before?: number } = {}): Promise<ConversationMessage[]> {
const dot = await this.requireDot(idOrName);
const events = await this.events.query({
dotId: dot.id,
types: [USER_MESSAGE_EVENT, "message.assistant"],
limit: page.limit ?? CONVERSATION_LIST_LIMIT,
order: page.order,
before: page.before,
});
return events.map((e) => {
// StoredEvent.type lists the contract's event types; the user side is logged as USER_MESSAGE_EVENT.
const user = (e.type as string) === USER_MESSAGE_EVENT;
const origin = user ? parseMessageOrigin(e.data.origin) : null;
return {
event_id: e.id,
role: user ? "user" : "assistant",
text: String(e.data.text ?? ""),
in_reply_to: typeof e.data.in_reply_to === "string" ? e.data.in_reply_to : null,
...(origin && { origin }),
created_at: e.created_at,
};
});
}
/**
* Send what is stored for the Dot. A READY Dot gets it before this
* returns ("delivered"); otherwise it is sent once the Dot is woken, in the
* background ("queued"), and stays stored until then.
*/
async #deliver(dotId: string, eventId: string): Promise<"delivered" | "queued"> {
if (!this.lifecycle.isReady(dotId)) {
void this.inbound.kick(dotId);
return "queued";
}
await this.inbound.kick(dotId);
return (await this.db.inbound.get(eventId))?.delivered_at ? "delivered" : "queued";
}
// Tasks
async createTask(idOrName: string, body: Partial<CreateTaskRequest>): Promise<TaskRecord> {
const dot = await this.requireDot(idOrName);
if (typeof body.description !== "string" || body.description.trim() === "") {
throw new ControlPlaneError(400, "invalid_request", "description must be a non-empty string");
}
if (body.priority !== undefined && (!Number.isInteger(body.priority) || Math.abs(body.priority) > 1_000_000)) {
throw new ControlPlaneError(400, "invalid_request", "priority must be an integer from -1000000 to 1000000");
}
let scheduledAt: Date | null = null;
if (body.scheduled_at !== undefined && body.scheduled_at !== null) {
scheduledAt = new Date(body.scheduled_at);
if (typeof body.scheduled_at !== "string" || Number.isNaN(scheduledAt.getTime())) {
throw new ControlPlaneError(400, "invalid_request", "scheduled_at must be an ISO 8601 timestamp");
}
}
const id = newId("task");
const { description } = body;
const priority = body.priority ?? 0;
const { task, logged } = await this.db.transaction(async (tx) => {
// The event first (database events.ts); it commits with the task it tells of, or neither does.
const logged = await this.events.appendHostIn(tx, dot.id, "task.created", { task_id: id, description, priority });
const task = await tx.tasks.insert({ id, dotId: dot.id, description, priority, scheduledAt });
return { task, logged };
});
this.events.publish(logged);
void this.dispatcher.dispatch();
return task;
}
/** The Dot's tasks, the newest created first, at most TASK_LIST_LIMIT a page; `before` (the id of the last task of the previous page) goes on, older. */
async listTasks(idOrName: string, page: { limit?: number; before?: string } = {}): Promise<TaskRecord[]> {
const dot = await this.requireDot(idOrName);
return this.db.tasks.listByDot(dot.id, page);
}
/** The tasks of every Dot that ended in a terminal `status` after `after`, the latest end first: one read for all of them. */
async listEndedTasks(status: unknown, after: unknown): Promise<TaskRecord[]> {
if (!TERMINAL_TASK_STATES.includes(status as TaskState)) {
throw new ControlPlaneError(400, "invalid_request", `status must be one of ${TERMINAL_TASK_STATES.join(", ")}`);
}
const since = typeof after === "string" ? new Date(after) : new Date(Number.NaN);
if (Number.isNaN(since.getTime())) throw new ControlPlaneError(400, "invalid_request", "finished_after must be an ISO 8601 time");
return this.db.tasks.listEnded(status as TaskState, since);
}
async getTask(id: string): Promise<TaskRecord> {
const task = await this.db.tasks.get(id);
if (!task) throw notFound("task", id);
return task;
}
/**
* Cancel a task. A PENDING one simply never runs. Otherwise, in the same
* transaction: when no send of its task.created ever began, that event is
* dropped and the guest never hears of the task; when one began, the
* guest may hold the task, so `system.event { name: "task.cancelled" }`
* is stored behind it and reaches the guest even if it sleeps now (the
* contract has no dedicated inbound cancel). The previous state comes from
* the update itself, never from an earlier read.
*/
async cancelTask(id: string): Promise<TaskRecord> {
const task = await this.getTask(id);
if (TERMINAL_TASK_STATES.includes(task.status)) {
throw new ControlPlaneError(409, "task_finished", `task ${id} is already ${task.status}`);
}
const { logged, cancelled, tellGuest } = await this.db.transaction(async (tx) => {
// The event first: its insert takes the event-order lock (database events.ts).
const logged = await this.events.appendHostIn(tx, task.dot_id, "task.cancelled", { task_id: id });
const moved = await tx.tasks.transition(id, "CANCELLED", { error: "cancelled by the user" });
if (!moved) throw new ControlPlaneError(409, "task_finished", `task ${id} finished meanwhile`);
let tellGuest = false;
if (moved.previous !== "PENDING" && !(await tx.inbound.dropUnsent(id, "the task was cancelled before it was delivered"))) {
const event: InboundEvent<"system.event"> = {
id: newId("evt"),
type: "system.event",
ts: this.#clock.now().toISOString(),
data: { name: TASK_CANCELLED_SYSTEM_EVENT, data: { task_id: id } },
};
await tx.inbound.enqueue(task.dot_id, event, { taskId: id });
tellGuest = true;
}
return { logged, cancelled: moved.task, tellGuest };
});
this.events.publish(logged);
if (tellGuest) void this.inbound.kick(task.dot_id);
void this.dispatcher.dispatch();
return cancelled;
}
// Computer
async computer(idOrName: string): Promise<ComputerAnswer> {
const dot = await this.requireDot(idOrName);
const computer = await this.db.computers.get(dot.id);
if (!computer) throw notFound("computer of Dot", idOrName);
const ready = this.lifecycle.isReady(dot.id);
let system: SystemAnswer | null = null;
if (ready) {
// Live figures are a convenience of this answer: a guest that is slow or
// briefly unreachable must not turn the whole route into an error.
try {
system = await (await this.lifecycle.guest(dot.id)).system();
} catch (error) {
this.#log.debug("live system figures unavailable", { dotId: dot.id, error: errorMessage(error) });
}
}
return { ...computer, ready, system };
}
async startComputer(idOrName: string): Promise<AcceptedAnswer> {
const dot = await this.requireDot(idOrName);
if (dot.computer_state === "PROVISIONING" || dot.computer_state === "DELETING") {
throw new ControlPlaneError(409, "invalid_state", `the computer is ${dot.computer_state}`);
}
this.#background.run("start", dot.id, () => this.lifecycle.ensureReady(dot.id));
return { accepted: true };
}
async stopComputer(idOrName: string): Promise<AcceptedAnswer> {
const dot = await this.requireDot(idOrName);
if (dot.computer_state === "PROVISIONING" || dot.computer_state === "DELETING") {
throw new ControlPlaneError(409, "invalid_state", `the computer is ${dot.computer_state}`);
}
this.#background.run("stop", dot.id, () => this.lifecycle.stop(dot.id, "user"));
return { accepted: true };
}
async rebootComputer(idOrName: string): Promise<AcceptedAnswer> {
const dot = await this.requireDot(idOrName);
if (!computerIsUp(dot.computer_state)) {
throw new ControlPlaneError(409, COMPUTER_STOPPED, `the computer is ${dot.computer_state ?? "missing"}`);
}
this.#background.run("reboot", dot.id, () => this.lifecycle.reboot(dot.id));
return { accepted: true };
}
/** A guest for a Dot whose computer is running; 409 computer_stopped otherwise (section 9.6). */
async #runningGuest(idOrName: string): Promise<{ dotId: string; guest: GuestApi }> {
const dot = await this.requireDot(idOrName);
if (!computerIsUp(dot.computer_state)) {
throw new ControlPlaneError(
409,
COMPUTER_STOPPED,
`the computer of Dot ${dot.name} is ${dot.computer_state ?? "missing"}; start it first`,
);
}
return { dotId: dot.id, guest: await this.lifecycle.guest(dot.id) };
}
/**
* Run a guest call and turn its failure into an API error: the guest's own 4xx passes through, and so do the
* engine's coded identity answers that are not 4xx (`IDENTITY_ERROR_STATUS` of packages/shared: `busy` and the browser's own failures): a coded answer is the engine
* speaking, not a silence, so the UI can tell a busy or crashed browser from an unreachable computer.
*/
async #guestCall<T>(dotId: string, what: string, call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (error) {
const status = guestErrorStatus(error);
const code = guestErrorCode(error);
if ((status >= 400 && status < 500 && status !== 401) || isIdentityAnswer(code, status)) {
throw new ControlPlaneError(status, code ?? "guest_error", errorMessage(error));
}
this.#log.warn("guest call failed", { dotId, what, error: errorMessage(error) });
throw new ControlPlaneError(502, "guest_unavailable", `${what}: the Dot's computer did not answer: ${errorMessage(error)}`);
}
}
async screenshot(idOrName: string): Promise<Uint8Array> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return this.#guestCall(dotId, "screenshot", () => guest.screenshot());
}
/** The files of a directory under /home/dot, as dot-agentd lists them; `path` defaults to home itself. */
async listFiles(idOrName: string, path: string = "~"): Promise<FilesListAnswer> {
const checked = homePath(path);
const { dotId, guest } = await this.#runningGuest(idOrName);
const { entries } = await this.#guestCall(dotId, "list files", () => guest.listFiles(checked));
return { path: checked, entries };
}
/** The bytes of a file under /home/dot; a file larger than MAX_HOST_FILE_BYTES is a 413. */
async readFile(idOrName: string, path: unknown): Promise<{ path: string; content: Uint8Array }> {
const checked = homePath(path);
const { dotId, guest } = await this.#runningGuest(idOrName);
const content = await this.#guestCall(dotId, "read a file", () => guest.readFile(checked, { maxBytes: MAX_HOST_FILE_BYTES }));
return { path: checked, content };
}
async listIdentities(idOrName: string): Promise<BrowserIdentity[]> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return (await this.#guestCall(dotId, "list browser identities", () => guest.listBrowserIdentities())).identities;
}
async createIdentity(idOrName: string, body: Partial<CreateBrowserIdentityRequest>): Promise<BrowserIdentity> {
if (typeof body.name !== "string" || body.name.trim() === "") {
throw new ControlPlaneError(400, "invalid_request", "name must be a non-empty string");
}
if (body.proxy != null && typeof body.proxy !== "string") {
throw new ControlPlaneError(400, "invalid_request", "proxy must be a string");
}
const { dotId, guest } = await this.#runningGuest(idOrName);
const request: CreateBrowserIdentityRequest = { name: body.name, ...(body.proxy ? { proxy: body.proxy } : {}) };
return this.#guestCall(dotId, "create a browser identity", () => guest.createBrowserIdentity(request));
}
async getIdentity(idOrName: string, identityId: string): Promise<BrowserIdentity> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return this.#guestCall(dotId, "get a browser identity", () => guest.getBrowserIdentity(identityId));
}
async deleteIdentity(idOrName: string, identityId: string): Promise<void> {
const { dotId, guest } = await this.#runningGuest(idOrName);
await this.#guestCall(dotId, "delete a browser identity", () => guest.deleteBrowserIdentity(identityId));
}
/** The JPEG of an open identity's window: 409 `not_open` when it is closed, 503 `busy` while a call holds it. */
async identityFrame(idOrName: string, identityId: string): Promise<Uint8Array> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return this.#guestCall(dotId, "get the frame of a browser identity", () => guest.getBrowserIdentityFrame(identityId));
}
async closeIdentity(idOrName: string, identityId: string): Promise<void> {
const { dotId, guest } = await this.#runningGuest(idOrName);
await this.#guestCall(dotId, "close a browser identity", () => guest.closeBrowserIdentity(identityId));
}
// The tools and the skills: the Dot's engine keeps both, read through the computer
/**
* The Dot's tools, each with the permission it exercises and whether the model is offered it now, and the MCP servers
* its config declares with where each is (connecting, connected, failed and why).
*/
async listTools(idOrName: string): Promise<ToolListAnswer> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return this.#guestCall(dotId, "list tools", () => guest.listTools());
}
/** The Dot's skills, the built-in ones and its own, each with its whole file. */
async listSkills(idOrName: string): Promise<Skill[]> {
const { dotId, guest } = await this.#runningGuest(idOrName);
return (await this.#guestCall(dotId, "list skills", () => guest.listSkills())).skills;
}
// Approvals
/** The approvals of one status or several (every one when omitted); `page` is what the approvals repository's `list` takes. */
async listApprovals(
status?: ApprovalStatus | readonly ApprovalStatus[],
page: { limit?: number; order?: ListOrder; before?: string; dot?: string } = {},
): Promise<ApprovalRecord[]> {
const { dot, ...rest } = page;
// Of one Dot (by id or name; the history of a deleted Dot is read by id, as the event log's is), in the database.
return this.db.approvals.list({ status, ...rest, ...(dot === undefined ? {} : { dotId: await this.historyDotId(dot) }) });
}
/**
* Record the decision, move the task back to RUNNING and store the
* `approval.received` for the guest, in one transaction: a decision the
* person saw accepted always reaches the guest, also when the Dot sleeps
* and its wake fails or the control plane restarts first.
*
* `always` (an approval only) is "allow this from now on": in the same
* transaction `permissions[<the approval's permission>]` becomes `allow` in
* the Dot's config, and the config is pushed to the guest before the answer
* is delivered, so what the approved call does next is not asked again: the
* Dot's deliveries are held (`InboundDelivery.hold`) from before the commit
* until the push is over, so no flush overtakes it. A push that fails does
* not fail the answer; the guest gets the config on its next READY. An
* answer that loses the race for the approval (409) changes nothing.
*/
async resolveApproval(
id: string,
decision: "approve" | "reject",
answer: { note?: unknown; always?: unknown } = {},
): Promise<ApprovalRecord> {
if (answer.note !== undefined && typeof answer.note !== "string") {
throw new ControlPlaneError(400, "invalid_request", "note must be a string");
}
if (answer.always !== undefined && answer.always !== true) {
throw new ControlPlaneError(400, "invalid_request", "always must be true");
}
const note = answer.note as string | undefined;
const always = answer.always as true | undefined;
if (note !== undefined && note.length > APPROVAL_NOTE_MAX) {
throw new ControlPlaneError(400, "invalid_request", `note must be at most ${APPROVAL_NOTE_MAX} characters`);
}
if (always && decision !== "approve") {
throw new ControlPlaneError(400, "invalid_request", "always applies to an approval, not to a rejection");
}
const existing = await this.db.approvals.get(id);
if (!existing) throw notFound("approval", id);
const event: InboundEvent<"approval.received"> = {
id: newId("evt"),
type: "approval.received",
ts: this.#clock.now().toISOString(),
data: { approval_id: id, decision, ...(note !== undefined ? { note } : {}) },
};
// From its commit on, any flush may send the answer: the Dot's deliveries wait until the config that "always"
// changed has reached the guest, so the approved call's next use of the permission is not asked again.
const release = always ? this.inbound.hold(existing.dot_id) : undefined;
let resolved: ApprovalRecord;
try {
const stored = await this.db.transaction(async (tx) => {
// The event first: its insert takes the event-order lock (database events.ts).
const logged = await this.events.appendHostIn(tx, existing.dot_id, "approval.resolved", {
approval_id: id,
decision,
...(existing.task_id !== null ? { task_id: existing.task_id } : {}),
...(note !== undefined ? { note } : {}),
...(always ? { always } : {}),
});
// "Always allow" changes the Dot's config: that is told by the event of every config change, in the same commit.
const dot = always ? await tx.dots.get(existing.dot_id) : null;
if (always && !dot) throw notFound("Dot", existing.dot_id);
// The permission of an MCP server the config no longer declares has nothing left to allow.
if (dot && !permissionNames(dot.config).includes(existing.permission as never)) {
throw new ControlPlaneError(409, "permission_gone", `the Dot's config no longer has the permission ${existing.permission}, so it cannot be allowed always`);
}
const updated = dot ? await this.events.appendHostIn(tx, existing.dot_id, "dot.updated", { name: dot.name }) : null;
const resolved = await tx.approvals.resolve(id, decision === "approve" ? "approved" : "rejected", note ?? null);
if (!resolved) {
const current = await tx.approvals.get(id);
throw new ControlPlaneError(409, "already_resolved", `approval ${id} is already ${current?.status ?? existing.status}`);
}
const reconfigured = always ? await tx.dots.setPermission(resolved.dot_id, resolved.permission, "allow") : null;
if (always && !reconfigured) throw notFound("Dot", resolved.dot_id);
if (resolved.task_id) await tx.tasks.transition(resolved.task_id, "RUNNING", { dotId: resolved.dot_id });
await tx.inbound.enqueue(resolved.dot_id, event);
return { logged: [logged, ...(updated ? [updated] : [])], resolved, reconfigured };
});
resolved = stored.resolved;
for (const logged of stored.logged) this.events.publish(logged);
if (stored.reconfigured) await this.#pushToGuest(stored.reconfigured);
} finally {
release?.();
}
await this.#deliver(resolved.dot_id, event.id);
return resolved;
}
// Events and secrets
/** The id of a Dot whose history is read: a deleted Dot's history stays readable by id. */
async historyDotId(idOrName: string): Promise<string> {
const dot = await this.db.dots.resolve(idOrName);
const dotId = dot?.id ?? (idOrName.includes("_") ? idOrName : undefined);
if (!dotId) throw notFound("Dot", idOrName);
return dotId;
}
/**
* The Dot's events, oldest first: those after an id, of these types (an unknown type name is a 400, so a typo
* does not look like a quiet Dot) and of this task.
*/
async listEvents(
idOrName: string,
filter: { after?: number; before?: number; limit?: number; types?: readonly string[]; tools?: readonly string[]; taskId?: string; order?: ListOrder } = {},
): Promise<StoredEvent[]> {
const unknown = filter.types?.filter((type) => !isStoredEventType(type)) ?? [];
if (unknown.length > 0) throw new ControlPlaneError(400, "invalid_request", `unknown event type: ${unknown.join(", ")}`);
if (filter.taskId === "") throw new ControlPlaneError(400, "invalid_request", "task_id must not be empty");
const types = filter.types === undefined || filter.types.length === 0 ? undefined : filter.types;
const tools = filter.tools === undefined || filter.tools.length === 0 ? undefined : filter.tools;
return this.events.query({ dotId: await this.historyDotId(idOrName), after: filter.after, before: filter.before, limit: filter.limit, types, tools, taskId: filter.taskId, order: filter.order });
}
/** The model spend the Dot's guest reported since `since` (every event when omitted), from the event log. */
async usage(idOrName: string, since?: Date): Promise<UsageAnswer> {
const dotId = await this.historyDotId(idOrName);
return { dot_id: dotId, since: since?.toISOString() ?? null, spent_usd: await this.db.events.spentUsd(dotId, since) };
}
/**
* Set or clear a Dot's VM proxy (`socks5://[user:password@]host:port`; null or blank clears it). The VM uses it from
* its next start: the seed is written at every start. The answer never carries the value.
*/
async setVmProxy(idOrName: string, value: unknown): Promise<{ dot_id: string; proxy: boolean }> {
const dot = await this.requireDot(idOrName);
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
await this.db.secrets.delete(dot.id, VM_PROXY_NAME);
return { dot_id: dot.id, proxy: false };
}
if (typeof value !== "string" || !/^socks5:\/\/\S+$/.test(value.trim())) {
throw new ControlPlaneError(400, "invalid_request", "proxy must be a socks5:// URL");
}
await this.db.secrets.put(dot.id, VM_PROXY_NAME, value.trim());
return { dot_id: dot.id, proxy: true };
}
/** Whether a Dot has a VM proxy; never its value. */
async vmProxy(idOrName: string): Promise<{ dot_id: string; proxy: boolean }> {
const dot = await this.requireDot(idOrName);
return { dot_id: dot.id, proxy: (await this.db.secrets.get(dot.id, VM_PROXY_NAME)) !== null };
}
/** Every secret the Dot's MCP servers name, and whether each is set; never a value. */
async mcpSecrets(idOrName: string): Promise<McpSecretsAnswer> {
const dot = await this.requireDot(idOrName);
const set = await this.db.secrets.mcpSecrets(dot.id, dot.config.mcp_servers);
const secrets = mcpServerNames(dot.config).flatMap((server) =>
dot.config.mcp_servers[server]!.secrets.map((name) => ({ server, name, set: set[server]?.[name] !== undefined })),
);
return { dot_id: dot.id, secrets };
}
/**
* Set the value of a secret an MCP server of the Dot's config names, or clear it with null, and push the secrets to
* the guest when it runs: the server starts again with it. A secret the config does not name is refused, so none is
* stored that the config would not keep. The answer never carries a value.
*/
async setMcpSecret(idOrName: string, server: string, name: string, value: unknown): Promise<McpSecretsAnswer> {
const dot = await this.requireDot(idOrName);
const declared = dot.config.mcp_servers[server];
if (!declared) throw new ControlPlaneError(404, "not_found", `Dot ${dot.name} declares no MCP server "${server}"`);
if (!declared.secrets.includes(name)) {
throw new ControlPlaneError(404, "not_found", `the MCP server "${server}" of Dot ${dot.name} names no secret "${name}"`);
}
if (value === null) {
await this.db.secrets.delete(dot.id, mcpSecretName(server, name));
} else {
const checked = checkMcpSecret(value);
if (!checked.ok) throw new ControlPlaneError(400, "invalid_request", checked.problem);
await this.db.secrets.put(dot.id, mcpSecretName(server, name), checked.value);
}
await this.#pushToGuest(dot);
return this.mcpSecrets(dot.id);
}
/** Store the OpenRouter key (global or per Dot) and push it to the READY guests it applies to. */
async setOpenRouterKey(value: unknown, dotIdOrName?: unknown): Promise<{ pushed: number }> {
const checked = checkOpenRouterKey(value);
if (!checked.ok) throw new ControlPlaneError(400, "invalid_request", checked.problem);
let scope = GLOBAL_SCOPE;
if (dotIdOrName !== undefined && dotIdOrName !== null) {
if (typeof dotIdOrName !== "string") throw new ControlPlaneError(400, "invalid_request", "dot_id must be a string");
scope = (await this.requireDot(dotIdOrName)).id;
}
await this.db.secrets.put(scope, OPENROUTER_KEY_NAME, checked.key);
let pushed = 0;
// Every Dot it applies to, READY or not: one whose READY is under way must notice the change too.
const targets = scope === GLOBAL_SCOPE ? (await this.db.dots.list()).map((d) => d.id) : [scope];
for (const dotId of targets) {
try {
if (await this.lifecycle.syncGuest(dotId)) pushed++;
} catch (error) {
this.#log.warn("could not push the new OpenRouter key", { dotId, error: errorMessage(error) });
}
}
// Dots that failed READY only for want of a key can be retried now.
for (const dot of await this.db.dots.list()) {
const missedKey = dot.error?.includes(MISSING_KEY_MESSAGE) ?? false;
if (dot.status === "ERROR" && missedKey && (scope === GLOBAL_SCOPE || scope === dot.id)) {
this.#background.run("READY after a new key", dot.id, () => this.lifecycle.ensureReady(dot.id));
}
}
return { pushed };
}
// Idle sleep (section 9.5)
/**
* Put every Dot to sleep that is READY with an IDLE agent, has no work
* (no due or active task, nothing waiting to reach its guest, no automation
* due within the wake lead time), and was not active for its `idle_timeout`.
* Returns the ids of the Dots it started stopping; the stop checks all of it
* again under the Dot's lock and is called off when work arrived meanwhile.
*/
async idleCheck(): Promise<string[]> {
const now = this.#clock.now();
const sleeping: string[] = [];
for (const dotId of this.lifecycle.readyDots()) {
if (this.lifecycle.isBusy(dotId) || this.inbound.isFlushing(dotId)) continue;
const [dot, computer] = await Promise.all([this.db.dots.get(dotId), this.db.computers.get(dotId)]);
if (!dot || !computer || computer.state !== "RUNNING" || dot.status !== "READY") continue;
const timeout = computerResources(dot.config).idleTimeoutMs;
if (timeout === null) continue;
const lastActive = new Date(computer.last_active_at ?? computer.updated_at).getTime();
if (now.getTime() - lastActive < timeout) continue;
if (await this.lifecycle.keepsAwake(dotId)) continue;
this.#log.info("dot idle, going to sleep", { dotId, idleMs: now.getTime() - lastActive });
sleeping.push(dotId);
this.#background.run("idle sleep", dotId, () => this.lifecycle.stop(dotId, "idle"));
}
return sleeping;
}
/** The database answers, and whether the global OpenRouter key is stored (and decrypts). */
async health(): Promise<{ database: "ok"; openrouter_configured: boolean }> {
let key: string | null;
try {
key = await this.db.secrets.get(GLOBAL_SCOPE, OPENROUTER_KEY_NAME);
} catch (error) {
throw new ControlPlaneError(503, "database_unavailable", `the database did not answer: ${errorMessage(error)}`);
}
return { database: "ok", openrouter_configured: key !== null };
}
}
+66
View File
@@ -0,0 +1,66 @@
/** The scheduler's own building blocks: a per-key lock, and the work it starts without waiting for it. */
import { errorMessage, type Logger } from "@invisible-dots/shared";
/**
* Serializes work per key: lifecycle operations on one Dot (create, start,
* stop, reboot, delete) run one after the other, operations on different
* Dots run in parallel.
*/
export class KeyedMutex {
readonly #tails = new Map<string, Promise<unknown>>();
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = this.#tails.get(key) ?? Promise.resolve();
const result = previous.then(fn, fn);
const tail = result.catch(() => {});
this.#tails.set(key, tail);
void tail.then(() => {
if (this.#tails.get(key) === tail) this.#tails.delete(key);
});
return result;
}
isBusy(key: string): boolean {
return this.#tails.has(key);
}
}
/**
* Work a caller starts without waiting for it (a provisioning, a stop, a push to a guest): its failure is logged,
* since nobody awaits it, and `settle` waits until all of it has finished (tests and shutdown). After `close` nothing
* new starts.
*/
export class BackgroundWork {
readonly #pending = new Set<Promise<unknown>>();
#closed = false;
constructor(private readonly log: Logger) {}
/** Start `work`; a failure is logged as "<what> failed" with the Dot. */
run(what: string, dotId: string, work: () => Promise<unknown>): void {
if (this.#closed) return;
this.track(work().catch((error) => this.log.error(`${what} failed`, { dotId, error: errorMessage(error) })));
}
/** Count a promise as background work until it settles; it is returned as it is, for a caller that awaits it. */
track<T>(work: Promise<T>): Promise<T> {
const tracked = work.finally(() => this.#pending.delete(tracked));
tracked.catch(() => {});
this.#pending.add(tracked);
return work;
}
/** Whether any of it is still running. */
get busy(): boolean {
return this.#pending.size > 0;
}
/** Wait until all of it has finished, including work started meanwhile. */
async settle(): Promise<void> {
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending]);
}
close(): void {
this.#closed = true;
}
}
+879
View File
@@ -0,0 +1,879 @@
/**
* In-process stand-ins for the VM layer and the guest agent, used by the
* scheduler and API tests. FakeGuest follows the guest protocol of
* architecture section 5: health that comes up after a boot, a key held in
* memory only (lost on every reboot and every agent restart), `agent.started`
* at every start of the agent, inbound events accepted once per id, an
* outbox with monotonically increasing `seq` replayed after a cursor, and
* browser identities under the same rules as the real agent's, and a small
* file system (`putFile`) served through the files routes with the real
* daemon's errors, the automations its engine runs (`putAutomation`) and a small tool table.
*/
import { crc32, deflateSync } from "node:zlib";
import {
checkIdentityRequest,
IDENTITY_ERROR_STATUS,
FILE_TOO_LARGE,
mcpPermission,
mcpServerNames,
GUEST_PATHS,
IdentityRequestError,
newId,
newIdentityId,
pollGuestHealth,
type AgentState,
type AgentStateAnswer,
type BrowserIdentity,
type BrowserIdentityListAnswer,
type CreateBrowserIdentityRequest,
type DotRuntimeConfig,
type FileEntry,
type FileListAnswer,
type GuestChecks,
type HealthAnswer,
type IdentityErrorCode,
type InboundEvent,
type OutboundEvent,
type OutboundEventDataMap,
type OutboundEventType,
type PermissionName,
type RefusedEvent,
type SecretsRequest,
type SystemAnswer,
type ToolInfo,
type Skill,
type SkillListAnswer,
type ToolListAnswer,
type VmState,
} from "@invisible-dots/shared";
import type {
ComputerDriver,
ComputerSpecInput,
ComputerState,
CreatedComputer,
GuestApi,
GuestEndpoint,
StartedComputer,
WaitForHealthOptions,
} from "./driver.js";
/** The fake desktop's size: a picture a browser draws with real proportions, so a page that shows it is laid out as it will be. */
const FAKE_DESKTOP = { width: 1280, height: 800 } as const;
/**
* A job of the fake's engine, with the fields of nanobot's CronJob that decide when it runs and that a test reads back.
* The host never lists the jobs (they are the Dot's, made and changed through its cron tool); it hears only when the
* earliest is next due (`automation.next_run`).
*/
export interface FakeAutomation {
id: string;
name: string;
enabled: boolean;
schedule: { kind: "at" | "every" | "cron"; at_ms?: number; every_ms?: number; expr?: string; tz?: string };
message: string;
next_run_at_ms: number | null;
last_run_at_ms: number | null;
last_status: "ok" | "error" | "skipped" | null;
last_error: string | null;
delete_after_run: boolean;
created_at_ms: number;
}
let desktop: Uint8Array | undefined;
/** A real PNG of the fake desktop, one flat colour, made once. */
function desktopPng(): Uint8Array {
if (desktop) return desktop;
const { width, height } = FAKE_DESKTOP;
const stride = 1 + width * 3;
const raw = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) raw.set([24, 32, 48], y * stride + 1 + x * 3);
const chunk = (type: string, data: Buffer) => {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, "latin1");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
return Buffer.concat([head, data, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bits per channel
ihdr[9] = 2; // RGB
desktop = new Uint8Array(Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]));
return desktop;
}
/** The error shape of vm-manager's GuestRequestError: a status (0 = unreachable) and the guest's code. */
/** The most browser identities the engine keeps (`DEFAULT_MAX_IDENTITIES` in nanobot/dots/main.py). */
export const FAKE_MAX_IDENTITIES = 20;
export class FakeGuestError extends Error {
constructor(
readonly status: number,
message: string,
readonly code?: string,
) {
super(message);
this.name = "FakeGuestError";
}
}
export type InboundHandler = (event: InboundEvent, guest: FakeGuest) => void | Promise<void>;
/**
* The default agent: a task runs at once and completes; a message gets an
* answer; an approval resumes and completes the task it belongs to.
*/
export const completeEverything: InboundHandler = (event, guest) => {
switch (event.type) {
case "task.created":
guest.emit("agent.state", { state: "THINKING" });
guest.emit("task.started", { task_id: event.data.task_id });
guest.emit("task.completed", { task_id: event.data.task_id, summary: `done: ${event.data.description}` });
guest.emit("agent.state", { state: "IDLE" });
break;
case "user.message":
guest.emit("message.assistant", { text: `echo: ${event.data.text}`, in_reply_to: event.id });
break;
case "approval.received": {
const pending = guest.pendingApproval;
guest.pendingApproval = null;
guest.emit("agent.state", { state: "EXECUTING" });
if (pending?.task_id) {
guest.emit("task.completed", { task_id: pending.task_id, summary: `approval ${event.data.decision}d` });
}
guest.emit("agent.state", { state: "IDLE" });
break;
}
default:
break;
}
};
export class FakeGuest implements GuestApi {
/** Whether the VM is powered on; every call fails as unreachable when it is not. */
running = false;
/** Health polls answered with agent "starting" after each boot before it reports "ok". */
bootPolls = 1;
#pollsSinceBoot = 0;
#bootedAt = 0;
agentState: AgentState = "IDLE";
openrouterKey: string | null = null;
/** The MCP servers' secrets the host pushed last, by server and name. */
mcpSecrets: SecretsRequest["mcp_secrets"] = {};
config: DotRuntimeConfig | null = null;
checks: GuestChecks = { filesystem_writable: true, network_reachable: true, browser_installed: true };
readonly outbox: OutboundEvent[] = [];
readonly inbound: InboundEvent[] = [];
readonly identities = new Map<string, BrowserIdentity>();
/** The files of the guest by absolute path; directories exist where a file is under them (and home always). */
readonly files = new Map<string, { content: Uint8Array; mtime: Date }>();
/** Symbolic links of the guest: the absolute path of the link to its target (an absolute path). */
readonly links = new Map<string, string>();
/** The automations of the Dot by id (its cron tool made them). */
readonly automations = new Map<string, FakeAutomation>();
/**
* The tools the fake's engine has: the real table's shape, a few rows; `offered` follows the permissions of the
* config, as the engine's `offered_tools` does.
*/
readonly tools: Omit<ToolInfo, "offered">[] = [
{ name: "exec", permission: "computer.exec", description: "Run a shell command on the computer." },
{ name: "read_file", permission: "files.read", description: "Read a file." },
{ name: "write_file", permission: "files.write", description: "Write a file." },
{ name: "cron", permission: "automations", description: "Schedule reminders and recurring tasks." },
{ name: "browser_identity_list", permission: "browser.identity.list", description: "List the browser identities." },
{ name: "browser_identity_create", permission: "browser.identity.create", description: "Create a browser identity." },
];
/**
* The tools each declared MCP server serves, by server; a server of the config with none here serves none. A server
* named in `mcpFailures` is failed with that error instead, as the engine reports a server it could not start.
*/
readonly mcpTools = new Map<string, Omit<ToolInfo, "offered" | "permission">[]>();
readonly mcpFailures = new Map<string, string>();
/** The skills the fake's engine shows: one built-in by default, as the real one ships; a test adds the Dot's own. */
skills: Skill[] = [
{
name: "invisible-playwright",
description: "Use the Dot's browser for any task on a website.",
source: "builtin",
path: "/opt/invisible-dots/engine/skills/invisible-playwright/SKILL.md",
content: "---\nname: invisible-playwright\ndescription: Use the Dot's browser for any task on a website.\n---\n\n# The browser\n",
},
];
readonly calls: string[] = [];
pendingApproval: { approval_id: string; task_id?: string } | null = null;
onInbound: InboundHandler = completeEverything;
/** While set, a frame of an open identity answers 503 `busy`, as when a call of the Dot holds the browser. */
identityBusy = false;
/** While set, a frame of an open identity answers with this code and its status of the shared table, as when the engine's browser fails. */
identityFault: IdentityErrorCode | null = null;
/** When set, `postEvent` fails with it once. */
failNextPost: Error | null = null;
boots = 0;
/** The guest's clock, in ms since the epoch: what its engine compares the jobs' times with at a boot. */
now: () => number = () => Date.now();
#seq = 0;
/** Messages the guest sent that the host will refuse, each after the `seq` of the last event the guest had written when it was sent. */
#refusals: { info: RefusedEvent; after: number }[] = [];
#wakers = new Set<() => void>();
#streams = new Set<AbortController>();
constructor(readonly token: string) {}
boot(): void {
this.running = true;
this.boots++;
this.#pollsSinceBoot = 0;
this.#bootedAt = Date.now();
this.openrouterKey = null;
this.mcpSecrets = {};
this.agentState = "IDLE";
this.emit("agent.started", {});
this.#catchUp();
}
/**
* What the engine does at a start (CronService.start): a job whose time came while the computer was off runs once,
* late, and moves on (a recurring job counts its next run from now, a one-time one is over), then the engine reports
* the earliest next run when it differs from what the host was last told.
*/
#catchUp(): void {
const now = this.now();
for (const job of [...this.automations.values()]) {
if (!job.enabled || job.next_run_at_ms === null || job.next_run_at_ms > now) continue;
const ran = { ...job, last_run_at_ms: now, last_status: "ok" as const, last_error: null };
if (job.schedule.kind === "at") {
if (job.delete_after_run) this.automations.delete(job.id);
else this.automations.set(job.id, { ...ran, enabled: false, next_run_at_ms: null });
} else {
const every = job.schedule.kind === "every" ? (job.schedule.every_ms ?? 60_000) : 24 * 3_600_000;
this.automations.set(job.id, { ...ran, next_run_at_ms: now + every });
}
}
this.#reportNextRun();
}
/**
* The agent process restarts inside a running VM (systemd after a crash):
* its key is gone, its event stream drops, and it announces its start.
*/
restartAgent(): void {
this.agentRestarts++;
this.openrouterKey = null;
this.mcpSecrets = {};
this.disconnectStreams();
this.emit("agent.started", {});
}
agentRestarts = 0;
powerOff(): void {
this.running = false;
this.disconnectStreams();
}
/** End every open event stream, as a dropped connection would. */
disconnectStreams(): void {
for (const controller of this.#streams) controller.abort();
this.#streams.clear();
this.#wake();
}
#wake(): void {
for (const w of [...this.#wakers]) w();
}
#openIdentities(): BrowserIdentity[] {
return [...this.identities.values()].filter((identity) => identity.status === "open");
}
/** The Dot opens an identity's browser (the engine does it for a tool call; the host only observes it). */
launchIdentity(id: string): void {
const identity = this.identities.get(id);
if (!identity) throw new Error(`no identity ${id}`);
this.identities.set(id, { ...identity, status: "open", lastUsedAt: new Date().toISOString() });
this.emit("browser.identity.launched", { identity_id: id, name: identity.name });
}
#reachable(what: string): void {
this.calls.push(what);
if (!this.running) {
if (this.#rebootDelayMs !== null) {
setTimeout(() => this.boot(), this.#rebootDelayMs);
this.#rebootDelayMs = null;
}
throw new FakeGuestError(0, `${what}: connect ECONNREFUSED (the VM is off)`, "ECONNREFUSED");
}
}
#rebootDelayMs: number | null = null;
/**
* Power off and come back `delayMs` after the first call that finds the
* guest down. A real reboot is seconds of downtime that a poll always sees;
* booting on a plain timer instead would let a slow test machine miss it.
*/
reboot(delayMs: number): void {
this.powerOff();
this.#rebootDelayMs = delayMs;
}
/** Append an outbound event to the outbox, as the agent does before streaming it. */
emit<T extends OutboundEventType>(type: T, data: OutboundEventDataMap[T]): OutboundEvent {
if (type === "agent.state") this.agentState = (data as { state: AgentState }).state;
if (type === "approval.requested") {
const d = data as OutboundEventDataMap["approval.requested"];
this.pendingApproval = { approval_id: d.approval_id, ...(d.task_id ? { task_id: d.task_id } : {}) };
this.agentState = "WAITING_APPROVAL";
}
const event = { seq: ++this.#seq, id: newId("evt"), type, ts: new Date().toISOString(), data } as OutboundEvent;
this.outbox.push(event);
this.#wake();
return event;
}
/**
* The engine writes an event of this type that the host's schema does not take: it has a `seq` (the next one) and the
* stream carries it, but the host refuses it. What follows is stored as usual.
*/
writeUnreadable(type: string, problem: string): void {
this.#refusals.push({ info: { seq: ++this.#seq, type, problem }, after: this.outbox.at(-1)?.seq ?? 0 });
this.#wake();
}
/** The stream carries a message that is not JSON, so it has no `seq` and no type to name. */
sendGarbage(problem: string): void {
this.#refusals.push({ info: { seq: null, type: null, problem }, after: this.outbox.at(-1)?.seq ?? 0 });
this.#wake();
}
/** Ask for an approval from inside a task, as the policy engine does on `ask`. */
requestApproval(
taskId: string | undefined,
tool = "browser_identity_delete",
permission: PermissionName = "browser.identity.delete",
args: Record<string, unknown> = { identity_id: "shop-abc123" },
): string {
const approvalId = newId("apr");
this.emit("agent.state", { state: "WAITING_APPROVAL" });
this.emit("approval.requested", {
approval_id: approvalId,
...(taskId ? { task_id: taskId } : {}),
tool,
permission,
arguments: args,
reason: "the tool needs approval",
});
return approvalId;
}
async health(): Promise<HealthAnswer> {
this.#reachable("health");
this.#pollsSinceBoot++;
// A real guest has been up for several seconds by the time its agent answers.
const uptime = 5 + Math.floor((Date.now() - this.#bootedAt) / 1000);
if (this.#pollsSinceBoot <= this.bootPolls) {
return { agentd: "ok", agent: { status: "down" }, uptime_s: uptime };
}
return {
agentd: "ok",
agent: {
status: "ok",
state: this.agentState,
openrouter_configured: this.openrouterKey !== null,
browser: { identities: this.identities.size, open: this.#openIdentities().length },
checks: { ...this.checks },
},
uptime_s: uptime,
};
}
async system(): Promise<SystemAnswer> {
this.#reachable("system");
return {
hostname: "invisible-dot-fake",
uptime_s: 5 + Math.floor((Date.now() - this.#bootedAt) / 1000),
cpus: 2,
mem_total_bytes: 4 * 1024 ** 3,
mem_available_bytes: 3 * 1024 ** 3,
disk_total_bytes: 40 * 1024 ** 3,
disk_free_bytes: 35 * 1024 ** 3,
};
}
async pushSecrets(secrets: SecretsRequest): Promise<void> {
this.#reachable("pushSecrets");
this.openrouterKey = secrets.openrouter_api_key;
this.mcpSecrets = structuredClone(secrets.mcp_secrets);
}
/** While set, `putConfig` waits for it (a slow push), and the config is stored when it resolves. */
configPushGate: Promise<void> | null = null;
async putConfig(config: DotRuntimeConfig): Promise<void> {
this.#reachable("putConfig");
await this.configPushGate;
this.config = config;
}
async postEvent(event: InboundEvent): Promise<{ accepted: true }> {
this.#reachable(`postEvent:${event.type}`);
if (this.failNextPost) {
const error = this.failNextPost;
this.failNextPost = null;
throw error;
}
// Like the agent's inbox: an id it already accepted is accepted again and ignored.
if (this.inbound.some((e) => e.id === event.id)) return { accepted: true };
this.inbound.push(event);
await this.onInbound(event, this);
return { accepted: true };
}
async state(): Promise<AgentStateAnswer> {
this.#reachable("state");
return { state: this.agentState, current_task_id: null, pending_approval: null };
}
async listBrowserIdentities(): Promise<BrowserIdentityListAnswer> {
this.#reachable("listBrowserIdentities");
return { identities: [...this.identities.values()] };
}
async createBrowserIdentity(body: CreateBrowserIdentityRequest): Promise<BrowserIdentity> {
this.#reachable("createBrowserIdentity");
let checked: { name: string; proxy?: string };
try {
// The agent's own rules, with the engine's own limit (DEFAULT_MAX_IDENTITIES in nanobot/dots/main.py).
checked = checkIdentityRequest(body, this.identities.size, FAKE_MAX_IDENTITIES);
} catch (error) {
if (error instanceof IdentityRequestError) throw new FakeGuestError(error.code === "limit" ? 409 : 400, error.message, error.code);
throw error;
}
const id = newIdentityId(checked.name);
const identity: BrowserIdentity = {
id,
name: checked.name,
createdAt: new Date().toISOString(),
lastUsedAt: null,
status: "available",
profilePath: `/home/dot/browsers/${id}/profile`,
hasProxy: checked.proxy !== undefined,
};
this.identities.set(id, identity);
this.emit("browser.identity.created", { identity_id: id, name: checked.name });
return identity;
}
async getBrowserIdentity(id: string): Promise<BrowserIdentity> {
this.#reachable("getBrowserIdentity");
const identity = this.identities.get(id);
if (!identity) throw new FakeGuestError(404, `identity ${id} not found`, "not_found");
return identity;
}
async deleteBrowserIdentity(id: string): Promise<void> {
this.#reachable("deleteBrowserIdentity");
const identity = this.identities.get(id);
if (!identity) throw new FakeGuestError(404, `identity ${id} not found`, "not_found");
this.identities.delete(id);
this.emit("browser.identity.deleted", { identity_id: id, name: identity.name });
}
async getBrowserIdentityFrame(id: string): Promise<Uint8Array> {
this.#reachable("getBrowserIdentityFrame");
const identity = this.identities.get(id);
if (!identity) throw new FakeGuestError(404, `no browser identity "${id}"`, "not_found");
if (identity.status !== "open") {
throw new FakeGuestError(409, `identity ${id} is not open; call browser_identity_launch first`, "not_open");
}
if (this.identityBusy) {
throw new FakeGuestError(IDENTITY_ERROR_STATUS.busy, `browser identity "${id}" is busy with a call; ask again in a moment`, "busy");
}
if (this.identityFault) {
throw new FakeGuestError(IDENTITY_ERROR_STATUS[this.identityFault], `browser identity "${id}" failed: ${this.identityFault}`, this.identityFault);
}
// The JPEG markers of an empty image: enough for a content check.
return Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]);
}
async closeBrowserIdentity(id: string): Promise<void> {
this.#reachable("closeBrowserIdentity");
const identity = this.identities.get(id);
if (!identity) throw new FakeGuestError(404, `no browser identity "${id}"`, "not_found");
if (identity.status !== "open") return;
this.identities.set(id, { ...identity, status: "available" });
this.emit("browser.identity.closed", { identity_id: id, name: identity.name });
}
/** Give the Dot an automation (its cron tool made it). */
putAutomation(automation: FakeAutomation): void {
this.automations.set(automation.id, automation);
this.#reportNextRun();
}
/** Take an automation away (its cron tool removed it). */
removeAutomation(id: string): void {
this.automations.delete(id);
this.#reportNextRun();
}
/**
* What the engine does after every change of its jobs: tell the host, with an `automation.next_run` event, when the
* earliest enabled one is due (null when none is), once per change (also at a boot, when a run made
* late moved it: a boot with nothing changed reports nothing, the engine has told the host what it has to).
*/
#reportNextRun(): void {
const due = [...this.automations.values()].flatMap((a) => (a.enabled && a.next_run_at_ms !== null ? [a.next_run_at_ms] : []));
const next = due.length > 0 ? Math.min(...due) : null;
if (next === this.#reportedNextRun) return;
this.#reportedNextRun = next;
this.emit("automation.next_run", { next_run_at_ms: next });
}
#reportedNextRun: number | null = null;
async listTools(): Promise<ToolListAnswer> {
this.#reachable("listTools");
const permissions: Record<string, string | undefined> = this.config?.permissions ?? {};
const offered = (tool: Omit<ToolInfo, "offered">): boolean =>
this.config !== null && (permissions[tool.permission] === "allow" || permissions[tool.permission] === "ask");
const servers = this.config ? mcpServerNames(this.config) : [];
const connected = servers.filter((name) => !this.mcpFailures.has(name));
const mcpTools = connected.flatMap((name) => (this.mcpTools.get(name) ?? []).map((tool) => ({ ...tool, permission: mcpPermission(name) })));
return {
tools: [...this.tools, ...mcpTools].map((tool) => ({ ...tool, offered: offered(tool) })),
mcp_servers: servers.map((name) => {
const error = this.mcpFailures.get(name);
return error === undefined
? { name, state: "connected" as const, error: null, tools: this.mcpTools.get(name)?.length ?? 0 }
: { name, state: "failed" as const, error, tools: 0 };
}),
};
}
async listSkills(): Promise<SkillListAnswer> {
this.#reachable("listSkills");
return { skills: [...this.skills] };
}
/** Put a file in the guest's file system (the Dot wrote it). */
putFile(path: string, content: string | Uint8Array, mtime: Date = new Date()): void {
this.files.set(path, { content: typeof content === "string" ? new TextEncoder().encode(content) : content, mtime });
}
/** A symbolic link at `path` leading to `target` (absolute). */
link(path: string, target: string): void {
this.links.set(path, target);
}
/**
* What dot-agentd's TCP listener does with a path: follow the symbolic links, and refuse (403 `outside_home`) a
* real location that is not under home. The host's file routes always reach the guest through that listener.
*/
#realPath(path: string): string {
let real = path;
for (let hops = 0; ; hops++) {
if (hops > 40) throw new FakeGuestError(500, `${path}: too many levels of symbolic links`, "io_error");
const key = [...this.links.keys()].filter((link) => real === link || real.startsWith(`${link}/`)).sort((a, b) => b.length - a.length)[0];
if (key === undefined) break;
real = this.links.get(key)! + real.slice(key.length);
}
if (real !== GUEST_PATHS.home && !real.startsWith(`${GUEST_PATHS.home}/`)) {
throw new FakeGuestError(403, `the path leads outside ${GUEST_PATHS.home}`, "outside_home");
}
return real;
}
#isDirectory(path: string): boolean {
return path === GUEST_PATHS.home || [...this.files.keys()].some((file) => file.startsWith(`${path}/`));
}
async readFile(asked: string, options: { maxBytes?: number } = {}): Promise<Uint8Array> {
this.#reachable("readFile");
const path = this.#realPath(asked);
if (this.#isDirectory(path)) throw new FakeGuestError(400, `${path} is a directory; use /v1/files/list`, "is_a_directory");
const file = this.files.get(path);
if (!file) throw new FakeGuestError(404, `open ${path}: no such file or directory`, "not_found");
if (options.maxBytes !== undefined && file.content.length > options.maxBytes) {
throw new FakeGuestError(413, `the answer is larger than ${options.maxBytes} bytes`, FILE_TOO_LARGE);
}
return file.content;
}
async listFiles(asked: string): Promise<FileListAnswer> {
this.#reachable("listFiles");
const path = this.#realPath(asked);
if (this.files.has(path)) throw new FakeGuestError(400, `${path} is not a directory`, "not_a_directory");
if (!this.#isDirectory(path)) throw new FakeGuestError(404, `stat ${path}: no such file or directory`, "not_found");
const entries = new Map<string, FileEntry>();
for (const [file, { content, mtime }] of this.files) {
if (!file.startsWith(`${path}/`)) continue;
const [name, ...rest] = file.slice(path.length + 1).split("/");
const modified = mtime.toISOString();
const known = entries.get(name!);
if (rest.length > 0) entries.set(name!, { name: name!, type: "dir", size: 0, mtime: known && known.mtime > modified ? known.mtime : modified });
else entries.set(name!, { name: name!, type: "file", size: content.length, mtime: modified });
}
return { entries: [...entries.values()].sort((a, b) => (a.name < b.name ? -1 : 1)) };
}
async prepareSleep(): Promise<void> {
this.#reachable("prepareSleep");
}
async screenshot(): Promise<Uint8Array> {
this.#reachable("screenshot");
return desktopPng();
}
/** The outbox after `after`, then every new event, until aborted or disconnected. */
async *events(options: { after?: number; signal?: AbortSignal; onRefused?: (info: RefusedEvent) => void | Promise<void> }): AsyncGenerator<OutboundEvent> {
this.#reachable("events");
const own = new AbortController();
this.#streams.add(own);
let after = options.after ?? 0;
const handed = new Set<object>();
try {
for (;;) {
if (options.signal?.aborted) return;
if (own.signal.aborted) throw new FakeGuestError(0, "event stream disconnected");
// A refused message is handed over when its turn comes (after the events written before it), as the real
// stream does, and a reconnect resumes after its `seq`.
const refusal = this.#refusals.find((r) => !handed.has(r) && !this.outbox.some((e) => e.seq > after && e.seq <= r.after));
if (refusal) {
handed.add(refusal);
if (refusal.info.seq !== null && refusal.info.seq <= after) continue;
await options.onRefused?.(refusal.info);
if (refusal.info.seq !== null) after = refusal.info.seq;
continue;
}
const next = this.outbox.find((e) => e.seq > after);
if (next) {
after = next.seq;
yield next;
continue;
}
await new Promise<void>((resolve) => {
const done = () => {
this.#wakers.delete(done);
options.signal?.removeEventListener("abort", done);
resolve();
};
this.#wakers.add(done);
options.signal?.addEventListener("abort", done, { once: true });
});
}
} finally {
this.#streams.delete(own);
}
}
}
interface FakeVm {
state: VmState;
guestPort: number | null;
pid: number | null;
token: string;
}
export type DriverOperation = "create" | "start" | "stop" | "reboot" | "destroy";
/** The first port FakeDriver hands out; far from anything a test binds, and never really bound. */
const FAKE_FIRST_PORT = 47_000;
/**
* A ComputerDriver that keeps its "VMs" in memory and boots a FakeGuest in
* each. Like QEMU with a fresh port forward, every start gets a new port,
* and a guest is reached only through the port its VM runs with now.
*/
export class FakeDriver implements ComputerDriver {
readonly vms = new Map<string, FakeVm>();
readonly guests = new Map<string, FakeGuest>();
readonly calls: string[] = [];
readonly #failures = new Map<DriverOperation, { error: Error; times: number }>();
#nextPort = FAKE_FIRST_PORT;
#nextPid = 9_000;
/** Applied to every new guest, e.g. to install a custom agent behaviour. */
configureGuest: (guest: FakeGuest) => void = () => {};
rebootDelayMs = 30;
goldenImage = "images/golden-test.qcow2";
runtimeImage = "images/runtime-test.iso";
/** Make the next `times` calls of `operation` fail with `error`. */
failNext(operation: DriverOperation, error: Error = new Error(`${operation} failed (injected)`), times = 1): void {
this.#failures.set(operation, { error, times });
}
#enter(operation: DriverOperation, dotId: string): void {
this.calls.push(`${operation}:${dotId}`);
const failure = this.#failures.get(operation);
if (failure) {
if (--failure.times <= 0) this.#failures.delete(operation);
throw failure.error;
}
}
guestOf(dotId: string): FakeGuest {
const guest = this.guests.get(dotId);
if (!guest) throw new Error(`no fake guest for ${dotId}`);
return guest;
}
/** Power a VM off behind the control plane's back, as a crash or a host reboot would. */
crash(dotId: string): void {
const vm = this.vms.get(dotId);
if (vm) Object.assign(vm, { state: "STOPPED", guestPort: null, pid: null });
this.guests.get(dotId)?.powerOff();
}
async create(spec: ComputerSpecInput): Promise<CreatedComputer> {
this.#enter("create", spec.dotId);
if (!this.vms.has(spec.dotId)) this.vms.set(spec.dotId, { state: "STOPPED", guestPort: null, pid: null, token: spec.token });
if (!this.guests.has(spec.dotId)) {
const guest = new FakeGuest(spec.token);
this.configureGuest(guest);
this.guests.set(spec.dotId, guest);
}
return { goldenImage: this.goldenImage, runtimeImage: this.runtimeImage };
}
async start(spec: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer> {
this.#enter("start", spec.dotId);
const vm = this.vms.get(spec.dotId);
if (!vm) throw new Error(`the disk of ${spec.dotId} does not exist`);
if (vm.state === "RUNNING" && vm.guestPort !== null && vm.pid !== null) {
return { guestPort: vm.guestPort, pid: vm.pid, runtimeImage: this.runtimeImage, alreadyRunning: true };
}
Object.assign(vm, { state: "RUNNING", guestPort: this.#nextPort++, pid: this.#nextPid++ });
this.guestOf(spec.dotId).boot();
return { guestPort: vm.guestPort!, pid: vm.pid!, runtimeImage: this.runtimeImage, alreadyRunning: false };
}
async stop(dotId: string, token: string) {
this.#enter("stop", dotId);
// Like dot-agentd: the poweroff needs the Dot's token.
if (this.vms.get(dotId) && token !== this.vms.get(dotId)!.token) throw new FakeGuestError(401, "unauthorized", "unauthorized");
this.crash(dotId);
return { forced: false };
}
/** Like the real one: the guest goes down, comes back a moment later, and the port changes. */
async reboot(spec: ComputerSpecInput & { goldenImage: string }): Promise<StartedComputer> {
this.#enter("reboot", spec.dotId);
const vm = this.vms.get(spec.dotId);
if (!vm) throw new Error(`the disk of ${spec.dotId} does not exist`);
Object.assign(vm, { state: "RUNNING", guestPort: this.#nextPort++, pid: this.#nextPid++ });
this.guestOf(spec.dotId).reboot(this.rebootDelayMs);
return { guestPort: vm.guestPort!, pid: vm.pid!, runtimeImage: this.runtimeImage, alreadyRunning: false };
}
/** vm-manager's rule and loop (pollGuestHealth), with "QEMU exited" as the check, as VmManager has. */
async waitForHealth(endpoint: GuestEndpoint, token: string, options: WaitForHealthOptions): Promise<HealthAnswer> {
const source = { address: `fake:${endpoint.port}`, health: (o: { timeoutMs?: number }) => this.guest(endpoint, token).health(o) };
return pollGuestHealth(source, {
timeoutMs: options.timeoutMs,
intervalMs: options.intervalMs,
requestTimeoutMs: options.requestTimeoutMs,
check: () => {
if (this.vms.get(endpoint.dotId)?.state !== "RUNNING") throw new Error(`QEMU of ${endpoint.dotId} is not running any more`);
},
});
}
async destroy(dotId: string) {
this.#enter("destroy", dotId);
this.guests.get(dotId)?.powerOff();
this.vms.delete(dotId);
this.guests.delete(dotId);
}
async state(dotId: string): Promise<ComputerState> {
const vm = this.vms.get(dotId);
if (!vm) return { exists: false, state: "STOPPED", pid: null, guestPort: null, detail: null };
return { exists: true, state: vm.state, pid: vm.pid, guestPort: vm.guestPort, detail: vm.state === "RUNNING" ? "running" : null };
}
guest(endpoint: GuestEndpoint, token: string): GuestApi {
const vm = this.vms.get(endpoint.dotId);
const guest = this.guests.get(endpoint.dotId);
const refuse = (status: number, message: string, code?: string) =>
new Proxy({} as GuestApi, {
// Not a thenable: `await` on the client must not call "then" and hang.
get: (_target, property) =>
property === "then"
? undefined
: async () => {
throw new FakeGuestError(status, message, code);
},
});
// A port the VM no longer runs with reaches nothing, like a stale forward after a restart.
if (!vm || !guest || vm.guestPort !== endpoint.port) {
return refuse(0, `connect ECONNREFUSED 127.0.0.1:${endpoint.port}`, "ECONNREFUSED");
}
// Every call is refused, like dot-agentd answering 401 to a wrong token.
if (token !== guest.token) return refuse(401, "unauthorized", "unauthorized");
return guest;
}
async close() {}
}
/** Poll until `predicate` holds; fails with `what` after `timeoutMs`. */
export async function waitFor<T>(
predicate: () => T | Promise<T>,
what: string,
timeoutMs = 5_000,
intervalMs = 10,
): Promise<NonNullable<T>> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = await predicate();
if (value) return value as NonNullable<T>;
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
/**
* Wait until the Dot is READY and doing nothing: no lifecycle operation
* under its lock, every event its fake guest emitted so far stored by the
* pump, and what READY and those events started in the background done too
* (the flush of its outbox, the push an agent.started asks for). A test that
* starts earlier races that work: an idle check skips a Dot whose outbox is
* being flushed, and a guest event stored later counts as activity, which is
* right in a server and a coin toss in a test.
*/
export async function waitUntilSettledReady(
scheduler: {
db: { dots: { get(id: string): Promise<{ status: string } | null> }; computers: { get(id: string): Promise<{ event_cursor: number } | null> } };
lifecycle: { isBusy(id: string): boolean };
settle(): Promise<void>;
},
driver: FakeDriver,
dotId: string,
what: string,
): Promise<void> {
// A Dot's creation is dozens of queries: against a Postgres across WSL on a loaded host that has taken over 5 s.
await waitFor(async () => (await scheduler.db.dots.get(dotId))?.status === "READY" && !scheduler.lifecycle.isBusy(dotId), `${what} READY`, 15_000);
const lastSeq = driver.guestOf(dotId).outbox.at(-1)?.seq ?? 0;
await waitFor(async () => ((await scheduler.db.computers.get(dotId))?.event_cursor ?? 0) >= lastSeq, `${what}'s guest events stored`, 15_000);
await scheduler.settle();
}
/** A clock tests move by hand. */
export class ManualClock {
#now: number;
constructor(start: Date = new Date()) {
this.#now = start.getTime();
}
now(): Date {
return new Date(this.#now);
}
advance(ms: number): void {
this.#now += ms;
}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* A change of state and the host event that tells it commit together or not at all. The control plane can be killed
* (kill -9, a power cut) between any two statements; a transaction cannot be half done, so what is left is the state it
* was in before the change, which recovery knows how to finish, never a state nobody was told of.
*
* Each test plays the kill by making the last write of the change throw (`killedAt`, database/testing): the process
* dies there, and whatever was written before it in the same transaction must not have been committed. The events
* checked are those the change would have logged.
*/
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, killedAt, type TestDatabase } from "@invisible-dots/database/testing";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { Scheduler } from "../src/index.js";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
const yaml = (name: string) => `name: ${name}\nmodel:\n provider: openrouter\n id: test/model\n`;
describe("the commit points of the host", () => {
let t: TestDatabase;
let db: Database;
const open: Scheduler[] = [];
beforeAll(async () => {
t = await createTestDatabase("pglite");
db = t.db;
await db.secrets.put("global", "openrouter_api_key", "sk-or-test");
});
afterEach(async () => {
await Promise.all(open.splice(0).map((s) => s.close()));
});
afterAll(async () => {
await t?.drop();
});
function make(driver: FakeDriver, over: Database = db) {
const scheduler = new Scheduler({
db: over,
driver,
clock: new ManualClock(),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 },
dispatcher: { retryDelayMs: 0, maxDeliveryAttempts: 2 },
dispatchIntervalMs: 60_000,
idleCheckIntervalMs: 60_000,
});
open.push(scheduler);
return scheduler;
}
const typesOf = async (dotId: string, ...types: string[]) => (await db.events.list({ dotId, types })).map((e) => e.type);
it("a stop killed at its last write leaves the computer STOPPING and says nothing of a stop; recovery finishes it, and tells of it once", async () => {
const driver = new FakeDriver();
const killed = killedAt(db, "dots", "setStatus");
const first = make(driver, killed.db);
const dot = await first.createDot(yaml("killed-stop"));
await waitUntilSettledReady(first, driver, dot.id, "killed-stop");
const before = (await typesOf(dot.id, "computer.stopped")).length;
killed.kill();
await first.stopComputer(dot.id);
await first.settle();
// Before the change the stop's rows were four statements, so the state was STOPPED, the Dot's status old and no event.
expect(await db.computers.get(dot.id)).toMatchObject({ state: "STOPPING", stop_reason: "user" });
expect((await db.events.list({ dotId: dot.id, types: ["computer.state"] })).map((e) => e.data.state)).not.toContain("STOPPED");
expect(await typesOf(dot.id, "computer.stopped")).toHaveLength(before);
// The control plane starts again: the interrupted stop is finished, and told once.
killed.revive();
await first.close();
open.splice(open.indexOf(first), 1);
const second = make(driver);
await second.start();
await second.settle();
expect(await db.computers.get(dot.id)).toMatchObject({ state: "STOPPED", stop_reason: "user", pid: null, guest_port: null });
expect(await db.events.list({ dotId: dot.id, types: ["computer.stopped"] })).toHaveLength(before + 1);
expect((await db.events.list({ dotId: dot.id, types: ["computer.state"] })).map((e) => e.data.state).filter((state) => state === "STOPPED")).toHaveLength(1);
expect((await db.dots.get(dot.id))?.status).toBe("IDLE");
});
it("a deletion killed at its last write leaves the Dot, and no dot.deleted; the event and the Dot go together", async () => {
const driver = new FakeDriver();
const killed = killedAt(db, "dots", "delete");
const scheduler = make(driver, killed.db);
const dot = await scheduler.createDot(yaml("killed-delete"));
await waitUntilSettledReady(scheduler, driver, dot.id, "killed-delete");
killed.kill();
await scheduler.deleteDot(dot.id);
await scheduler.settle();
expect(await db.dots.get(dot.id)).not.toBeNull();
expect(await typesOf(dot.id, "dot.deleted")).toEqual([]);
killed.revive();
await scheduler.deleteDot(dot.id);
await scheduler.settle();
expect(await db.dots.get(dot.id)).toBeNull();
expect(await typesOf(dot.id, "dot.deleted")).toEqual(["dot.deleted"]);
});
it("a Dot killed at its second insert is not created and is not announced", async () => {
const killed = killedAt(db, "computers", "insert");
const scheduler = make(new FakeDriver(), killed.db);
killed.kill();
await expect(scheduler.createDot(yaml("killed-create"))).rejects.toThrow(/killed at computers.insert/);
expect(await db.dots.resolve("killed-create")).toBeNull();
expect((await db.events.list({ types: ["dot.created"] })).filter((e) => e.data.name === "killed-create")).toEqual([]);
killed.revive();
const dot = await scheduler.createDot(yaml("killed-create"));
expect(await typesOf(dot.id, "dot.created", "computer.state")).toEqual(["dot.created", "computer.state"]);
});
it("a task killed at its insert is not announced, and an announced one exists", async () => {
const driver = new FakeDriver();
const killed = killedAt(db, "tasks", "insert");
const scheduler = make(driver, killed.db);
const dot = await scheduler.createDot(yaml("killed-task"));
await waitUntilSettledReady(scheduler, driver, dot.id, "killed-task");
killed.kill();
await expect(scheduler.createTask(dot.id, { description: "never stored" })).rejects.toThrow(/killed at tasks.insert/);
expect(await typesOf(dot.id, "task.created")).toEqual([]);
killed.revive();
const task = await scheduler.createTask(dot.id, { description: "stored" });
await waitFor(async () => (await typesOf(dot.id, "task.created")).length === 1, "the event of the task");
expect((await db.events.list({ dotId: dot.id, types: ["task.created"] }))[0]!.data.task_id).toBe(task.id);
});
it("a task given up killed at its last write is neither FAILED nor told; given up whole, it is both", async () => {
const driver = new FakeDriver();
const killed = killedAt(db, "inbound", "drop");
const scheduler = make(driver, killed.db);
const dot = await scheduler.createDot(yaml("killed-give-up"));
await waitUntilSettledReady(scheduler, driver, dot.id, "killed-give-up");
await scheduler.lifecycle.stop(dot.id, "user");
driver.failNext("start", new Error("qemu-system-x86_64 exited: boom"), 3);
const failures = async (taskId: string) =>
(await db.query<{ failures: number }>("SELECT failures FROM inbound_events WHERE task_id = $1", [taskId])).rows[0]?.failures ?? 0;
killed.kill();
const task = await scheduler.createTask(dot.id, { description: "undeliverable" });
await waitFor(async () => (await failures(task.id)) >= 2, "its attempts used up");
await scheduler.settle();
expect((await db.tasks.get(task.id))?.status).toBe("RUNNING");
expect(await typesOf(dot.id, "task.failed")).toEqual([]);
// The next attempt gives it up whole: the state and its event in one commit.
killed.revive();
await scheduler.inbound.kick(dot.id);
expect((await db.tasks.get(task.id))?.status).toBe("FAILED");
expect(await typesOf(dot.id, "task.failed")).toEqual(["task.failed"]);
});
it("a config killed at its save is not announced, and a saved one is, in the same commit", async () => {
const driver = new FakeDriver();
const killed = killedAt(db, "dots", "updateConfig");
const scheduler = make(driver, killed.db);
const dot = await scheduler.createDot(yaml("killed-config"));
await waitUntilSettledReady(scheduler, driver, dot.id, "killed-config");
const before = (await typesOf(dot.id, "dot.updated")).length;
killed.kill();
await expect(scheduler.updateDot(dot.id, `${yaml("killed-config")}instructions: be brief\n`)).rejects.toThrow(/killed at dots.updateConfig/);
expect(await typesOf(dot.id, "dot.updated")).toHaveLength(before);
expect((await db.dots.get(dot.id))?.config.instructions).toBeUndefined();
killed.revive();
await scheduler.updateDot(dot.id, `${yaml("killed-config")}instructions: be brief\n`);
expect(await typesOf(dot.id, "dot.updated")).toHaveLength(before + 1);
expect((await db.dots.get(dot.id))?.config.instructions).toBe("be brief");
});
it("the person's stop of a computer that is asleep is told, so that every view learns the automations will not wake it", async () => {
const driver = new FakeDriver();
const scheduler = make(driver);
const dot = await scheduler.createDot(yaml("asleep-then-stopped"));
await waitUntilSettledReady(scheduler, driver, dot.id, "asleep-then-stopped");
await scheduler.lifecycle.stop(dot.id, "idle");
expect(await db.computers.get(dot.id)).toMatchObject({ state: "STOPPED", stop_reason: "idle" });
const stops = async () => (await db.events.list({ dotId: dot.id, types: ["computer.stopped"] })).map((e) => e.data);
expect(await stops()).toEqual([{ reason: "idle", forced: false }]);
await scheduler.stopComputer(dot.id);
await scheduler.settle();
expect(await db.computers.get(dot.id)).toMatchObject({ state: "STOPPED", stop_reason: "user" });
expect(await stops()).toEqual([{ reason: "idle", forced: false }, { reason: "user", forced: false }]);
// Asked again, it changes nothing and says nothing more.
await scheduler.stopComputer(dot.id);
await scheduler.settle();
expect(await stops()).toHaveLength(2);
});
});
+379
View File
@@ -0,0 +1,379 @@
/**
* Races and failures around getting work to a guest: what is stored for a
* guest survives a failed wake and a restart, a cancel never loses to a
* slow wake, a sleep never swallows work, and what the guest must hold (its
* key, its config) is pushed again whenever it may have lost it.
*/
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import type { InboundEvent, OutboundEvent, SecretsRequest } from "@invisible-dots/shared";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { Scheduler, type SchedulerOptions } from "../src/index.js";
import { type Logger } from "@invisible-dots/shared";
import type { GuestApi, GuestEndpoint } from "../src/index.js";
import { FakeDriver, FakeGuest, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
const yaml = (name: string, instructions = "keep watch") =>
`name: ${name}\ninstructions: ${instructions}\nmodel:\n provider: openrouter\n id: test/model\ncomputer:\n idle_timeout: 10m\n`;
const KEY = "sk-or-delivery-test-key-0123456789";
/** A gate a fake waits on until the test opens it. */
function gate(): { wait: Promise<void>; open(): void; reached: Promise<void>; arrive(): void } {
let open!: () => void;
let arrive!: () => void;
const wait = new Promise<void>((resolve) => (open = resolve));
const reached = new Promise<void>((resolve) => (arrive = resolve));
return { wait, open, reached, arrive };
}
const received = (guest: FakeGuest, type: InboundEvent["type"]) => guest.inbound.filter((e) => e.type === type);
describe.each(testAdapters())("delivery to guests (%s)", (kind) => {
let t: TestDatabase;
let db: Database;
const open: Scheduler[] = [];
beforeAll(async () => {
t = await createTestDatabase(kind);
db = t.db;
await db.secrets.put("global", "openrouter_api_key", KEY);
});
afterEach(async () => {
await Promise.all(open.splice(0).map((s) => s.close()));
});
afterAll(async () => {
await t?.drop();
});
function make(driver: FakeDriver = new FakeDriver(), extra: Partial<SchedulerOptions> = {}) {
const clock = new ManualClock();
const scheduler = new Scheduler({
db,
driver,
clock,
dispatchIntervalMs: 60_000,
idleCheckIntervalMs: 60_000,
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 },
dispatcher: { retryDelayMs: 0, maxDeliveryAttempts: 2 },
...extra,
});
open.push(scheduler);
drivers.set(scheduler, driver);
return { scheduler, driver, clock };
}
const drivers = new Map<Scheduler, FakeDriver>();
const driverOf = (s: Scheduler) => drivers.get(s)!;
async function readyDot(s: Scheduler, name: string) {
const dot = await s.createDot(yaml(name));
await waitUntilSettledReady(s, driverOf(s), dot.id, name);
return dot;
}
/** A guest that asks for an approval inside every task. */
function asksForApproval(guest: FakeGuest): void {
const original = guest.onInbound;
guest.onInbound = (event, g) => {
if (event.type === "task.created") {
g.emit("task.started", { task_id: event.data.task_id });
g.requestApproval(event.data.task_id);
} else {
return original(event, g);
}
};
}
it("an approval for a sleeping Dot whose wake fails is kept and reaches the guest at the next READY", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "approve-later");
const guest = driver.guestOf(dot.id);
asksForApproval(guest);
const task = await scheduler.createTask(dot.id, { description: "needs a yes" });
const approval = await waitFor(async () => (await scheduler.listApprovals("pending")).find((a) => a.task_id === task.id), "pending approval");
await scheduler.lifecycle.stop(dot.id, "user");
driver.failNext("start", new Error("QEMU exited (injected)"));
await scheduler.resolveApproval(approval.id, "approve");
await scheduler.settle();
expect(received(guest, "approval.received")).toHaveLength(0);
expect((await db.inbound.pending(dot.id)).map((r) => r.event.type)).toEqual(["approval.received"]);
await scheduler.startComputer(dot.id);
await waitFor(() => received(guest, "approval.received").length === 1, "approval delivered after the next READY");
await waitFor(async () => (await db.tasks.get(task.id))?.status === "COMPLETED", "task completed");
expect(await db.inbound.pending(dot.id)).toEqual([]);
});
it("a message stored before a restart is delivered by the next control plane", async () => {
const driver = new FakeDriver();
const first = make(driver).scheduler;
const dot = await readyDot(first, "restart-chat");
await first.lifecycle.stop(dot.id, "user");
driver.failNext("start", new Error("QEMU exited (injected)"));
const answer = await first.sendMessage(dot.id, "are you there?");
expect(answer.delivery).toBe("queued");
await first.close();
open.splice(open.indexOf(first), 1);
const { scheduler } = make(driver);
await scheduler.start();
await scheduler.startComputer(dot.id);
const guest = driver.guestOf(dot.id);
await waitFor(() => received(guest, "user.message").some((e) => e.id === answer.message_id), "message delivered");
await waitFor(async () => (await scheduler.conversation(dot.id)).length === 2, "reply");
});
it("a task cancelled while its Dot wakes is never sent to the guest", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "cancel-early");
await scheduler.lifecycle.stop(dot.id, "user");
const guest = driver.guestOf(dot.id);
guest.bootPolls = 40;
const task = await scheduler.createTask(dot.id, { description: "never mind" });
await waitFor(async () => (await db.tasks.get(task.id))?.status === "RUNNING", "claimed");
expect(scheduler.lifecycle.isReady(dot.id)).toBe(false);
await scheduler.cancelTask(task.id);
await waitFor(() => scheduler.lifecycle.isReady(dot.id), "woken anyway");
await scheduler.settle();
expect((await db.tasks.get(task.id))?.status).toBe("CANCELLED");
expect(received(guest, "task.created")).toHaveLength(0);
// Nothing to cancel on a guest that never heard of the task.
expect(received(guest, "system.event")).toHaveLength(0);
});
it("a task cancelled after its send began is cancelled on the guest too", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "cancel-late");
const guest = driver.guestOf(dot.id);
guest.onInbound = (event, g) => {
if (event.type === "task.created") g.emit("task.started", { task_id: event.data.task_id });
};
const task = await scheduler.createTask(dot.id, { description: "long job" });
await waitFor(() => received(guest, "task.created").length === 1, "delivered");
await scheduler.cancelTask(task.id);
await waitFor(() => received(guest, "system.event").length === 1, "cancel delivered");
expect(received(guest, "system.event")[0]?.data).toMatchObject({ name: "task.cancelled", data: { task_id: task.id } });
});
it("an idle sleep never swallows a task that arrives while the agent prepares to sleep", async () => {
const { scheduler, driver, clock } = make();
const dot = await readyDot(scheduler, "light-sleeper");
const guest = driver.guestOf(dot.id);
const bootsAtReceipt: number[] = [];
const complete = guest.onInbound;
guest.onInbound = (event, g) => {
if (event.type === "task.created") bootsAtReceipt.push(g.boots);
return complete(event, g);
};
const sleeping = gate();
const prepare = guest.prepareSleep.bind(guest);
guest.prepareSleep = async () => {
sleeping.arrive();
await sleeping.wait;
return prepare();
};
clock.advance(11 * 60_000);
expect(await scheduler.idleCheck()).toEqual([dot.id]);
await sleeping.reached;
const task = await scheduler.createTask(dot.id, { description: "just in time" });
await scheduler.dispatcher.dispatch();
// Give a send that does not wait for the stop every chance to happen; a
// correct one waits for the Dot's lock, so nothing arrives in this window.
await waitFor(() => received(guest, "task.created").length > 0, "a send during prepare-sleep", 300).catch(() => undefined);
expect(received(guest, "task.created")).toHaveLength(0);
sleeping.open();
await waitFor(async () => (await db.tasks.get(task.id))?.status === "COMPLETED", "task completed after the wake");
// Received once, by the guest of the next boot: never by the one that was powering off.
expect(bootsAtReceipt).toEqual([2]);
expect((await db.computers.get(dot.id))?.state).toBe("RUNNING");
});
it("an idle sleep is called off when work was stored after the idle check", async () => {
const { scheduler, driver, clock } = make();
const dot = await readyDot(scheduler, "called-off");
const guest = driver.guestOf(dot.id);
// Work the idle check did not see: a message stored straight into the outbox.
clock.advance(11 * 60_000);
const message: InboundEvent<"user.message"> = { id: "msg_late", type: "user.message", ts: new Date().toISOString(), data: { text: "hi" } };
await db.inbound.enqueue(dot.id, message);
const stop = scheduler.lifecycle.stop(dot.id, "idle");
await stop;
expect((await db.computers.get(dot.id))?.state).toBe("RUNNING");
expect(guest.calls).not.toContain("prepareSleep");
expect(scheduler.lifecycle.isReady(dot.id)).toBe(true);
});
it("a stopped Dot with an unfinished task is woken for a new task", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "blocked");
const guest = driver.guestOf(dot.id);
guest.onInbound = (event, g) => {
if (event.type === "task.created") g.emit("task.started", { task_id: event.data.task_id });
};
const first = await scheduler.createTask(dot.id, { description: "unfinished" });
await waitFor(() => received(guest, "task.created").length === 1, "first delivered");
await scheduler.lifecycle.stop(dot.id, "user");
await scheduler.createTask(dot.id, { description: "waits behind it" });
await scheduler.pass();
await waitFor(() => scheduler.lifecycle.isReady(dot.id), "woken for the new task");
expect((await db.tasks.get(first.id))?.status).toBe("RUNNING");
});
it("a send that reached the guest but timed out is sent again, accepted once, and never fails the task", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "slow-ack");
const guest = driver.guestOf(dot.id);
const post = guest.postEvent.bind(guest);
let timeouts = 3;
guest.postEvent = async (event) => {
await post(event);
if (event.type === "task.created" && timeouts-- > 0) throw new FakeGuestError(0, "no answer within 30000 ms");
return { accepted: true };
};
const task = await scheduler.createTask(dot.id, { description: "acknowledged late" });
await waitFor(async () => (await db.tasks.get(task.id))?.status === "COMPLETED", "completed");
expect(received(guest, "task.created")).toHaveLength(1);
expect((await db.tasks.runs(task.id)).map((r) => r.outcome)).toEqual(["completed"]);
});
it("a cancelled task leaves no pending approval, and a late request for it is never pending", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "expiring");
const guest = driver.guestOf(dot.id);
asksForApproval(guest);
const task = await scheduler.createTask(dot.id, { description: "asks then gets cancelled" });
const approval = await waitFor(async () => (await scheduler.listApprovals("pending")).find((a) => a.task_id === task.id), "pending");
await scheduler.cancelTask(task.id);
expect((await scheduler.listApprovals("pending")).map((a) => a.id)).not.toContain(approval.id);
expect((await db.approvals.get(approval.id))?.status).toBe("expired");
await expect(scheduler.resolveApproval(approval.id, "approve")).rejects.toMatchObject({ status: 409, code: "already_resolved" });
const late = guest.requestApproval(task.id);
await waitFor(async () => (await db.approvals.get(late)) !== null, "late request stored");
expect((await db.approvals.get(late))?.status).toBe("expired");
});
it("an agent that restarts inside a running VM gets its key again", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "phoenix");
const guest = driver.guestOf(dot.id);
expect(guest.openrouterKey).toBe(KEY);
guest.restartAgent();
expect(guest.openrouterKey).toBeNull();
await waitFor(() => guest.openrouterKey === KEY, "key pushed again after the restart");
expect(guest.config?.name).toBe("phoenix");
});
it("a config changed while the Dot passes READY reaches the guest", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "late-config");
const guest = driver.guestOf(dot.id);
// The boot's agent.started is stored, and the push it starts in the
// background has finished (it runs after the event's transaction, so the
// cursor alone does not say so), so nothing but READY pushes from here on.
await waitFor(async () => (await db.computers.get(dot.id))?.event_cursor === guest.outbox.at(-1)?.seq, "boot events stored");
await scheduler.lifecycle.settle();
const pushing = gate();
const putConfig = guest.putConfig.bind(guest);
let held = false;
guest.putConfig = async (config) => {
if (!held) {
held = true;
pushing.arrive();
await pushing.wait;
}
return putConfig(config);
};
// A READY procedure on the running VM (as after a failed call or a control plane restart).
scheduler.lifecycle.markSuspect(dot.id);
const ready = scheduler.lifecycle.ensureReady(dot.id);
await pushing.reached;
const update = await scheduler.updateDot(dot.id, yaml("late-config", "a brand new instruction"));
expect(update.config.instructions).toBe("a brand new instruction");
pushing.open();
await ready;
expect(guest.config?.instructions).toBe("a brand new instruction");
});
it("a guest that echoes the key in a failed secret push never puts it in the log, the rows or the events", async () => {
const lines: string[] = [];
const record = (level: string) => (message: string, fields?: Record<string, unknown>) => lines.push(`${level} ${message} ${JSON.stringify(fields ?? {})}`);
const logger: Logger = { debug: record("debug"), info: record("info"), warn: record("warn"), error: record("error") };
const driver = new FakeDriver();
driver.configureGuest = (g) => {
g.pushSecrets = async (secrets: SecretsRequest) => {
throw new FakeGuestError(502, `upstream said: bad request body {"openrouter_api_key":"${secrets.openrouter_api_key}"}`);
};
};
const { scheduler } = make(driver, { logger });
const dot = await scheduler.createDot(yaml("echoes"));
await waitFor(async () => (await db.dots.get(dot.id))?.status === "ERROR", "ERROR");
await scheduler.settle();
const dotRow = await db.dots.get(dot.id);
const computer = await db.computers.get(dot.id);
const events = await db.events.list({ dotId: dot.id });
expect(dotRow?.error).toMatch(/did not take the OpenRouter key and the MCP secrets \(status 502\)/);
for (const text of [dotRow?.error ?? "", computer?.last_error ?? "", JSON.stringify(events), lines.join("\n")]) {
expect(text).not.toContain(KEY);
expect(text).not.toContain(KEY.slice(0, 12));
}
});
it("a VM that dies is recorded as stopped, and a new task runs on a new port with a working event pump", async () => {
/** Like the real guest client: an event stream bound to the port it was opened on, reconnecting forever. */
class StalePortDriver extends FakeDriver {
override guest(endpoint: GuestEndpoint, token: string): GuestApi {
const inner = super.guest(endpoint, token);
const vms = this.vms;
return new Proxy(inner, {
get: (target, property) => {
if (property === "then") return undefined;
if (property !== "events") {
const value: unknown = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
}
return async function* (options: { after?: number; signal?: AbortSignal }): AsyncGenerator<OutboundEvent> {
while (!options.signal?.aborted) {
if (vms.get(endpoint.dotId)?.guestPort === endpoint.port) {
try {
yield* target.events(options);
} catch {
// reconnects, as GuestClient does on a reset
}
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
};
},
});
}
}
const driver = new StalePortDriver();
const { scheduler } = make(driver);
const dot = await readyDot(scheduler, "lazarus");
const before = (await db.computers.get(dot.id))?.guest_port;
driver.crash(dot.id);
const task = await scheduler.createTask(dot.id, { description: "after the crash" });
await waitFor(async () => (await db.tasks.get(task.id))?.status === "COMPLETED", "task completed on the new boot");
expect((await db.computers.get(dot.id))?.guest_port).not.toBe(before);
});
it("a VM that powers itself off with a task in progress is started again", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "self-stopper");
const guest = driver.guestOf(dot.id);
guest.onInbound = (event, g) => {
if (event.type === "task.created") g.emit("task.started", { task_id: event.data.task_id });
};
await scheduler.createTask(dot.id, { description: "keeps going" });
await waitFor(() => received(guest, "task.created").length === 1, "delivered");
const boots = guest.boots;
driver.crash(dot.id);
await waitFor(async () => (await db.events.list({ dotId: dot.id, types: ["computer.stopped"] })).some((e) => e.data.reason === "exited"), "stop recorded");
await waitFor(() => guest.boots === boots + 1 && scheduler.lifecycle.isReady(dot.id), "started again");
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* The engine's answer to `GET /tools` and the data of every event it writes, against the host's description of them. The engine's own
* test (`invisible_engine_dots/tests/dots/test_wire_shapes.py`) writes what it really answers, and one event of each
* type and each set of keys its own writers produce, into `wire_shapes.json`; here that file is parsed with the
* schemas of `packages/shared` and the host's fake guest is held to the same answers, so neither side can move a key,
* or a rule of what the model is offered, without a suite failing. The host's schemas strip a key they do not know
* and the host drops an event they refuse, so an event must come out of its parse exactly as it went in.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
MAX_RUN_AT_MS,
mcpServerStatusSchema,
OUTBOUND_EVENT_TYPES,
parseDotConfig,
parseOutboundEvent,
PERMISSIONS,
skillSchema,
toolInfoSchema,
toRuntimeConfig,
type DotRuntimeConfig,
} from "@invisible-dots/shared";
import { describe, expect, it } from "vitest";
import { FakeGuest, type FakeAutomation } from "../src/testing.js";
interface OfferingCase {
permissions: Record<string, string>;
offered: string[];
tools: unknown[];
}
const shapes = JSON.parse(
readFileSync(fileURLToPath(new URL("../../../invisible_engine_dots/tests/dots/wire_shapes.json", import.meta.url)), "utf8"),
) as {
limits: { max_run_at_ms: number };
outbound_events: { type: string; data: Record<string, unknown> }[];
tool_offering: OfferingCase[];
mcp: { tools: unknown[]; mcp_servers: unknown[] };
skills: unknown[];
};
const baseConfig = toRuntimeConfig(parseDotConfig("name: shapes\nmodel:\n provider: openrouter\n id: test/model\n"));
function configFor(offering: OfferingCase): DotRuntimeConfig {
return {
...baseConfig,
permissions: offering.permissions as DotRuntimeConfig["permissions"],
};
}
describe("what the engine answers, as the host describes it", () => {
it("the last time the engine lets an automation run is the last time the host's schemas accept", () => {
expect(shapes.limits.max_run_at_ms).toBe(MAX_RUN_AT_MS);
const nextRun = (next_run_at_ms: number) => ({ seq: 1, id: "evt_x", type: "automation.next_run", ts: "2026-10-06T09:00:00.000Z", data: { next_run_at_ms } });
expect(parseOutboundEvent(nextRun(MAX_RUN_AT_MS)).data).toEqual({ next_run_at_ms: MAX_RUN_AT_MS });
expect(() => parseOutboundEvent(nextRun(MAX_RUN_AT_MS + 1))).toThrow();
expect(new Date(MAX_RUN_AT_MS).toISOString()).toBe("9999-12-31T23:59:59.999Z");
});
it("every outbound event the engine writes comes out of the host's parse as it went in: none is refused and no key is stripped", () => {
for (const [index, { type, data }] of shapes.outbound_events.entries()) {
const event = { seq: index + 1, id: `evt_${index}`, type, ts: "2026-10-06T09:00:00.000Z", data };
expect(parseOutboundEvent(event).data, JSON.stringify(event)).toEqual(data);
}
// One of every type the contract names, so a type the engine writes and the host does not know (or the reverse) is seen.
expect([...new Set(shapes.outbound_events.map((event) => event.type))].sort()).toEqual([...OUTBOUND_EVENT_TYPES].sort());
});
it("a key the schema does not know is stripped by the parse, which the check above sees, and a key it needs and the engine lacks is refused", () => {
const called = shapes.outbound_events.find((event) => event.type === "tool.called")!;
const around = (event: { type: string; data: Record<string, unknown> }, data: Record<string, unknown>) => ({ seq: 1, id: "evt_x", type: event.type, ts: "2026-10-06T09:00:00.000Z", data });
expect(parseOutboundEvent(around(called, { ...called.data, renamed_by_the_engine: true })).data).not.toEqual({ ...called.data, renamed_by_the_engine: true });
const { tool: _tool, ...withoutTool } = called.data;
expect(() => parseOutboundEvent(around(called, withoutTool))).toThrow();
});
it("the optional keys of the events are all written by the engine: a terminal, an interrupted call, a task's calls, an approval with and without a task, a reply to a message, the spend", () => {
const keys = (type: string) => shapes.outbound_events.filter((event) => event.type === type).map((event) => Object.keys(event.data).sort().join());
expect(keys("tool.called")).toEqual(expect.arrayContaining(["decision,duration_ms,ok,permission,target,tool,tty", "decision,duration_ms,interrupted,ok,permission,target,task_id,tool"]));
expect(keys("approval.requested")).toEqual(expect.arrayContaining(["approval_id,arguments,permission,reason,task_id,tool", "approval_id,arguments,permission,reason,tool"]));
expect(keys("message.assistant")).toEqual(["in_reply_to,spent_usd,text"]);
expect(keys("task.progress")).toEqual(["spent_usd,task_id,text"]);
expect(keys("task.failed")).toEqual(["error,spent_usd,task_id"]);
expect(keys("task.completed")).toEqual(["spent_usd,summary,task_id"]);
});
it("the host's fake guest writes the automation events as the engine does", async () => {
const written = shapes.outbound_events.filter((event) => event.type === "automation.next_run");
expect(written.map((event) => event.data.next_run_at_ms)).toEqual([1_790_000_000_000, null]);
const guest = new FakeGuest("token-for-shapes");
guest.running = true;
const row: FakeAutomation = {
id: "job_every",
name: "check the shop",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
message: "look at the orders",
next_run_at_ms: 1_790_000_000_000,
last_run_at_ms: null,
last_status: null,
last_error: null,
delete_after_run: false,
created_at_ms: 1_789_990_000_000,
};
guest.putAutomation(row);
expect(guest.outbox.map((event) => ({ type: event.type, data: event.data }))).toEqual([written[0]]);
guest.removeAutomation(row.id);
expect(guest.outbox.map((event) => ({ type: event.type, data: event.data }))).toEqual(written);
});
it("every skill the engine shows parses with the skill schema, and a key it lacks or has extra is refused", () => {
expect(shapes.skills.map((row) => (row as { name: string }).name)).toContain("invisible-playwright");
for (const row of shapes.skills) expect(skillSchema.safeParse(row).error?.issues, JSON.stringify(row)).toBeUndefined();
const first = shapes.skills[0] as Record<string, unknown>;
const { content: _content, ...missing } = first;
expect(skillSchema.safeParse(missing).success).toBe(false);
expect(skillSchema.safeParse({ ...first, renamed_key: 1 }).success).toBe(false);
});
it("every tool row the engine shows parses with the tool schema, in the order of its table, under a permission the host knows", () => {
for (const offering of shapes.tool_offering) {
for (const row of offering.tools) expect(toolInfoSchema.safeParse(row).error?.issues, JSON.stringify(row)).toBeUndefined();
const names = (offering.tools as { name: string }[]).map((row) => row.name);
expect(names).toEqual((shapes.tool_offering[0]!.tools as { name: string }[]).map((row) => row.name));
expect((offering.tools as { permission: string }[]).every((row) => PERMISSIONS.includes(row.permission as never))).toBe(true);
}
});
it("what the engine says of the MCP servers a config declares parses with the host's schemas: their tools under `mcp.<server>`, their states", () => {
expect(shapes.mcp.tools.length).toBeGreaterThan(0);
for (const row of shapes.mcp.tools) expect(toolInfoSchema.safeParse(row).error?.issues, JSON.stringify(row)).toBeUndefined();
expect(new Set((shapes.mcp.tools as { permission: string }[]).map((row) => row.permission))).toEqual(new Set(["mcp.tools"]));
for (const status of shapes.mcp.mcp_servers) expect(mcpServerStatusSchema.safeParse(status).error?.issues, JSON.stringify(status)).toBeUndefined();
expect((shapes.mcp.mcp_servers as { name: string; state: string }[]).map((status) => [status.name, status.state])).toEqual([["keyed", "failed"], ["tools", "connected"]]);
expect(toolInfoSchema.safeParse({ ...(shapes.mcp.tools[0] as object), permission: "mcp.Not_A_Server" }).success).toBe(false);
});
it("the fake guest holds the engine's tools to the same permissions, and offers what the engine offers for the same config", async () => {
const engineRows = new Map((shapes.tool_offering[0]!.tools as { name: string; permission: string }[]).map((row) => [row.name, row.permission]));
for (const offering of shapes.tool_offering) {
const guest = new FakeGuest("token-for-shapes");
guest.running = true;
guest.config = configFor(offering);
const { tools } = await guest.listTools();
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) {
expect(engineRows.get(tool.name), `the engine has no tool ${tool.name}`).toBe(tool.permission);
expect(tool.offered, `${tool.name} with ${JSON.stringify(offering.permissions)}`).toBe(offering.offered.includes(tool.name));
}
}
});
});
@@ -0,0 +1,768 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 18.4
-- Dumped by pg_dump version 18.4
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: approvals; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.approvals (
id text NOT NULL,
dot_id text NOT NULL,
task_id text,
tool text NOT NULL,
permission text NOT NULL,
arguments jsonb NOT NULL,
reason text NOT NULL,
status text NOT NULL,
note text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
resolved_at timestamp with time zone,
CONSTRAINT approvals_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'approved'::text, 'rejected'::text, 'expired'::text])))
);
--
-- Name: channel_bindings; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.channel_bindings (
id text NOT NULL,
dot_id text NOT NULL,
kind text NOT NULL,
enabled boolean DEFAULT true NOT NULL,
settings jsonb NOT NULL,
status text NOT NULL,
status_detail text,
account text,
event_cursor bigint NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT channel_bindings_event_cursor_check CHECK ((event_cursor >= 0)),
CONSTRAINT channel_bindings_kind_check CHECK ((kind = ANY (ARRAY['telegram'::text, 'whatsapp'::text]))),
CONSTRAINT channel_bindings_status_check CHECK ((status = ANY (ARRAY['connecting'::text, 'connected'::text, 'needs_relink'::text, 'error'::text])))
);
--
-- Name: channel_pairings; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.channel_pairings (
binding_id text NOT NULL,
code_hash text NOT NULL,
expires_at timestamp with time zone NOT NULL,
consumed_at timestamp with time zone
);
--
-- Name: channel_peers; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.channel_peers (
binding_id text NOT NULL,
peer_id text NOT NULL,
chat_id text NOT NULL,
role text NOT NULL,
label text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT channel_peers_role_check CHECK ((role = ANY (ARRAY['owner'::text, 'user'::text])))
);
--
-- Name: channel_prompts; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.channel_prompts (
binding_id text NOT NULL,
approval_id text NOT NULL,
chat_id text NOT NULL,
ref text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: computers; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.computers (
dot_id text NOT NULL,
vm_name text NOT NULL,
guest_port integer,
pid integer,
state text NOT NULL,
golden_image text,
runtime_image text,
token_enc bytea NOT NULL,
event_cursor bigint DEFAULT 0 NOT NULL,
last_active_at timestamp with time zone,
last_error text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
next_automation_at timestamp with time zone,
stop_reason text,
CONSTRAINT computers_guest_port_check CHECK (((guest_port >= 1) AND (guest_port <= 65535))),
CONSTRAINT computers_pid_check CHECK ((pid > 0)),
CONSTRAINT computers_state_check CHECK ((state = ANY (ARRAY['PROVISIONING'::text, 'STARTING'::text, 'RUNNING'::text, 'IDLE'::text, 'STOPPING'::text, 'STOPPED'::text, 'ERROR'::text, 'DELETING'::text]))),
CONSTRAINT computers_stop_reason_check CHECK ((stop_reason = ANY (ARRAY['idle'::text, 'user'::text, 'exited'::text])))
);
--
-- Name: dots; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.dots (
id text NOT NULL,
name text NOT NULL,
config jsonb NOT NULL,
status text NOT NULL,
error text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
config_version integer DEFAULT 1 NOT NULL,
CONSTRAINT dots_status_check CHECK ((status = ANY (ARRAY['CREATING'::text, 'READY'::text, 'IDLE'::text, 'RUNNING'::text, 'WAITING_APPROVAL'::text, 'ERROR'::text, 'DISABLED'::text])))
);
--
-- Name: events; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.events (
id bigint NOT NULL,
dot_id text NOT NULL,
type text NOT NULL,
data jsonb NOT NULL,
source text NOT NULL,
guest_seq bigint,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT events_check CHECK (((source = 'guest'::text) = (guest_seq IS NOT NULL))),
CONSTRAINT events_source_check CHECK ((source = ANY (ARRAY['host'::text, 'guest'::text])))
);
--
-- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.events_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id;
--
-- Name: inbound_events; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.inbound_events (
seq bigint NOT NULL,
id text NOT NULL,
dot_id text NOT NULL,
type text NOT NULL,
data jsonb NOT NULL,
ts text NOT NULL,
task_id text,
run_id text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
sent_at timestamp with time zone,
delivered_at timestamp with time zone,
dropped_at timestamp with time zone,
drop_reason text,
failures integer DEFAULT 0 NOT NULL,
last_error text,
retry_at timestamp with time zone,
CONSTRAINT inbound_events_type_check CHECK ((type = ANY (ARRAY['user.message'::text, 'task.created'::text, 'approval.received'::text, 'system.event'::text])))
);
--
-- Name: inbound_events_seq_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.inbound_events_seq_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: inbound_events_seq_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.inbound_events_seq_seq OWNED BY public.inbound_events.seq;
--
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.schema_migrations (
version text NOT NULL,
applied_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: secrets; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.secrets (
scope text NOT NULL,
name text NOT NULL,
value_enc bytea NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: task_runs; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.task_runs (
id text NOT NULL,
task_id text NOT NULL,
started_at timestamp with time zone DEFAULT now() NOT NULL,
delivered_at timestamp with time zone,
finished_at timestamp with time zone,
outcome text
);
--
-- Name: tasks; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.tasks (
id text NOT NULL,
dot_id text NOT NULL,
description text NOT NULL,
priority integer DEFAULT 0 NOT NULL,
status text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
scheduled_at timestamp with time zone,
started_at timestamp with time zone,
finished_at timestamp with time zone,
summary text,
error text,
spent_usd double precision DEFAULT 0 NOT NULL,
CONSTRAINT tasks_spent_usd_check CHECK ((spent_usd >= (0)::double precision)),
CONSTRAINT tasks_status_check CHECK ((status = ANY (ARRAY['PENDING'::text, 'RUNNING'::text, 'WAITING_APPROVAL'::text, 'COMPLETED'::text, 'FAILED'::text, 'CANCELLED'::text])))
);
--
-- Name: events id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass);
--
-- Name: inbound_events seq; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.inbound_events ALTER COLUMN seq SET DEFAULT nextval('public.inbound_events_seq_seq'::regclass);
--
-- Data for Name: approvals; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.approvals VALUES ('apr_01m4ceepvn47ypbkcpd5cymdey', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task_01m4ceeptfsmdeddevy4770sbk', 'browser_identity_delete', 'browser.identity.delete', '{"identity_id": "shop-abc123"}', 'the tool needs approval', 'approved', 'fine', '2026-10-08 00:26:18.038234+00', '2026-10-08 00:26:18.071102+00');
INSERT INTO public.approvals VALUES ('apr_01m4ceepzq74326e3e6vrheqvj', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task_01m4ceepz48cetk8s7k7zwkst5', 'browser_identity_delete', 'browser.identity.delete', '{"identity_id": "shop-abc123"}', 'the tool needs approval', 'pending', NULL, '2026-10-08 00:26:18.153524+00', NULL);
--
-- Data for Name: channel_bindings; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.channel_bindings VALUES ('chb_upgrade', 'dot_01m4ceepkrt609r0tde6hm21hb', 'telegram', true, '{"approvals": true, "notify_tasks": true, "show_arguments": false}', 'connecting', NULL, 'fare_watch_bot', 0, '2026-10-08 00:26:18.181416+00');
--
-- Data for Name: channel_pairings; Type: TABLE DATA; Schema: public; Owner: -
--
--
-- Data for Name: channel_peers; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.channel_peers VALUES ('chb_upgrade', '4242', '4242', 'owner', 'Federico', '2026-10-08 00:26:18.187194+00');
--
-- Data for Name: channel_prompts; Type: TABLE DATA; Schema: public; Owner: -
--
--
-- Data for Name: computers; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.computers VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'invisible-dot-dot_01m4ceepkrt609r0tde6hm21hb', 47000, 9000, 'RUNNING', 'images/golden-test.qcow2', 'images/runtime-test.iso', '\xd8f1c54703e75012cfac1404e670ebfdb057db12ea08404cd0e3b7367c02826ead80d0e36069ab2c190ee026c9ef5059590e1c32c10d787db872b944b7da832b635e4b9117ced9', 16, '2026-10-07 09:00:00+00', NULL, '2026-10-08 00:26:18.181416+00', '2026-10-09 00:23:53.167+00', NULL);
INSERT INTO public.computers VALUES ('dot_01m4ceeq1d5fgv96gmxs71ht30', 'invisible-dot-dot_01m4ceeq1d5fgv96gmxs71ht30', NULL, NULL, 'STOPPED', 'images/golden-test.qcow2', 'images/runtime-test.iso', '\x292befaaec6513a1a36ee6a6efa250c5eaba66e80bc9c2e7bca66bf26f3ece3c52f63b6c00d9eaec7a44d828176b1994b779f5cc635738a95529d12917459e3b65ed4a130f74d4', 1, '2026-10-07 09:00:00+00', NULL, '2026-10-08 00:26:18.281007+00', NULL, 'user');
--
-- Data for Name: dots; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.dots VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'fare-watch', '{"name": "fare-watch", "model": {"id": "z-ai/glm-5.3-flash", "provider": "openrouter"}, "limits": {"context_tokens": 32000, "max_steps_per_task": 60, "max_cost_per_task_usd": 1}, "models": {}, "computer": {"cpu": 2, "disk": "40gb", "memory": "4gb", "idle_timeout": "15m"}, "permissions": {"files.write": "ask"}}', 'WAITING_APPROVAL', NULL, '2026-10-08 00:26:17.77945+00', '2026-10-08 00:26:18.145257+00', 3);
INSERT INTO public.dots VALUES ('dot_01m4ceeq1d5fgv96gmxs71ht30', 'minimal-dot', '{"name": "minimal-dot", "model": {"id": "z-ai/glm-5.3-flash", "provider": "openrouter"}, "limits": {"context_tokens": 32000, "max_steps_per_task": 60, "max_cost_per_task_usd": 1}, "models": {}, "computer": {"cpu": 2, "disk": "40gb", "memory": "4gb", "idle_timeout": "15m"}, "permissions": {"files.write": "ask"}}', 'IDLE', NULL, '2026-10-08 00:26:18.190622+00', '2026-10-08 00:26:18.281007+00', 1);
--
-- Data for Name: events; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.events VALUES (1, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.created', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:17.77945+00');
INSERT INTO public.events VALUES (2, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "PROVISIONING"}', 'host', NULL, '2026-10-08 00:26:17.77945+00');
INSERT INTO public.events VALUES (3, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "STARTING"}', 'host', NULL, '2026-10-08 00:26:17.815323+00');
INSERT INTO public.events VALUES (4, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.state', '{"state": "RUNNING"}', 'host', NULL, '2026-10-08 00:26:17.823904+00');
INSERT INTO public.events VALUES (5, 'dot_01m4ceepkrt609r0tde6hm21hb', 'computer.started', '{"guest_port": 47000, "runtime_image": "images/runtime-test.iso"}', 'host', NULL, '2026-10-08 00:26:17.823904+00');
INSERT INTO public.events VALUES (6, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.started', '{"guest_ts": "2026-10-08T00:26:17.640Z", "guest_event_id": "evt_01m4ceepn88vyrgn8zp99tx0zz"}', 'guest', 1, '2026-10-08 00:26:17.862351+00');
INSERT INTO public.events VALUES (7, 'dot_01m4ceepkrt609r0tde6hm21hb', 'user.message', '{"text": "Remember: my favourite colour is teal.", "message_id": "msg_01m4ceepq4w4wdyp8tvy7v060r"}', 'host', NULL, '2026-10-08 00:26:17.880657+00');
INSERT INTO public.events VALUES (8, 'dot_01m4ceepkrt609r0tde6hm21hb', 'message.assistant', '{"text": "echo: Remember: my favourite colour is teal.", "guest_ts": "2026-10-08T00:26:17.720Z", "in_reply_to": "msg_01m4ceepq4w4wdyp8tvy7v060r", "guest_event_id": "evt_01m4ceepqrxs5240rvb8cmrtee"}', 'guest', 2, '2026-10-08 00:26:17.899743+00');
INSERT INTO public.events VALUES (9, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "priority": 0, "description": "List the workspace."}', 'host', NULL, '2026-10-08 00:26:17.910436+00');
INSERT INTO public.events VALUES (10, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "THINKING", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3b18qmjz7x3acqxr7"}', 'guest', 3, '2026-10-08 00:26:17.940112+00');
INSERT INTO public.events VALUES (11, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps31c9j93jpd8nb3gz9"}', 'guest', 4, '2026-10-08 00:26:17.948021+00');
INSERT INTO public.events VALUES (12, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.completed', '{"summary": "done: List the workspace.", "task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3hawexcrf77xqs9wq"}', 'guest', 5, '2026-10-08 00:26:17.956503+00');
INSERT INTO public.events VALUES (13, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "IDLE", "guest_ts": "2026-10-08T00:26:17.763Z", "guest_event_id": "evt_01m4ceeps3parat5eey42mqgsj"}', 'guest', 6, '2026-10-08 00:26:17.966985+00');
INSERT INTO public.events VALUES (14, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "priority": 0, "description": "Delete the old identity."}', 'host', NULL, '2026-10-08 00:26:17.981828+00');
INSERT INTO public.events VALUES (15, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.845Z", "guest_event_id": "evt_01m4ceepvn3vzncer00rmzep10"}', 'guest', 7, '2026-10-08 00:26:18.018234+00');
INSERT INTO public.events VALUES (16, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "WAITING_APPROVAL", "guest_ts": "2026-10-08T00:26:17.845Z", "guest_event_id": "evt_01m4ceepvnc1nwxjjf3q3w032b"}', 'guest', 8, '2026-10-08 00:26:18.029602+00');
INSERT INTO public.events VALUES (17, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.requested', '{"tool": "browser_identity_delete", "reason": "the tool needs approval", "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.845Z", "arguments": {"identity_id": "shop-abc123"}, "permission": "browser.identity.delete", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey", "guest_event_id": "evt_01m4ceepvnz1rg79k8y3p2hh5g"}', 'guest', 9, '2026-10-08 00:26:18.038234+00');
INSERT INTO public.events VALUES (18, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.resolved', '{"note": "fine", "always": true, "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "decision": "approve", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey"}', 'host', NULL, '2026-10-08 00:26:18.071102+00');
INSERT INTO public.events VALUES (19, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.updated', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:18.071102+00');
INSERT INTO public.events VALUES (20, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "EXECUTING", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy4fbsgfqd96zgj6t4v"}', 'guest', 10, '2026-10-08 00:26:18.091455+00');
INSERT INTO public.events VALUES (21, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.completed', '{"summary": "approval approved", "task_id": "task_01m4ceeptfsmdeddevy4770sbk", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy4ea0fjr9b6g6kpk29"}', 'guest', 11, '2026-10-08 00:26:18.100452+00');
INSERT INTO public.events VALUES (22, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "IDLE", "guest_ts": "2026-10-08T00:26:17.924Z", "guest_event_id": "evt_01m4ceepy48vx98jjv03q60ajm"}', 'guest', 12, '2026-10-08 00:26:18.107693+00');
INSERT INTO public.events VALUES (23, 'dot_01m4ceepkrt609r0tde6hm21hb', 'dot.updated', '{"name": "fare-watch"}', 'host', NULL, '2026-10-08 00:26:18.111019+00');
INSERT INTO public.events VALUES (24, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "priority": 0, "description": "Delete the shop identity."}', 'host', NULL, '2026-10-08 00:26:18.121808+00');
INSERT INTO public.events VALUES (25, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.started', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "guest_ts": "2026-10-08T00:26:17.975Z", "guest_event_id": "evt_01m4ceepzqq05k889g7kweerrc"}', 'guest', 13, '2026-10-08 00:26:18.139836+00');
INSERT INTO public.events VALUES (26, 'dot_01m4ceepkrt609r0tde6hm21hb', 'agent.state', '{"state": "WAITING_APPROVAL", "guest_ts": "2026-10-08T00:26:17.975Z", "guest_event_id": "evt_01m4ceepzqxj1pzcgx1zba0mcr"}', 'guest', 14, '2026-10-08 00:26:18.145257+00');
INSERT INTO public.events VALUES (27, 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.requested', '{"tool": "browser_identity_delete", "reason": "the tool needs approval", "task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "guest_ts": "2026-10-08T00:26:17.975Z", "arguments": {"identity_id": "shop-abc123"}, "permission": "browser.identity.delete", "approval_id": "apr_01m4ceepzq74326e3e6vrheqvj", "guest_event_id": "evt_01m4ceepzq7pb07ekz37zhskey"}', 'guest', 15, '2026-10-08 00:26:18.153524+00');
INSERT INTO public.events VALUES (28, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeq0s2zdfycr7vwnwy9s8", "priority": 0, "description": "Check the fares again."}', 'host', NULL, '2026-10-08 00:26:18.171462+00');
INSERT INTO public.events VALUES (29, 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.cancelled', '{"task_id": "task_01m4ceeq0s2zdfycr7vwnwy9s8"}', 'host', NULL, '2026-10-08 00:26:18.176182+00');
INSERT INTO public.events VALUES (30, 'dot_01m4ceepkrt609r0tde6hm21hb', 'automation.next_run', '{"guest_ts": "2026-10-08T00:26:18.019Z", "guest_event_id": "evt_01m4ceeq13g8b426wcn3432mbv", "next_run_at_ms": 1791505433167}', 'guest', 16, '2026-10-08 00:26:18.181416+00');
INSERT INTO public.events VALUES (31, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'dot.created', '{"name": "minimal-dot"}', 'host', NULL, '2026-10-08 00:26:18.190622+00');
INSERT INTO public.events VALUES (32, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "PROVISIONING"}', 'host', NULL, '2026-10-08 00:26:18.190622+00');
INSERT INTO public.events VALUES (33, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STARTING"}', 'host', NULL, '2026-10-08 00:26:18.203391+00');
INSERT INTO public.events VALUES (34, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "RUNNING"}', 'host', NULL, '2026-10-08 00:26:18.208314+00');
INSERT INTO public.events VALUES (35, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.started', '{"guest_port": 47001, "runtime_image": "images/runtime-test.iso"}', 'host', NULL, '2026-10-08 00:26:18.208314+00');
INSERT INTO public.events VALUES (36, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'agent.started', '{"guest_ts": "2026-10-08T00:26:18.048Z", "guest_event_id": "evt_01m4ceeq206y59sjefjm80tgar"}', 'guest', 1, '2026-10-08 00:26:18.237511+00');
INSERT INTO public.events VALUES (37, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STOPPING"}', 'host', NULL, '2026-10-08 00:26:18.260481+00');
INSERT INTO public.events VALUES (38, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.state', '{"state": "STOPPED"}', 'host', NULL, '2026-10-08 00:26:18.281007+00');
INSERT INTO public.events VALUES (39, 'dot_01m4ceeq1d5fgv96gmxs71ht30', 'computer.stopped', '{"forced": false, "reason": "user"}', 'host', NULL, '2026-10-08 00:26:18.281007+00');
--
-- Data for Name: inbound_events; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.inbound_events VALUES (1, 'msg_01m4ceepq4w4wdyp8tvy7v060r', 'dot_01m4ceepkrt609r0tde6hm21hb', 'user.message', '{"text": "Remember: my favourite colour is teal."}', '2026-10-07T09:00:00.000Z', NULL, NULL, '2026-10-08 00:26:17.880657+00', '2026-10-08 00:26:17.892461+00', '2026-10-08 00:26:17.899719+00', NULL, NULL, 0, NULL, NULL);
INSERT INTO public.inbound_events VALUES (2, 'evt_01m4ceeprmq0dfftt3ejd0v7qc', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepr4pcpyejkrwmwszmvy", "priority": 0, "description": "List the workspace."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceepr4pcpyejkrwmwszmvy', 'run_01m4ceeprj3zwtyjmrn8wpefzz', '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.933733+00', '2026-10-08 00:26:17.940076+00', NULL, NULL, 0, NULL, NULL);
INSERT INTO public.inbound_events VALUES (3, 'evt_01m4ceepv0asgrb9zz7j54fjsp', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceeptfsmdeddevy4770sbk", "priority": 0, "description": "Delete the old identity."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceeptfsmdeddevy4770sbk', 'run_01m4ceeptymc59t9c34rmqzq8v', '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.010516+00', '2026-10-08 00:26:18.018374+00', NULL, NULL, 0, NULL, NULL);
INSERT INTO public.inbound_events VALUES (4, 'evt_01m4ceepxe33m1qnqvsfbzvkvc', 'dot_01m4ceepkrt609r0tde6hm21hb', 'approval.received', '{"note": "fine", "decision": "approve", "approval_id": "apr_01m4ceepvn47ypbkcpd5cymdey"}', '2026-10-07T09:00:00.000Z', NULL, NULL, '2026-10-08 00:26:18.071102+00', '2026-10-08 00:26:18.087155+00', '2026-10-08 00:26:18.091459+00', NULL, NULL, 0, NULL, NULL);
INSERT INTO public.inbound_events VALUES (5, 'evt_01m4ceepzer7yzzxfn4t1hwway', 'dot_01m4ceepkrt609r0tde6hm21hb', 'task.created', '{"task_id": "task_01m4ceepz48cetk8s7k7zwkst5", "priority": 0, "description": "Delete the shop identity."}', '2026-10-07T09:00:00.000Z', 'task_01m4ceepz48cetk8s7k7zwkst5', 'run_01m4ceepzd75vf6eyq6s83tvxf', '2026-10-08 00:26:18.126436+00', '2026-10-08 00:26:18.135244+00', '2026-10-08 00:26:18.139817+00', NULL, NULL, 0, NULL, NULL);
--
-- Data for Name: schema_migrations; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.schema_migrations VALUES ('0001_initial', '2026-10-08 00:26:17.623866+00');
INSERT INTO public.schema_migrations VALUES ('0002_inbound_events', '2026-10-08 00:26:17.666723+00');
INSERT INTO public.schema_migrations VALUES ('0003_task_spend', '2026-10-08 00:26:17.680464+00');
INSERT INTO public.schema_migrations VALUES ('0004_channels', '2026-10-08 00:26:17.688232+00');
INSERT INTO public.schema_migrations VALUES ('0005_channel_prompts', '2026-10-08 00:26:17.707123+00');
INSERT INTO public.schema_migrations VALUES ('0006_events_task', '2026-10-08 00:26:17.718165+00');
INSERT INTO public.schema_migrations VALUES ('0007_dot_config_version', '2026-10-08 00:26:17.724644+00');
INSERT INTO public.schema_migrations VALUES ('0008_computer_next_automation', '2026-10-08 00:26:17.730628+00');
INSERT INTO public.schema_migrations VALUES ('0009_dot_keeps_its_memory', '2026-10-08 00:26:17.737384+00');
INSERT INTO public.schema_migrations VALUES ('0010_dot_has_no_goal', '2026-10-08 00:26:17.745491+00');
INSERT INTO public.schema_migrations VALUES ('0011_dot_has_no_browser_settings', '2026-10-08 00:26:17.753326+00');
--
-- Data for Name: secrets; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.secrets VALUES ('global', 'openrouter_api_key', '\x83ecec4851c227f6a0848b8123934629df0b90a2a63abc4b38d243360f1593e3604f9658463bada064baf61238436101953919de8d0551c0946b3f7cb6adbefbbf3da3', '2026-10-08 00:26:17.762727+00');
INSERT INTO public.secrets VALUES ('dot_01m4ceepkrt609r0tde6hm21hb', 'telegram_bot_token', '\xcb8b7dc5772170ab2190e83956526daf3705cd174d2c1d80cffa2d1dcd5709736fbde9a872341e083d0586749322d18eeede5662b8b012c46505bd92aba13e6a8417a8', '2026-10-08 00:26:18.184962+00');
--
-- Data for Name: task_runs; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.task_runs VALUES ('run_01m4ceeprj3zwtyjmrn8wpefzz', 'task_01m4ceepr4pcpyejkrwmwszmvy', '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.942498+00', '2026-10-08 00:26:17.956503+00', 'completed');
INSERT INTO public.task_runs VALUES ('run_01m4ceeptymc59t9c34rmqzq8v', 'task_01m4ceeptfsmdeddevy4770sbk', '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.023517+00', '2026-10-08 00:26:18.100452+00', 'completed');
INSERT INTO public.task_runs VALUES ('run_01m4ceepzd75vf6eyq6s83tvxf', 'task_01m4ceepz48cetk8s7k7zwkst5', '2026-10-08 00:26:18.126436+00', '2026-10-08 00:26:18.14187+00', NULL, NULL);
--
-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.tasks VALUES ('task_01m4ceepr4pcpyejkrwmwszmvy', 'dot_01m4ceepkrt609r0tde6hm21hb', 'List the workspace.', 0, 'COMPLETED', '2026-10-08 00:26:17.910436+00', NULL, '2026-10-08 00:26:17.918409+00', '2026-10-08 00:26:17.956503+00', 'done: List the workspace.', NULL, 0);
INSERT INTO public.tasks VALUES ('task_01m4ceeptfsmdeddevy4770sbk', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Delete the old identity.', 0, 'COMPLETED', '2026-10-08 00:26:17.981828+00', NULL, '2026-10-08 00:26:17.989407+00', '2026-10-08 00:26:18.100452+00', 'approval approved', NULL, 0);
INSERT INTO public.tasks VALUES ('task_01m4ceepz48cetk8s7k7zwkst5', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Delete the shop identity.', 0, 'WAITING_APPROVAL', '2026-10-08 00:26:18.121808+00', NULL, '2026-10-08 00:26:18.126436+00', NULL, NULL, NULL, 0);
INSERT INTO public.tasks VALUES ('task_01m4ceeq0s2zdfycr7vwnwy9s8', 'dot_01m4ceepkrt609r0tde6hm21hb', 'Check the fares again.', 0, 'CANCELLED', '2026-10-08 00:26:18.171462+00', NULL, NULL, '2026-10-08 00:26:18.176182+00', NULL, 'cancelled by the user', 0);
--
-- Name: events_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -
--
SELECT pg_catalog.setval('public.events_id_seq', 39, true);
--
-- Name: inbound_events_seq_seq; Type: SEQUENCE SET; Schema: public; Owner: -
--
SELECT pg_catalog.setval('public.inbound_events_seq_seq', 5, true);
--
-- Name: approvals approvals_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.approvals
ADD CONSTRAINT approvals_pkey PRIMARY KEY (id);
--
-- Name: channel_bindings channel_bindings_dot_id_kind_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_bindings
ADD CONSTRAINT channel_bindings_dot_id_kind_key UNIQUE (dot_id, kind);
--
-- Name: channel_bindings channel_bindings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_bindings
ADD CONSTRAINT channel_bindings_pkey PRIMARY KEY (id);
--
-- Name: channel_pairings channel_pairings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_pairings
ADD CONSTRAINT channel_pairings_pkey PRIMARY KEY (binding_id, code_hash);
--
-- Name: channel_peers channel_peers_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_peers
ADD CONSTRAINT channel_peers_pkey PRIMARY KEY (binding_id, peer_id);
--
-- Name: channel_prompts channel_prompts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_prompts
ADD CONSTRAINT channel_prompts_pkey PRIMARY KEY (binding_id, approval_id, chat_id);
--
-- Name: computers computers_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.computers
ADD CONSTRAINT computers_pkey PRIMARY KEY (dot_id);
--
-- Name: dots dots_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dots
ADD CONSTRAINT dots_name_key UNIQUE (name);
--
-- Name: dots dots_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dots
ADD CONSTRAINT dots_pkey PRIMARY KEY (id);
--
-- Name: events events_dot_id_guest_seq_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.events
ADD CONSTRAINT events_dot_id_guest_seq_key UNIQUE (dot_id, guest_seq);
--
-- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.events
ADD CONSTRAINT events_pkey PRIMARY KEY (id);
--
-- Name: inbound_events inbound_events_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.inbound_events
ADD CONSTRAINT inbound_events_id_key UNIQUE (id);
--
-- Name: inbound_events inbound_events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.inbound_events
ADD CONSTRAINT inbound_events_pkey PRIMARY KEY (seq);
--
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: secrets secrets_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.secrets
ADD CONSTRAINT secrets_pkey PRIMARY KEY (scope, name);
--
-- Name: task_runs task_runs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.task_runs
ADD CONSTRAINT task_runs_pkey PRIMARY KEY (id);
--
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);
--
-- Name: approvals_status_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX approvals_status_idx ON public.approvals USING btree (status, created_at);
--
-- Name: channel_bindings_account_key; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX channel_bindings_account_key ON public.channel_bindings USING btree (account) WHERE ((kind = 'telegram'::text) AND (account IS NOT NULL));
--
-- Name: channel_peers_chat_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX channel_peers_chat_idx ON public.channel_peers USING btree (binding_id, chat_id);
--
-- Name: channel_prompts_approval_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX channel_prompts_approval_idx ON public.channel_prompts USING btree (approval_id);
--
-- Name: events_dot_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX events_dot_idx ON public.events USING btree (dot_id, id);
--
-- Name: events_task_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX events_task_idx ON public.events USING btree (dot_id, ((data ->> 'task_id'::text)), id) WHERE ((data ->> 'task_id'::text) IS NOT NULL);
--
-- Name: events_user_message_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX events_user_message_idx ON public.events USING btree (dot_id, ((data ->> 'message_id'::text))) WHERE (type = 'user.message'::text);
--
-- Name: events_user_message_origin_key; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX events_user_message_origin_key ON public.events USING btree (dot_id, (((data -> 'origin'::text) ->> 'binding_id'::text)), (((data -> 'origin'::text) ->> 'external_id'::text))) WHERE ((type = 'user.message'::text) AND ((data -> 'origin'::text) IS NOT NULL));
--
-- Name: inbound_events_pending_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX inbound_events_pending_idx ON public.inbound_events USING btree (dot_id, seq) WHERE ((delivered_at IS NULL) AND (dropped_at IS NULL));
--
-- Name: task_runs_task_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX task_runs_task_idx ON public.task_runs USING btree (task_id);
--
-- Name: tasks_dot_status_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX tasks_dot_status_idx ON public.tasks USING btree (dot_id, status);
--
-- Name: tasks_pending_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX tasks_pending_idx ON public.tasks USING btree (priority DESC, created_at, id) WHERE (status = 'PENDING'::text);
--
-- Name: approvals approvals_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.approvals
ADD CONSTRAINT approvals_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE;
--
-- Name: channel_bindings channel_bindings_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_bindings
ADD CONSTRAINT channel_bindings_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE;
--
-- Name: channel_pairings channel_pairings_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_pairings
ADD CONSTRAINT channel_pairings_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE;
--
-- Name: channel_peers channel_peers_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_peers
ADD CONSTRAINT channel_peers_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE;
--
-- Name: channel_prompts channel_prompts_approval_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_prompts
ADD CONSTRAINT channel_prompts_approval_id_fkey FOREIGN KEY (approval_id) REFERENCES public.approvals(id) ON DELETE CASCADE;
--
-- Name: channel_prompts channel_prompts_binding_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.channel_prompts
ADD CONSTRAINT channel_prompts_binding_id_fkey FOREIGN KEY (binding_id) REFERENCES public.channel_bindings(id) ON DELETE CASCADE;
--
-- Name: computers computers_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.computers
ADD CONSTRAINT computers_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE;
--
-- Name: inbound_events inbound_events_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.inbound_events
ADD CONSTRAINT inbound_events_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE;
--
-- Name: task_runs task_runs_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.task_runs
ADD CONSTRAINT task_runs_task_id_fkey FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON DELETE CASCADE;
--
-- Name: tasks tasks_dot_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tasks
ADD CONSTRAINT tasks_dot_id_fkey FOREIGN KEY (dot_id) REFERENCES public.dots(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--
@@ -0,0 +1 @@
f38457d1f14a7ea3ee7119f7dc463369ae540f0ba0036e1001a40dba8a997e56
+111
View File
@@ -0,0 +1,111 @@
/**
* Writes the control plane's database as a release leaves it, for test/upgrade.test.ts.
*
* Run it from the root of a checkout of the release (a worktree of its tag), against a PostgreSQL server
* (it makes a database of its own there and prints its name), then dump that database, leaving out psql's
* meta-commands and the dump's reset of search_path, which would outlive the load on PGlite:
*
* DATABASE_URL=postgres://... npx tsx <this file> fixtures/upgrade/<release tag>/master.key
* pg_dump --inserts --no-owner --no-privileges <the database> | grep -v -e '^\\' -e "set_config('search_path'" \
* > fixtures/upgrade/<release tag>/host.sql
*
* It drives that release's scheduler, with a fake computer, through what a person's install holds: two
* Dots (one stopped), a chat answered, a completed task, a task waiting for an approval, an approval
* answered with "always", a cancelled task, an automation, a Telegram binding with its token, and the
* OpenRouter key. The master key it writes is a test key: it only opens the secrets of the dump.
*/
import { randomBytes } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { Database } from "@invisible-dots/database";
import { createScratchPostgres } from "@invisible-dots/database/testing";
import { Scheduler } from "../../../src/index.js";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../../../src/testing.js";
const [keyFile] = process.argv.slice(2);
if (!keyFile) throw new Error("usage: make-host-state.ts <master key file to write>");
const masterKey = randomBytes(32);
const { url } = await createScratchPostgres();
const db = await Database.open({ target: { kind: "pg", url }, masterKey, poolSize: 5 });
await db.migrate();
const driver = new FakeDriver();
const scheduler = new Scheduler({
db,
driver,
clock: new ManualClock(new Date("2026-10-07T09:00:00.000Z")),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 },
dispatcher: { retryDelayMs: 0, maxDeliveryAttempts: 2 },
});
await scheduler.setOpenRouterKey("sk-or-v1-upgrade-fixture-not-a-real-key");
const yaml = (name: string) =>
`name: ${name}\nmodel:\n provider: openrouter\n id: z-ai/glm-5.3-flash\ncomputer:\n idle_timeout: 15m\npermissions:\n files.write: ask\n`;
const ready = async (name: string) => {
const dot = await scheduler.createDot(yaml(name));
await waitUntilSettledReady(scheduler, driver, dot.id, name);
return dot;
};
const watcher = await ready("fare-watch");
const guest = driver.guestOf(watcher.id);
// A chat answered, and a completed task.
await scheduler.sendMessage(watcher.id, "Remember: my favourite colour is teal.");
const done = await scheduler.createTask(watcher.id, { description: "List the workspace." });
await waitFor(async () => (await db.tasks.get(done.id))?.status === "COMPLETED", "task completed");
// An approval answered with "always", and a task that waits for an approval when the release is upgraded.
const original = guest.onInbound;
guest.onInbound = (event, g) => {
if (event.type !== "task.created") return original(event, g);
g.emit("task.started", { task_id: event.data.task_id });
g.requestApproval(event.data.task_id);
};
const allowed = await scheduler.createTask(watcher.id, { description: "Delete the old identity." });
const always = await waitFor(async () => (await scheduler.listApprovals("pending")).find((a) => a.task_id === allowed.id), "first approval");
await scheduler.resolveApproval(always.id, "approve", { always: true, note: "fine" });
await waitFor(async () => (await db.tasks.get(allowed.id))?.status === "COMPLETED", "approved task completed");
await scheduler.updateDot(watcher.id, yaml("fare-watch"));
const waiting = await scheduler.createTask(watcher.id, { description: "Delete the shop identity." });
await waitFor(async () => (await db.tasks.get(waiting.id))?.status === "WAITING_APPROVAL", "task waiting");
// A cancelled task, queued behind the waiting one.
const cancelled = await scheduler.createTask(watcher.id, { description: "Check the fares again." });
await scheduler.cancelTask(cancelled.id);
// An automation the Dot reported.
guest.putAutomation({
id: "job_water",
name: "water the plants",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
message: "Water the plants",
next_run_at_ms: 1_791_505_433_167,
last_run_at_ms: null,
last_status: null,
last_error: null,
delete_after_run: false,
created_at_ms: 1_791_419_033_167,
});
// A Telegram binding with its token and a paired owner.
const binding = await db.channels.createBinding({
id: "chb_upgrade",
dotId: watcher.id,
kind: "telegram",
settings: { approvals: true, notify_tasks: true, show_arguments: false },
eventCursor: 0,
account: "fare_watch_bot",
});
await db.secrets.put(watcher.id, "telegram_bot_token", "123456:upgrade-fixture-not-a-real-token");
await db.channels.upsertPeer({ bindingId: binding.id, peerId: "4242", chatId: "4242", role: "owner", label: "Federico" });
// A second Dot, stopped.
const minimal = await ready("minimal-dot");
await scheduler.stopComputer(minimal.id);
await waitFor(async () => (await db.computers.get(minimal.id))?.state === "STOPPED", "computer stopped");
await scheduler.settle();
await scheduler.close();
await db.close();
await writeFile(keyFile, masterKey.toString("hex") + "\n");
console.log(new URL(url).pathname.slice(1));
+148
View File
@@ -0,0 +1,148 @@
/**
* What the host sends the engine: the events (`POST /events`) and the Dot's config (`PUT /config`), pinned in a file
* the engine checks. They are not written by hand here: a real scheduler runs a Dot against the fake guest, and what
* its own writers send is what the guest received. The engine is Python and parses them with its own parsers
* (`invisible_engine_dots/tests/dots/test_host_wire_shapes.py`), so neither side can move a key, or a rule of what is
* accepted, without a suite failing. The other direction, what the engine writes, is `engine-shapes.test.ts`.
*
* A key renamed or added here fails this test until the file is written again
* (`UPDATE_HOST_WIRE_SHAPES=1 npx vitest run apps/scheduler/test/host-shapes.test.ts`), and then fails the engine's
* test until its parser says the same.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createTestDatabase, type TestDatabase } from "@invisible-dots/database/testing";
import { INBOUND_EVENT_TYPES } from "@invisible-dots/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Scheduler } from "../src/index.js";
import { FakeDriver, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
const FIXTURE = fileURLToPath(new URL("../../../invisible_engine_dots/tests/dots/host_wire_shapes.json", import.meta.url));
const MINIMAL = "name: shapes-min\nmodel:\n provider: openrouter\n id: test/model\n";
const FULL = `name: shapes-full
instructions: Answer in one line.
model:
provider: openrouter
id: test/model
models:
summary: test/small
computer:
cpu: 4
memory: 6G
disk: 30G
idle_timeout: 5m
permissions:
computer.exec: deny
files.write: ask
mcp.time: allow
mcp_servers:
time:
command: uvx
args: [mcp-server-time, --local-timezone, Europe/Rome]
env:
LOG_LEVEL: warning
secrets: [TIME_TOKEN]
search:
url: https://search.example/mcp
headers:
X-Client: dots
secrets: [Authorization]
timeout_s: 30
limits:
max_steps_per_task: 30
max_cost_per_task_usd: 2.5
`;
describe("what the host sends the engine", () => {
let t: TestDatabase;
let scheduler: Scheduler;
let written: Awaited<ReturnType<typeof shapes>>;
const driver = new FakeDriver();
beforeAll(async () => {
t = await createTestDatabase("pglite");
await t.db.secrets.put("global", "openrouter_api_key", "sk-or-test");
scheduler = new Scheduler({
db: t.db,
driver,
clock: new ManualClock(),
lifecycle: { healthPollMs: 5, readyTimeoutMs: 3_000, pumpRetryMs: 10, pumpMaxRetryMs: 50 },
dispatcher: { retryDelayMs: 0, maxDeliveryAttempts: 2 },
});
written = await shapes();
});
afterAll(async () => {
await scheduler?.close();
await t?.drop();
});
async function readyDot(yaml: string, name: string) {
const dot = await scheduler.createDot(yaml);
await waitUntilSettledReady(scheduler, driver, dot.id, name);
return dot;
}
async function shapes() {
const configs = [];
for (const [name, yaml] of [["shapes-min", MINIMAL], ["shapes-full", FULL]] as const) {
const dot = await readyDot(yaml, name);
configs.push({ name, config: structuredClone(driver.guestOf(dot.id).config) });
}
// The secrets of the full config's servers, set as the person sets them: the push carries them with the key.
const full = await scheduler.requireDot("shapes-full");
await scheduler.setMcpSecret(full.id, "time", "TIME_TOKEN", "tt-1");
await scheduler.setMcpSecret(full.id, "search", "Authorization", "Bearer sk-search-1");
const fullGuest = driver.guestOf(full.id);
const secrets_requests = [{ openrouter_api_key: fullGuest.openrouterKey, mcp_secrets: structuredClone(fullGuest.mcpSecrets) }];
const dot = await readyDot("name: shapes-talk\nmodel:\n provider: openrouter\n id: test/model\n", "shapes-talk");
const guest = driver.guestOf(dot.id);
// A quiet guest: it takes a task up and says nothing else, so nothing but what the host sends is in `inbound`.
guest.onInbound = (event, g) => {
if (event.type === "task.created") g.emit("task.started", { task_id: event.data.task_id });
};
await scheduler.sendMessage(dot.id, "what is the cheapest fare to Lisbon?");
const task = await scheduler.createTask(dot.id, { description: "compare the fares", priority: 5 });
await waitFor(async () => (await scheduler.getTask(task.id)).status === "RUNNING", "the task running");
const plain = await scheduler.createTask(dot.id, { description: "a task with the default priority" });
await scheduler.cancelTask(task.id);
await waitFor(() => guest.inbound.some((event) => event.type === "system.event"), "the cancel reaching the guest");
const approved = guest.requestApproval(undefined);
const rejected = guest.requestApproval(undefined);
await waitFor(async () => (await scheduler.listApprovals("pending")).filter((a) => a.dot_id === dot.id).length === 2, "two pending approvals");
await scheduler.resolveApproval(approved, "approve", { note: "go on" });
await scheduler.resolveApproval(rejected, "reject");
await waitFor(() => guest.inbound.filter((event) => event.type === "approval.received").length === 2, "both answers reaching the guest");
expect(plain.priority).toBe(0);
// The id and the time of an event are the host's own and vary: the engine's parser takes any, and the file is about
// the data. The ids of tasks and approvals, drawn at random, are named in the order they appear.
const named = new Map([[task.id, "task-1"], [plain.id, "task-2"], [approved, "approval-1"], [rejected, "approval-2"]]);
const inbound_events = (JSON.parse(
[...named].reduce((text, [id, name]) => text.replaceAll(id, name), JSON.stringify(guest.inbound.map((event) => ({ type: event.type, data: event.data })))),
) as { type: string; data: Record<string, unknown> }[]);
return { runtime_configs: configs, secrets_requests, inbound_events };
}
it("writes what the host's own writers sent, as the file the engine's parsers check", async () => {
const text = `${JSON.stringify(written, null, 2)}\n`;
if (process.env.UPDATE_HOST_WIRE_SHAPES === "1") writeFileSync(FIXTURE, text);
expect(JSON.parse(readFileSync(FIXTURE, "utf8"))).toEqual(JSON.parse(text));
});
it("holds one event of each type the host sends, the optional note of an answer with and without it, and a config with every section set", async () => {
expect([...new Set(written.inbound_events.map((event) => event.type))].sort()).toEqual([...INBOUND_EVENT_TYPES].sort());
const answers = written.inbound_events.filter((event) => event.type === "approval.received").map((event) => Object.keys(event.data).sort().join());
expect(answers).toEqual(["approval_id,decision,note", "approval_id,decision"]);
const priorities = written.inbound_events.filter((event) => event.type === "task.created").map((event) => event.data.priority);
expect(priorities).toEqual([5, 0]);
const [minimal, full] = written.runtime_configs.map((entry) => entry.config as Record<string, unknown>);
expect(Object.keys(full!).sort()).toEqual(["instructions", "limits", "mcp_servers", "model", "models", "name", "permissions"]);
expect(Object.keys(full!.mcp_servers as object).sort()).toEqual(["search", "time"]);
expect(written.secrets_requests[0]!.mcp_secrets).toEqual({ time: { TIME_TOKEN: "tt-1" }, search: { Authorization: "Bearer sk-search-1" } });
expect(Object.keys(minimal!)).not.toContain("instructions");
expect(minimal).not.toHaveProperty("computer");
});
});
File diff suppressed because it is too large. Load diff
+102
View File
@@ -0,0 +1,102 @@
/**
* A release's database under this control plane: what a person's install holds must open, migrate and
* go on working after an upgrade. Each directory of test/fixtures/upgrade holds the database one release
* left (host.sql, made by make-host-state.ts from that release's checkout) and the test master key its
* secrets were sealed with.
*/
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Database, loadMigrations, OPENROUTER_KEY_NAME, PgliteDb } from "@invisible-dots/database";
import { createScratchPostgres, testAdapters } from "@invisible-dots/database/testing";
import pg from "pg";
import { afterEach, describe, expect, it } from "vitest";
import { Scheduler } from "../src/index.js";
import { FakeDriver, ManualClock } from "../src/testing.js";
const FIXTURES = fileURLToPath(new URL("./fixtures/upgrade/", import.meta.url));
const RELEASES = readdirSync(FIXTURES, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
describe.each(testAdapters())("a release's database under this control plane (%s)", (kind) => {
const cleanup: (() => Promise<void>)[] = [];
afterEach(async () => {
for (const step of cleanup.splice(0).reverse()) await step();
});
/** The release's database loaded as it was left, then opened and migrated by this build. */
async function upgraded(release: string): Promise<Database> {
const dump = readFileSync(join(FIXTURES, release, "host.sql"), "utf8");
const masterKey = Buffer.from(readFileSync(join(FIXTURES, release, "master.key"), "utf8").trim(), "hex");
let db: Database;
if (kind === "pglite") {
const dataDir = mkdtempSync(join(tmpdir(), "idots-upgrade-"));
cleanup.push(async () => rmSync(dataDir, { recursive: true, force: true }));
const loader = await PgliteDb.open(dataDir);
await loader.exec(dump);
await loader.close();
db = await Database.open({ target: { kind: "pglite", dataDir }, masterKey });
} else {
const { url, drop } = await createScratchPostgres();
cleanup.push(drop);
const loader = new pg.Client({ connectionString: url });
await loader.connect();
await loader.query(dump);
await loader.end();
db = await Database.open({ target: { kind: "pg", url }, masterKey, poolSize: 3 });
}
cleanup.push(() => db.close());
await db.migrate();
return db;
}
it("has a release to upgrade from", () => {
expect(RELEASES.length).toBeGreaterThan(0);
});
it.each(RELEASES)("%s: migrates to every migration of this build, and its Dots, configs, tasks and secrets read back", async (release) => {
const db = await upgraded(release);
const applied = (await db.query<{ version: string }>("SELECT version FROM schema_migrations ORDER BY version")).rows.map((r) => r.version);
expect(applied).toEqual((await loadMigrations()).map((m) => m.version));
const dots = await db.dots.list();
expect(dots.map((d) => d.name).sort()).toEqual(["fare-watch", "minimal-dot"]);
const watcher = dots.find((d) => d.name === "fare-watch")!;
expect(watcher.config.model.id).toBe("z-ai/glm-5.3-flash");
expect(watcher.config.permissions?.["files.write"]).toBe("ask");
expect((await db.computers.get(dots.find((d) => d.name === "minimal-dot")!.id))?.state).toBe("STOPPED");
const tasks = await db.tasks.listByDot(watcher.id, { limit: 50 });
expect(tasks.map((t) => t.status).sort()).toEqual(["CANCELLED", "COMPLETED", "COMPLETED", "WAITING_APPROVAL"]);
// The secrets open with the key they were sealed with.
expect(await db.secrets.get("global", OPENROUTER_KEY_NAME)).toBe("sk-or-v1-upgrade-fixture-not-a-real-key");
expect(await db.secrets.get(watcher.id, "telegram_bot_token")).toBe("123456:upgrade-fixture-not-a-real-token");
const [binding] = await db.channels.listBindings(watcher.id);
expect(binding).toMatchObject({ kind: "telegram", account: "fare_watch_bot" });
expect((await db.channels.peers(binding!.id)).map((p) => [p.peer_id, p.role])).toEqual([["4242", "owner"]]);
});
it.each(RELEASES)("%s: the approval it left pending is answered, new work is queued, and new events follow the old ones", async (release) => {
const db = await upgraded(release);
const scheduler = new Scheduler({ db, driver: new FakeDriver(), clock: new ManualClock(new Date("2026-11-01T09:00:00.000Z")) });
cleanup.push(() => scheduler.close());
const watcher = await scheduler.requireDot("fare-watch");
const lastEvent = (await db.query<{ id: string }>("SELECT max(id)::text AS id FROM events")).rows[0]!.id;
const [pending] = await scheduler.listApprovals("pending");
expect(pending).toMatchObject({ dot_id: watcher.id, status: "pending", tool: "browser_identity_delete" });
const resolved = await scheduler.resolveApproval(pending!.id, "approve", { note: "after the upgrade" });
expect(resolved.status).toBe("approved");
const queued = (await db.query<{ type: string; data: { approval_id?: string } }>("SELECT type, data FROM inbound_events WHERE dot_id = $1 AND sent_at IS NULL", [watcher.id])).rows;
expect(queued.some((row) => row.type === "approval.received" && row.data.approval_id === pending!.id)).toBe(true);
const task = await scheduler.createTask("fare-watch", { description: "Check the fares after the upgrade." });
expect(task.status).toBe("PENDING");
const conversation = await scheduler.conversation("fare-watch");
expect(JSON.stringify(conversation)).toContain("Remember: my favourite colour is teal.");
const events = await scheduler.listEvents(watcher.id, { after: Number(lastEvent) });
expect(events.map((e) => e.type)).toEqual(expect.arrayContaining(["approval.resolved", "task.created"]));
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@invisible-dots/vm-manager",
"version": "0.0.0",
"private": true,
"description": "QEMU driver: overlays, seed ISOs, the QEMU command line, port forwards, the guest client and the host's process runner.",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@invisible-dots/iso": "0.0.0",
"@invisible-dots/shared": "0.0.0"
},
"devDependencies": {
"yaml": "^2.9.1"
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* The host side of the accelerator (architecture section 1.1), read before
* QEMU is asked to use it: /dev/kvm opened read-write on Linux, the
* HypervisorPlatform optional feature on Windows. One of the two places that
* ask which operating system this is (the other is `accelerator()` in
* host.ts); `doctor` reports what it finds and `invisible-dots setup`
* enables what it finds missing.
*/
import { open } from "node:fs/promises";
import type { DoctorCheck } from "@invisible-dots/shared";
import type { Runner } from "./doctor.js";
import { firstLine, runProcess } from "./runner.js";
import { HYPERVISOR_PLATFORM_FEATURE, HYPERVISOR_PLATFORM_STATE_SCRIPT, powershellArgs, powershellPath } from "./powershell.js";
const KVM_DEVICE = "/dev/kvm";
const USERMOD_COMMAND = "sudo usermod -aG kvm $USER";
const FEATURE_QUERY_TIMEOUT_MS = 60_000;
export const UNSUPPORTED_FIX = "run invisible_dots on Linux or Windows (x86-64)";
export interface AccessDeps {
platform: NodeJS.Platform;
env: Record<string, string | undefined>;
run: Runner;
/** Opens a path read-write and closes it again; rejects with the errno error. */
openReadWrite(path: string): Promise<void>;
}
/** This machine: its platform, the real process runner and the real /dev/kvm. */
export function hostAccessDeps(env: Record<string, string | undefined>): AccessDeps {
return {
platform: process.platform,
env,
run: runProcess,
openReadWrite: async (path) => {
await (await open(path, "r+")).close();
},
};
}
/** The host side of the accelerator, before QEMU is asked to use it (doctor's "accelerator" check). */
export async function checkAcceleratorAccess(deps: AccessDeps): Promise<DoctorCheck> {
if (deps.platform === "linux") return checkKvmDevice(deps);
if (deps.platform === "win32") return checkHypervisorPlatform(deps);
return {
id: "accelerator",
label: "accelerator",
status: "failed",
detail: `${deps.platform} hosts are not supported`,
fix: UNSUPPORTED_FIX,
};
}
export async function checkKvmDevice(deps: AccessDeps): Promise<DoctorCheck> {
const base = { id: "accelerator", label: "accelerator" } as const;
try {
await deps.openReadWrite(KVM_DEVICE);
return { ...base, status: "ok", detail: `${KVM_DEVICE} opens read-write` };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return {
...base,
status: "missing",
detail: `${KVM_DEVICE} does not exist`,
fix: "enable hardware virtualization (Intel VT-x or AMD-V) in the firmware settings, then: sudo modprobe kvm_intel (or kvm_amd)",
};
}
if (code === "EACCES" || code === "EPERM") {
return {
...base,
status: "missing",
detail: `${KVM_DEVICE} exists but this user cannot open it read-write`,
fix: `${USERMOD_COMMAND}, then log out and in again (a new login is needed for the group to apply)`,
};
}
return { ...base, status: "failed", detail: `opening ${KVM_DEVICE} failed: ${(error as Error).message}`, fix: "check the kvm kernel module (dmesg | grep -i kvm)" };
}
}
async function checkHypervisorPlatform(deps: AccessDeps): Promise<DoctorCheck> {
const base = { id: "accelerator", label: "accelerator" } as const;
const answer = await deps.run(powershellPath(deps.env), powershellArgs(HYPERVISOR_PLATFORM_STATE_SCRIPT), { timeoutMs: FEATURE_QUERY_TIMEOUT_MS });
if (answer.code !== 0) {
const why = answer.timedOut ? "timed out" : (answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`));
return { ...base, status: "failed", detail: `cannot read the ${HYPERVISOR_PLATFORM_FEATURE} feature state: ${why}` };
}
const state = answer.stdout.trim();
if (state === "1") return { ...base, status: "ok", detail: `the ${HYPERVISOR_PLATFORM_FEATURE} feature is enabled` };
if (state === "2") {
return {
...base,
status: "missing",
detail: `the ${HYPERVISOR_PLATFORM_FEATURE} feature is disabled`,
fix: "invisible-dots setup (enables it with one administrator prompt; a restart follows)",
};
}
const detail =
state === ""
? `this Windows does not list the ${HYPERVISOR_PLATFORM_FEATURE} feature`
: `the ${HYPERVISOR_PLATFORM_FEATURE} feature is in state ${state} (3 means its files were removed from this Windows)`;
return { ...base, status: "failed", detail, fix: "use a Windows 10 or 11 edition that offers Windows Hypervisor Platform" };
}
+291
View File
@@ -0,0 +1,291 @@
/**
* The host report (architecture section 11.1): every check, in the
* contract's order, each answering ok / missing / failed and naming the
* command that fixes it. It runs in the API process (`GET /api/doctor`) and
* in `invisible-dots doctor` alike. Doctor only reads: it never creates a
* directory, never opens the database and never changes a setting.
*
* Status meanings, so every check uses them the same way (`DoctorStatus`):
* - ok: present and working.
* - missing: absent; the fix line installs or creates it.
* - failed: present but not working, or the check itself could not tell.
*/
import { ENV, type DoctorAnswer, type DoctorCheck, type DoctorCheckId, type DoctorStatus } from "@invisible-dots/shared";
import { SETUP_COMMAND, STORE_OPENROUTER_KEY } from "./errors.js";
import { isSupportedQemuVersion, MIN_QEMU_VERSION_TEXT, parseQemuVersion, type Accelerator, type QemuVersion } from "./host.js";
import { CPU_MODEL, qemuPathProblem } from "./qemu-args.js";
import { firstLine, type RunOptions, type RunResult } from "./runner.js";
/** `npm run build --workspace @invisible-dots/web`: what leaves the web client where `invisible-dots server` serves it from. */
export const WEB_BUILD_COMMAND = "npm run build --workspace @invisible-dots/web";
export interface WebBuild {
/** The server script `node` runs. */
entry: string;
/** The first file or directory of the build that does not exist; undefined when the build is complete. */
missing: string | undefined;
}
/** Where the QEMU programs were found, each searched separately (section 3.1). */
export interface FoundQemu {
system?: string;
img?: string;
/** The places searched, in order, for the message when something is not found. */
searched: string[];
/** INVISIBLE_DOTS_QEMU_DIR is set, so it was the only place searched. */
configured?: boolean;
}
export type Runner = (command: string, args: readonly string[], options?: RunOptions) => Promise<RunResult>;
export interface DoctorDeps {
/** `process.versions.node`. */
nodeVersion: string;
/** The vm-manager's choice for this host (section 1.1); throws on a host invisible_dots does not run on. */
accelerator(): Accelerator;
findQemu(): Promise<FoundQemu>;
run: Runner;
/** The host side of the accelerator: /dev/kvm or the HypervisorPlatform feature (accelerator-access.ts). */
acceleratorAccess(): Promise<DoctorCheck>;
/** INVISIBLE_DOTS_HOME, as the server and the image builder will use it. */
home: string;
/** Free space for INVISIBLE_DOTS_HOME, measured on the nearest directory that exists. */
freeSpace(): Promise<{ path: string; bytes: number }>;
/** The golden image and the runtime ISO against their manifests, one result each. */
images(): Promise<DoctorCheck[]>;
/** Where the built web client is, and the first file of it that is missing. Only a process that serves the web client (`invisible-dots server`) has one: without it the report has no web row. */
webBuild?(): Promise<WebBuild>;
/** Whether an OpenRouter key is stored. */
openRouterKey(): Promise<DoctorCheck>;
}
export const MIN_NODE_MAJOR = 24;
/**
* One golden image (a few GiB once provisioned) plus the overlays of a few
* Dots, which grow with what each Dot stores. Below this a first `image
* build` or a busy Dot runs the disk full.
*/
export const MIN_FREE_BYTES = 20 * 1024 ** 3;
/**
* The probe boots the empty machine's firmware, which takes a fraction of a
* second with a working accelerator (0.2 s measured with QEMU 8.2 and KVM);
* a QEMU still running after this long has a virtual CPU that does not run.
*/
export const PROBE_TIMEOUT_MS = 30_000;
const VERSION_TIMEOUT_MS = 15_000;
/**
* Starts QEMU with the accelerator and the CPU model of the Dots (section
* 3.4) on their machine type with no devices at all, and lets the virtual
* CPU run: the firmware finds nothing to boot, `-boot reboot-timeout=0`
* makes it reset at once, and `-no-reboot` turns that reset into QEMU
* exiting with code 0. So exit code 0 means the accelerator opened AND ran
* guest code, which a QEMU held before its first instruction could never
* show; there is no monitor to talk to, the same as for a Dot. `-accel` is
* given alone, so QEMU cannot fall back to software emulation.
*
* Measured with QEMU 8.2.2 and KVM: exit 0 after 0.2 s, and the firmware's
* debug output reads "No bootable device. Retrying in 0 seconds. Rebooting."
* Without `-no-reboot`, or without the reboot timeout, QEMU never exits.
*
* Not `-machine none`: measured with QEMU 11.1 on Windows, WHPX with
* `-machine none` aborts with "X86_MACHINE: Object ... is not an instance of
* type x86-machine" (exit 3) even where WHPX works, and it has no CPU to run.
*/
export function acceleratorProbeArgs(accelerator: Accelerator): string[] {
return [
"-nodefaults",
"-no-user-config",
"-machine",
"q35",
"-accel",
accelerator,
"-cpu",
CPU_MODEL,
"-display",
"none",
"-no-reboot",
"-boot",
"reboot-timeout=0",
];
}
/**
* PATH alone can hold dozens of directories, so the line names the search
* order of section 3.1 and how many places it covered rather than all of them.
*/
function notFound(found: FoundQemu): string {
const count = found.searched.length;
if (count === 1 && found.searched[0]!.startsWith("(")) return `not found ${found.searched[0]}`;
if (found.configured) return `not found in ${ENV.QEMU_DIR} (${found.searched[0] ?? ""}), the only place searched while it is set`;
return `not found in the official installer's directory or on PATH (${count} ${count === 1 ? "directory" : "directories"} searched)`;
}
function versionText(version: QemuVersion): string {
return `${version.major}.${version.minor}.${version.micro}`;
}
export function formatBytes(bytes: number): string {
return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
}
function result(id: DoctorCheckId, label: string, status: DoctorStatus, detail: string, fix?: string): DoctorCheck {
return fix === undefined || status === "ok" ? { id, label, status, detail } : { id, label, status, detail, fix };
}
export function checkNode(version: string): DoctorCheck {
const major = Number(version.split(".")[0]);
if (Number.isInteger(major) && major >= MIN_NODE_MAJOR) return result("node", "Node", "ok", version);
return result("node", "Node", "failed", `${version} is older than ${MIN_NODE_MAJOR}`, `install Node ${MIN_NODE_MAJOR} or newer from https://nodejs.org`);
}
async function checkQemuSystem(found: FoundQemu, run: Runner): Promise<DoctorCheck> {
const label = "QEMU";
const minimum = MIN_QEMU_VERSION_TEXT;
if (!found.system) {
return result("qemu", label, "missing", `qemu-system-x86_64 ${notFound(found)}`, SETUP_COMMAND);
}
const answer = await run(found.system, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS });
const version = answer.code === 0 ? parseQemuVersion(answer.stdout) : undefined;
if (!version) {
const why = answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`);
return result("qemu", label, "failed", `${found.system} --version did not report a version (${why})`, SETUP_COMMAND);
}
const text = versionText(version);
if (!isSupportedQemuVersion(version)) {
return result(
"qemu",
label,
"failed",
`${found.system} is version ${text}, older than ${minimum}`,
`${SETUP_COMMAND}, or set INVISIBLE_DOTS_QEMU_DIR to a QEMU ${minimum} or newer`,
);
}
return result("qemu", label, "ok", `${text} at ${found.system}`);
}
async function checkQemuImg(found: FoundQemu, run: Runner): Promise<DoctorCheck> {
if (!found.img) return result("qemu-img", "qemu-img", "missing", `qemu-img ${notFound(found)}`, SETUP_COMMAND);
const answer = await run(found.img, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS });
const version = answer.code === 0 ? parseQemuVersion(answer.stdout) : undefined;
if (!version) {
const why = answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`);
return result("qemu-img", "qemu-img", "failed", `${found.img} --version did not report a version (${why})`, SETUP_COMMAND);
}
return result("qemu-img", "qemu-img", "ok", `${versionText(version)} at ${found.img}`);
}
/**
* Runs whenever QEMU is ready, also when the host-side check said the
* accelerator is missing: that check reads a precondition, the probe reads
* the result. Measured on Windows 11 with VirtualMachinePlatform enabled
* (WSL 2): HypervisorPlatform reports disabled and WHPX still works.
*/
async function probeAccelerator(deps: DoctorDeps, qemu: DoctorCheck, access: DoctorCheck, system: string | undefined): Promise<DoctorCheck> {
const label = "accelerator probe";
if (qemu.status !== "ok" || !system) {
return result("accelerator-probe", label, qemu.status, `not run: QEMU is not ready`, qemu.fix);
}
const accelerator = deps.accelerator();
const command = `qemu-system-x86_64 -accel ${accelerator} -cpu ${CPU_MODEL} -machine q35`;
const answer = await deps.run(system, acceleratorProbeArgs(accelerator), { timeoutMs: PROBE_TIMEOUT_MS });
if (answer.code === 0 && !answer.timedOut) return result("accelerator-probe", label, "ok", `${command} ran the firmware and exited`);
const why = answer.timedOut
? `did not exit within ${PROBE_TIMEOUT_MS / 1000} s, so the virtual CPU does not run${firstLine(answer.stderr) ? ` (QEMU said: ${firstLine(answer.stderr)})` : ""}`
: (answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`));
const fix =
access.status !== "ok" && access.fix
? access.fix
: "enable hardware virtualization (Intel VT-x or AMD-V) in the firmware settings; after enabling the accelerator, restart the computer";
return result("accelerator-probe", label, "failed", `${command} failed: ${why}`, fix);
}
/** A host-side "missing" that the probe contradicts is reported as what it is: QEMU uses the accelerator. */
function confirmedByProbe(access: DoctorCheck, probe: DoctorCheck): DoctorCheck {
if (access.status !== "missing" || probe.status !== "ok") return access;
return result("accelerator", access.label, "ok", `${access.detail}, but the probe below shows QEMU uses the accelerator`);
}
const DATA_DIRECTORY = "data directory";
/**
* INVISIBLE_DOTS_HOME: a path QEMU can be given (the same rule a Dot's start
* applies, qemuPathProblem), with enough free space. A home under an account
* name with an accent fails here, before an image build or a Dot does.
*/
export function checkDataDirectory(home: string, space: { path: string; bytes: number }): DoctorCheck {
const problem = qemuPathProblem(home);
if (problem) {
return result("disk", DATA_DIRECTORY, "failed", `${home} ${problem}`, `set ${ENV.HOME} to a directory whose path is plain ASCII without commas`);
}
const detail = `${formatBytes(space.bytes)} free at ${space.path}`;
if (space.bytes >= MIN_FREE_BYTES) return result("disk", DATA_DIRECTORY, "ok", detail);
return result(
"disk",
DATA_DIRECTORY,
"failed",
`${detail}, less than ${formatBytes(MIN_FREE_BYTES)}`,
`free some space, or set ${ENV.HOME} to a directory on a larger disk`,
);
}
/** The web client `invisible-dots server` serves: built where the server looks for it. */
export function checkWebBuild(build: WebBuild): DoctorCheck {
if (!build.missing) return result("web", "web client", "ok", `built at ${build.entry}`);
return result("web", "web client", "missing", `not built: ${build.missing} does not exist`, WEB_BUILD_COMMAND);
}
/** Runs one check; a check that throws is reported as failed instead of ending the report. */
async function guarded(id: DoctorCheckId, label: string, check: () => Promise<DoctorCheck>): Promise<DoctorCheck> {
try {
return await check();
} catch (error) {
return result(id, label, "failed", `the check itself failed: ${(error as Error).message}`);
}
}
/** Every check of section 11.1, in its order. */
export async function runDoctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
const results: DoctorCheck[] = [checkNode(deps.nodeVersion)];
let found: FoundQemu = { searched: [] };
try {
found = await deps.findQemu();
} catch (error) {
found = { searched: [`(the search failed: ${(error as Error).message})`] };
}
const qemu = await guarded("qemu", "QEMU", () => checkQemuSystem(found, deps.run));
results.push(qemu);
results.push(await guarded("qemu-img", "qemu-img", () => checkQemuImg(found, deps.run)));
const access = await guarded("accelerator", "accelerator", () => deps.acceleratorAccess());
const probe = await guarded("accelerator-probe", "accelerator probe", () => probeAccelerator(deps, qemu, access, found.system));
results.push(confirmedByProbe(access, probe), probe);
results.push(await guarded("disk", DATA_DIRECTORY, async () => checkDataDirectory(deps.home, await deps.freeSpace())));
try {
results.push(...(await deps.images()));
} catch (error) {
const detail = `the check itself failed: ${(error as Error).message}`;
results.push(result("golden-image", "golden image", "failed", detail), result("runtime-image", "runtime ISO", "failed", detail));
}
const webBuild = deps.webBuild;
if (webBuild) results.push(await guarded("web", "web client", async () => checkWebBuild(await webBuild())));
results.push(await guarded("openrouter", "OpenRouter key", () => deps.openRouterKey()));
return results;
}
export function allOk(results: readonly DoctorCheck[]): boolean {
return results.every((r) => r.status === "ok");
}
/** The report as the API and `doctor --json` send it. */
export function doctorAnswer(results: readonly DoctorCheck[]): DoctorAnswer {
return { ok: allOk(results), checks: [...results] };
}
/**
* The OpenRouter key row from what the control plane knows (`openrouter_configured` of its health), asked of
* the scheduler in the API and of the running server by the CLI.
*/
export function openRouterCheck(configured: boolean): DoctorCheck {
const base = { id: "openrouter", label: "OpenRouter key" } as const;
return configured ? { ...base, status: "ok", detail: "stored" } : { ...base, status: "missing", detail: "no key stored", fix: STORE_OPENROUTER_KEY };
}
+174
View File
@@ -0,0 +1,174 @@
/** Errors raised by the vm-manager. Each one says which command or VM it is about, and what fixes it when something can. */
/** The commands that prepare a host (architecture section 11); errors and the doctor report about the host name them. */
export const DOCTOR_COMMAND = "invisible-dots doctor";
export const SETUP_COMMAND = "invisible-dots setup";
/** Stores the OpenRouter key: in a terminal it asks for it, one line; piped, it reads standard input. */
export const STORE_OPENROUTER_KEY = "invisible-dots secret openrouter";
/** A host command exited non-zero, timed out or could not be started. */
export class CommandError extends Error {
readonly command: string;
readonly args: readonly string[];
readonly exitCode: number | null;
readonly stdout: string;
readonly stderr: string;
readonly timedOut: boolean;
/** The errno code when the command could not be started at all (ENOENT when it is not installed). */
readonly code: string | undefined;
constructor(init: {
command: string;
args: readonly string[];
exitCode: number | null;
stdout?: string;
stderr?: string;
timedOut?: boolean;
code?: string;
message?: string;
cause?: unknown;
}) {
const line = [init.command, ...init.args].join(" ");
const stderr = (init.stderr ?? "").trim();
const reason =
init.message ??
(init.timedOut
? "timed out"
: init.code === "ENOENT"
? `command not found (run "${DOCTOR_COMMAND}")`
: `exited with code ${init.exitCode}`);
super(`${line}: ${reason}${stderr ? `: ${stderr}` : ""}`, init.cause === undefined ? undefined : { cause: init.cause });
this.name = "CommandError";
this.command = init.command;
this.args = init.args;
this.exitCode = init.exitCode;
this.stdout = init.stdout ?? "";
this.stderr = init.stderr ?? "";
this.timedOut = init.timedOut ?? false;
this.code = init.code;
}
/** Whether the command itself is missing from the host. */
get notFound(): boolean {
return this.code === "ENOENT";
}
}
/** A feature the contract places out of scope for this version (architecture section 10). */
export class NotImplementedError extends Error {
constructor(feature: string) {
super(`${feature} is out of scope for this version (architecture section 10)`);
this.name = "NotImplementedError";
}
}
/** The operation needs the VM in another state, e.g. a disk resize on a running VM. */
export class VmStateError extends Error {
readonly dotId: string;
constructor(dotId: string, message: string) {
super(`dot ${dotId}: ${message}`);
this.name = "VmStateError";
this.dotId = dotId;
}
}
/** Invalid input to the vm-manager (a bad dot id, a shrinking disk, a missing image). */
export class VmManagerError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "VmManagerError";
}
}
/** QEMU (qemu-system-x86_64 or qemu-img) is not installed where the vm-manager looks (section 3.1). */
export class QemuNotFoundError extends Error {
readonly binary: string;
readonly searched: readonly string[];
constructor(binary: string, searched: readonly string[]) {
super(
`${binary} was not found (looked in: ${searched.length > 0 ? searched.join(", ") : "nothing"}). ` +
`Run "${SETUP_COMMAND}" to install QEMU, or set INVISIBLE_DOTS_QEMU_DIR to the directory that holds it; ` +
`"${DOCTOR_COMMAND}" checks the result.`,
);
this.name = "QemuNotFoundError";
this.binary = binary;
this.searched = searched;
}
}
/**
* QEMU could not use the hardware accelerator. There is no fallback to
* software emulation (section 1.1): the start fails and says what fixes it.
*/
export class AcceleratorUnavailableError extends Error {
readonly accelerator: string;
readonly qemuOutput: string;
constructor(accelerator: string, qemuOutput: string) {
super(
`QEMU could not use the ${accelerator} accelerator, and invisible_dots never falls back to software emulation. ` +
`Run "${DOCTOR_COMMAND}" to see what is missing and "${SETUP_COMMAND}" to fix it. QEMU said: ${qemuOutput.trim() || "nothing"}`,
);
this.name = "AcceleratorUnavailableError";
this.accelerator = accelerator;
this.qemuOutput = qemuOutput;
}
}
/**
* The accelerator does not run the Dots' CPU model (`CPU_MODEL`, section
* 3.4). Section 3.4 forbids replacing it with a guessed one, so this is an
* error to report, not to work around.
*/
export class CpuModelError extends Error {
readonly accelerator: string;
readonly qemuOutput: string;
constructor(accelerator: string, cpuModel: string, qemuOutput: string) {
super(
`QEMU rejected "-cpu ${cpuModel}" with the ${accelerator} accelerator. invisible_dots does not substitute another CPU model ` +
`(architecture section 3.4); run "${DOCTOR_COMMAND}". QEMU said: ${qemuOutput.trim() || "nothing"}`,
);
this.name = "CpuModelError";
this.accelerator = accelerator;
this.qemuOutput = qemuOutput;
}
}
/**
* QEMU exited during start, never set up its port forward, or started but
* never brought the guest up. `qemuOutput` is what QEMU wrote to its log and
* `serialOutput` the end of the guest's serial console: together they are
* what says why, since nothing else watches a VM's insides (section 3.4).
*/
export class VmStartError extends Error {
readonly dotId: string;
readonly qemuOutput: string;
readonly serialOutput: string;
constructor(dotId: string, message: string, qemuOutput = "", options?: { cause?: unknown; serialOutput?: string }) {
const output = qemuOutput.trim();
const serial = (options?.serialOutput ?? "").trim();
super(
`dot ${dotId}: ${message}${output ? `. QEMU said: ${output}` : ""}${serial ? `. The serial console ends with: ${serial}` : ""}`,
options?.cause === undefined ? undefined : { cause: options.cause },
);
this.name = "VmStartError";
this.dotId = dotId;
this.qemuOutput = qemuOutput;
this.serialOutput = options?.serialOutput ?? "";
}
}
/** A guest route answered with an error status, or with something that is not the expected shape. */
export class GuestRequestError extends Error {
readonly status: number;
/** The `error` code of an `{ error, message }` body, when the guest sent one. */
readonly code: string | undefined;
readonly route: string;
constructor(route: string, status: number, message: string, code?: string) {
super(`${route}: ${status === 0 ? "" : `HTTP ${status}: `}${message}`);
this.name = "GuestRequestError";
this.route = route;
this.status = status;
this.code = code;
}
}
Loaded 100 of 968 files, more files were not shown because too many files have changed in this diff. Show more