mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 02:35:57 -10:00
AIHawk: find the jobs that fit your CV and apply to them
This repository is AIHawk again. Dots moved to feder-cr/invisible_dots with its history. Every day a job merges the public job databases into one (pipeline/), one row per posting, and publishes it as a release. `aihawk scan` queries it in place, so a user scrapes nothing and downloads only what the search reads. `score` judges jobs against the CV through OrcaRouter, `apply` tailors the CV and fills the form through invisible-playwright-mcp. The model never sends an application: it fills the form and names the submit control, and AIHawk clicks it after the person's yes, or by itself with --auto-submit. While the model works, a click on what would send the form and the Enter key are refused. What the CV and the answers file do not say comes back as a question, never as a guess.
This commit is contained in:
1 parent
da1c7ff106
commit
8cead59b7b
982 files changed
+2145
-164489
No files matched your search
+1
-29
@@ -1,30 +1,2 @@
|
||||
# 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
|
||||
*.pdf binary
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Pre-push gate. Two halves:
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
# 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" tmp/gates-venv/bin/python tmp/gates-venv/Scripts/python.exe python3 python py; do
|
||||
[ -n "$candidate" ] || continue
|
||||
if [ -x "$candidate" ] || command -v "$candidate" >/dev/null 2>&1; then
|
||||
python_bin="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$python_bin" ]; then
|
||||
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
|
||||
|
||||
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, PackageNotFoundError) as exc:
|
||||
sys.exit("[pre-push] REFUSED - cannot import invisible_core.hooks (%s).\n"
|
||||
"[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"
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
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.
|
||||
@@ -1,17 +0,0 @@
|
||||
## 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 +0,0 @@
|
||||
invisible-core==34.31.0
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/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}`);
|
||||
@@ -1,193 +0,0 @@
|
||||
// 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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");
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"//": "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": "" }
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)."
|
||||
@@ -0,0 +1,34 @@
|
||||
name: job database
|
||||
|
||||
# Every day: read the public job databases, merge them and publish the result as a release, which `aihawk scan` reads.
|
||||
# Users never scrape anything.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: job-database
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 350
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: room on disk
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
df -h /
|
||||
- run: pip install duckdb
|
||||
- run: python pipeline/build.py --out work
|
||||
- run: python pipeline/publish.py --out work/out --repo "$GITHUB_REPOSITORY"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -1,82 +0,0 @@
|
||||
# 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
|
||||
+14
-304
@@ -4,333 +4,43 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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 }})
|
||||
unit:
|
||||
name: lint and unit tests (${{ 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
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: ruff check .
|
||||
- run: pytest -m "not browser"
|
||||
|
||||
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
|
||||
browser:
|
||||
name: the real browser on a real form
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
# 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 -r .github/gates-requirements.txt
|
||||
- name: check the prose
|
||||
run: |
|
||||
python -m invisible_core.english --selftest
|
||||
python -m invisible_core.english
|
||||
|
||||
claims:
|
||||
name: the README promises nothing it cannot support
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: install the pinned gates
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r .github/gates-requirements.txt
|
||||
- name: check the claims
|
||||
run: |
|
||||
python -m invisible_core.claims --selftest
|
||||
python -m invisible_core.claims
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgtk-3-0t64 libasound2t64 libdbus-glib-1-2 libxt6t64
|
||||
- run: xvfb-run -a -s "-screen 0 1920x1080x24" pytest -m browser
|
||||
|
||||
gate:
|
||||
needs: [node, postgres, whatsapp-client, iso, go, engine, smoke, browser-smoke, web, english, claims]
|
||||
if: always()
|
||||
name: gate
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit, browser]
|
||||
if: always()
|
||||
steps:
|
||||
- name: refuse unless every job succeeded
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
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");
|
||||
'
|
||||
- run: test "${{ needs.unit.result }}" = success && test "${{ needs.browser.result }}" = success
|
||||
+11
-32
@@ -1,35 +1,14 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
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__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# The README animation's frames: docs/hero/build.mjs writes them on every run, from the pages and photos beside it
|
||||
/docs/hero/frames/
|
||||
# A search's own files: your CV, your answers and what AIHawk records.
|
||||
.aihawk/
|
||||
aihawk.yaml
|
||||
cv.md
|
||||
answers.yaml
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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)).
|
||||
@@ -0,0 +1,11 @@
|
||||
AIHawk includes or adapts work from these projects.
|
||||
|
||||
src/aihawk/documents.py
|
||||
The shape of the job report (dimensions weighed by judgment, a score from 1 to 5, posting legitimacy
|
||||
judged apart from the score) is adapted from career-ops (https://github.com/santifer/career-ops),
|
||||
MIT License, Copyright (c) 2026 Santiago Fernandez de Valderrama. No file was copied.
|
||||
|
||||
The published job database
|
||||
Is merged from the public sources named in pipeline/build.py, each with its home and licence as far as
|
||||
it states one; each release's manifest.json lists them and which were read that day, and every row
|
||||
names the sources that carried it. Each posting belongs to its employer and links back to the original.
|
||||
@@ -1,104 +1,50 @@
|
||||
<div align="center">
|
||||
<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>
|
||||
# AIHawk
|
||||
|
||||
Open-source, self-hosted alternative to OpenAI Dots, Grok Bot. Built to be undetectable by anti-bot systems.
|
||||
Find the jobs that fit your CV and apply to them.
|
||||
|
||||
## Quickstart
|
||||
AIHawk searches a job database rebuilt every day from every public job database we know of, judges each job against your CV, writes a CV for the job, and fills in the application form in a visible browser. It sends nothing until you say yes.
|
||||
|
||||
You need Node 24+, Go 1.25+, Git and hardware virtualization (x86-64), plus an OpenRouter key.
|
||||
```sh
|
||||
pip install git+https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk
|
||||
export ORCAROUTER_API_KEY=sk-orca-...
|
||||
|
||||
**Windows** (PowerShell):
|
||||
|
||||
```powershell
|
||||
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
|
||||
mkdir my-search && cd my-search
|
||||
aihawk init # writes aihawk.yaml, cv.md and answers.yaml: fill them in
|
||||
aihawk scan # searches the database; uses no model, scrapes nothing
|
||||
aihawk score # judges the new jobs against your CV and goals
|
||||
aihawk list # the best first
|
||||
aihawk apply --top 5 # fills the forms and asks before each send
|
||||
```
|
||||
|
||||
**Linux** (Ubuntu 24.04):
|
||||
## How it works
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
`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).
|
||||
|
||||
## What to ask a Dot
|
||||
|
||||
Anything that needs a computer and a person's judgement, for as long as it takes:
|
||||
|
||||
> 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.
|
||||
|
||||
> 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.
|
||||
|
||||
> 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 a Dot has
|
||||
|
||||
| | |
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| **[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. |
|
||||
| **Find** | Queries the daily database in place, keeping the titles and locations you asked for, without downloading the whole of it. |
|
||||
| **Judge** | Scores each job from 1 to 5 by reasoning about your CV against the posting, and reports the gaps. |
|
||||
| **Tailor** | Reorders and rephrases your CV for the job. Every fact must already be in your CV. |
|
||||
| **Apply** | Fills the form through [invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp) with what your CV and answers file say. What they do not say comes back to you as a question, never as a guess. |
|
||||
|
||||
## Documentation
|
||||
The model never sends an application. It fills the form and names the submit button; AIHawk clicks it after your yes. A click by the model on anything that would send the form is refused.
|
||||
|
||||
- [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)
|
||||
`aihawk apply --auto-submit` sends without asking. An automated application in your name can be wrong, and sites can close accounts for it: read the forms first, and use it only when you accept that.
|
||||
|
||||
## Related projects
|
||||
## Models
|
||||
|
||||
**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).
|
||||
AIHawk talks to one gateway, [OrcaRouter](https://orcarouter.ai), with `orcarouter/auto` as the default model. Change `model` in `aihawk.yaml`, or point `base_url` at any endpoint compatible with OpenAI's API that supports tool calling and structured output.
|
||||
|
||||
## Limits
|
||||
|
||||
- AIHawk fills forms on Greenhouse, Lever and Ashby. Other postings are found and listed, to apply to by hand.
|
||||
- Forms behind a login or a captcha come back as blocked; AIHawk does not try to get around them.
|
||||
- The tailored PDF uses a Latin font, so CVs in other scripts need your own PDF.
|
||||
|
||||
## Thanks
|
||||
|
||||
The job database is built from the public databases listed in [NOTICE](NOTICE); each posting belongs to its employer and links back to it. The scoring rubric's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT).
|
||||
|
||||
The Dots project that used to live here moved to [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
<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>
|
||||
[MIT](LICENSE)
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
@@ -1,634 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 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")];
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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)}` };
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 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 }));
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/** 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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]/);
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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",
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* 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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { BuildOptions } from "esbuild";
|
||||
|
||||
/** The esbuild options of the invisible-dots bundle (bundle-options.mjs). */
|
||||
export declare const BUNDLE_OPTIONS: BuildOptions;
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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);",
|
||||
},
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
/** 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 });
|
||||
}
|
||||
@@ -1,755 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 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}`;
|
||||
@@ -1,10 +0,0 @@
|
||||
/** `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;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/** 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`;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/** 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;
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* 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 +0,0 @@
|
||||
export * from "./cli.js";
|
||||
@@ -1,43 +0,0 @@
|
||||
/** 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);
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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)));
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 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"] };
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,813 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
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`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
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]?$/);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,7 +0,0 @@
|
||||
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";
|
||||
@@ -1,878 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -1,922 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -1,879 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,768 +0,0 @@
|
||||
--
|
||||
-- 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
|
||||
--
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
f38457d1f14a7ea3ee7119f7dc463369ae540f0ba0036e1001a40dba8a997e56
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 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"]));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 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" };
|
||||
}
|
||||
Loaded 100 of 982 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user