mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
Compare commits
357
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96854ec564 | ||
|
|
f6634195d6 | ||
|
|
a1d32f23a8 | ||
|
|
aa90d31e19 | ||
|
|
aa89c53f6f | ||
|
|
89eba3f376 | ||
|
|
cd2ceb2e6d | ||
|
|
267a673fcc | ||
|
|
d000aff9ca | ||
|
|
a93e1e52cb | ||
|
|
d610ed8d8d | ||
|
|
300fe36373 | ||
|
|
5ab69679c5 | ||
|
|
b9455fbc43 | ||
|
|
0f79dd9aa5 | ||
|
|
a59e207d3c | ||
|
|
dac9bda8e3 | ||
|
|
cd4a59a996 | ||
|
|
f7725ad193 | ||
|
|
b5ed2dbe90 | ||
|
|
d5eced75dc | ||
|
|
4ea915ffb4 | ||
|
|
5a77154ff5 | ||
|
|
9a8a351cd1 | ||
|
|
cc06ee41ed | ||
|
|
2a92484eff | ||
|
|
3fea84f645 | ||
|
|
3adacefa78 | ||
|
|
d12ac83050 | ||
|
|
1d11488d65 | ||
|
|
3275746587 | ||
|
|
9de5b04f62 | ||
|
|
4cee3f5a04 | ||
|
|
e67a25eced | ||
|
|
fb3dc4ab6c | ||
|
|
e18bec83e7 | ||
|
|
4b9ea72dcb | ||
|
|
748e4e8f6c | ||
|
|
9f1878f511 | ||
|
|
294eaeb744 | ||
|
|
72c318d4a1 | ||
|
|
1353621739 | ||
|
|
479c7aaf17 | ||
|
|
f0a41a3a94 | ||
|
|
c64a021245 | ||
|
|
9089d88825 | ||
|
|
7f9de4092a | ||
|
|
f863c0e000 | ||
|
|
ba2303b437 | ||
|
|
b78169d7cc | ||
|
|
f8d7093c5a | ||
|
|
95e0827833 | ||
|
|
0212a10580 | ||
|
|
d1e8cb3bee | ||
|
|
1cc83c524f | ||
|
|
9f22016d23 | ||
|
|
0a88535eba | ||
|
|
7737d2293e | ||
|
|
0f6b9b02ca | ||
|
|
7ea02ac86e | ||
|
|
2f342b721d | ||
|
|
0a483f97d2 | ||
|
|
81a4cac39c | ||
|
|
42109ed275 | ||
|
|
c3f5ab3912 | ||
|
|
c61ab594d3 | ||
|
|
0bd868fc53 | ||
|
|
09b8b75e90 | ||
|
|
3da4faa3c9 | ||
|
|
524f9dd241 | ||
|
|
17c70e76dc | ||
|
|
d559055ef2 | ||
|
|
7987df831b | ||
|
|
7793e25fad | ||
|
|
5358834a9f | ||
|
|
9b26b349d7 | ||
|
|
b3a1a5a0f0 | ||
|
|
5437d7cd15 | ||
|
|
708c28917f | ||
|
|
a7121c7c98 | ||
|
|
7acc7899b2 | ||
|
|
3146c3f891 | ||
|
|
6f04dd0b3e | ||
|
|
9f72278f35 | ||
|
|
0a331e936c | ||
|
|
cd37c8f9fd | ||
|
|
844f2d5684 | ||
|
|
69d93438cd | ||
|
|
b23cadb276 | ||
|
|
19465fc04f | ||
|
|
a43d74c798 | ||
|
|
e758bd61e0 | ||
|
|
290de45e4d | ||
|
|
8804a31ffd | ||
|
|
02ba09f666 | ||
|
|
a7bbca6ed0 | ||
|
|
f3223b8569 | ||
|
|
8966ec97a3 | ||
|
|
7b193af502 | ||
|
|
881badd0ee | ||
|
|
e74f532b83 | ||
|
|
5e55568f53 | ||
|
|
a9ed365344 | ||
|
|
4d61118129 | ||
|
|
4c98668d75 | ||
|
|
78d7809c6a | ||
|
|
10712972e8 | ||
|
|
60039535b3 | ||
|
|
33234f9eb3 | ||
|
|
2fe2f31fea | ||
|
|
d3573d06b3 | ||
|
|
60d252d49c | ||
|
|
c843b75165 | ||
|
|
9d8c6849d9 | ||
|
|
a2dc29b491 | ||
|
|
cc598fddd7 | ||
|
|
1c054dc6d3 | ||
|
|
3cfdd23581 | ||
|
|
bffe28e385 | ||
|
|
4ccd937475 | ||
|
|
90e63afe8f | ||
|
|
efa34bd88e | ||
|
|
744d710e34 | ||
|
|
f1e764854f | ||
|
|
5356361bbb | ||
|
|
fd80fa6181 | ||
|
|
9a4858ab52 | ||
|
|
afd7e0a688 | ||
|
|
cde65e964b | ||
|
|
eb54b9b4f7 | ||
|
|
4c68f897a0 | ||
|
|
f7ef2564bb | ||
|
|
7e2da5ab31 | ||
|
|
d2f93b5850 | ||
|
|
dcec5023b5 | ||
|
|
9094020f7f | ||
|
|
d1c3e2d0e6 | ||
|
|
69be2d40b5 | ||
|
|
ce01c37a31 | ||
|
|
cdf2129e86 | ||
|
|
9840ed6da4 | ||
|
|
193b5ed027 | ||
|
|
a5ac30fbba | ||
|
|
2d036b1d2c | ||
|
|
776bfee32b | ||
|
|
e9a9c7746e | ||
|
|
0c82d4317a | ||
|
|
7179db6da6 | ||
|
|
4ea8594019 | ||
|
|
1a837c5c72 | ||
|
|
6f66f1b767 | ||
|
|
5c676d8151 | ||
|
|
e2efac4135 | ||
|
|
1572ff93b4 | ||
|
|
64ebd4aa42 | ||
|
|
7686bcafe1 | ||
|
|
0da41b3b69 | ||
|
|
99e2a8a16a | ||
|
|
750e65ea80 | ||
|
|
b3338a99e4 | ||
|
|
f8de6b19eb | ||
|
|
da5517bd4f | ||
|
|
c88169fbb7 | ||
|
|
927d01f797 | ||
|
|
db192afb8e | ||
|
|
934f153d05 | ||
|
|
5751d35810 | ||
|
|
04f93d3e5a | ||
|
|
8cead59b7b | ||
|
|
da1c7ff106 | ||
|
|
44a51c67f5 | ||
|
|
ef69f5e19c | ||
|
|
ed406afb14 | ||
|
|
2968ca8372 | ||
|
|
4d20e947a1 | ||
|
|
6712104ea5 | ||
|
|
a2584f2619 | ||
|
|
fe03ee6325 | ||
|
|
5c920517e4 | ||
|
|
440e43a1f5 | ||
|
|
00f63e88c1 | ||
|
|
9610d12e29 | ||
|
|
81eb3ba067 | ||
|
|
9151ce3386 | ||
|
|
96eee2ba8c | ||
|
|
fc470314f2 | ||
|
|
361b770285 | ||
|
|
6a9cc8c113 | ||
|
|
fb3b667c01 | ||
|
|
c5d48ac017 | ||
|
|
b82ca890ff | ||
|
|
780fb3b46a | ||
|
|
3d0da4e090 | ||
|
|
bd1b5866cc | ||
|
|
1935f47e5a | ||
|
|
08d6786421 | ||
|
|
b50f09ed40 | ||
|
|
9ee1f32094 | ||
|
|
373d837e29 | ||
|
|
2e762e77d5 | ||
|
|
528be98890 | ||
|
|
ac26cec1bf | ||
|
|
5316d43308 | ||
|
|
5a65ada65d | ||
|
|
b0514eac62 | ||
|
|
c8cb603941 | ||
|
|
825740351d | ||
|
|
4e376c1927 | ||
|
|
1cbf4609d8 | ||
|
|
8c6f3cb010 | ||
|
|
61e6dc21b4 | ||
|
|
21500338a3 | ||
|
|
0060eee57a | ||
|
|
0c51c42c60 | ||
|
|
e03edb06d4 | ||
|
|
4f4cbca2f4 | ||
|
|
bd3cf77274 | ||
|
|
a33c706731 | ||
|
|
7067f0357b | ||
|
|
2c803ef5b5 | ||
|
|
72cd7d4df6 | ||
|
|
33d82c57d7 | ||
|
|
440f6837d7 | ||
|
|
7e103cc636 | ||
|
|
c5c8c07e66 | ||
|
|
803d03b0ac | ||
|
|
441c96d4e0 | ||
|
|
7378d76e19 | ||
|
|
ba6227765c | ||
|
|
a6c727a5f1 | ||
|
|
cd7e36f190 | ||
|
|
a4472f0394 | ||
|
|
a0c5aefb0e | ||
|
|
f542349ce7 | ||
|
|
46ba8330c8 | ||
|
|
97fea69aee | ||
|
|
4d5e59fd0d | ||
|
|
6875851a43 | ||
|
|
c0cbb552db | ||
|
|
e6ebfc79f8 | ||
|
|
2afd73b0fe | ||
|
|
71bdf9db85 | ||
|
|
a60ee568e3 | ||
|
|
2b354e2a29 | ||
|
|
05bd036027 | ||
|
|
6791bd1e34 | ||
|
|
5c9d964423 | ||
|
|
26ed9228e7 | ||
|
|
540f21b6c5 | ||
|
|
fc07373f9d | ||
|
|
1d6827065b | ||
|
|
f9a21766ad | ||
|
|
7788fbc068 | ||
|
|
669a4f0350 | ||
|
|
b47df8936b | ||
|
|
37d58b616b | ||
|
|
6815e3a253 | ||
|
|
80f4709da6 | ||
|
|
1ece62d3e3 | ||
|
|
e00898b9f0 | ||
|
|
6a324467e7 | ||
|
|
a7d48bc94c | ||
|
|
7406d05d0c | ||
|
|
02a17d5b36 | ||
|
|
71e9408975 | ||
|
|
9e42bfd8b3 | ||
|
|
5e163e969a | ||
|
|
e8e3d71d15 | ||
|
|
3820581c6a | ||
|
|
ad4fbf3306 | ||
|
|
f0227ede4d | ||
|
|
933f12f3b3 | ||
|
|
de3d9a1bd9 | ||
|
|
469b853ecc | ||
|
|
569ccda475 | ||
|
|
1fa281b70d | ||
|
|
504100259b | ||
|
|
7f0c202aff | ||
|
|
c0cbcd0b05 | ||
|
|
17464b6f8a | ||
|
|
c3a26d50b4 | ||
|
|
3e276ae414 | ||
|
|
cd9f24992e | ||
|
|
857ccf40cb | ||
|
|
0070890f84 | ||
|
|
62132694ea | ||
|
|
066738f9b8 | ||
|
|
5eb2b5ceb2 | ||
|
|
f44071d4db | ||
|
|
824be653ab | ||
|
|
5d6c16412d | ||
|
|
71e88ac69e | ||
|
|
dd4c6a75cd | ||
|
|
578739dc11 | ||
|
|
c5fbb26973 | ||
|
|
62f3500c59 | ||
|
|
e0d9596967 | ||
|
|
4d2c514aa2 | ||
|
|
a5b8121fd8 | ||
|
|
c0005a91e3 | ||
|
|
564bed5198 | ||
|
|
44ca4cd1fc | ||
|
|
50d7016204 | ||
|
|
b2e21fe972 | ||
|
|
09b0212c11 | ||
|
|
5997873166 | ||
|
|
d859756de0 | ||
|
|
5afd5f066e | ||
|
|
b6264738c0 | ||
|
|
733d29f568 | ||
|
|
bf5a374995 | ||
|
|
21d1b8d1d7 | ||
|
|
0dbb164d8b | ||
|
|
4d4d346eef | ||
|
|
83bd7928c1 | ||
|
|
9f367dc8a7 | ||
|
|
f4427daab7 | ||
|
|
7f2c17e8ed | ||
|
|
83d97af7f6 | ||
|
|
a4c0ecde64 | ||
|
|
c4e1d97ac9 | ||
|
|
147135e90a | ||
|
|
dd7495871a | ||
|
|
5babbf4580 | ||
|
|
713398d806 | ||
|
|
e0f0fa4382 | ||
|
|
5407240a43 | ||
|
|
c17395dce5 | ||
|
|
57f32de671 | ||
|
|
2fced2beac | ||
|
|
21749e2b1f | ||
|
|
12bd7283f4 | ||
|
|
6c3d0be0d9 | ||
|
|
641ec29deb | ||
|
|
ccf0ec4f00 | ||
|
|
9db8182df4 | ||
|
|
bcf03a0eb2 | ||
|
|
a0bc3a640a | ||
|
|
6abd91e947 | ||
|
|
4f9819f783 | ||
|
|
0b58221579 | ||
|
|
4aa45c5d16 | ||
|
|
5c636cd029 | ||
|
|
39963e5277 | ||
|
|
1415d12f3a | ||
|
|
41eb57ea58 | ||
|
|
cba1ccaee5 | ||
|
|
24170f9547 | ||
|
|
bff0eef8a5 | ||
|
|
6402a753fb | ||
|
|
95415aad0e | ||
|
|
4ec820739b | ||
|
|
4693d47fd9 | ||
|
|
118985398e | ||
|
|
d6cf24e1a6 | ||
|
|
5ff4ef0211 | ||
|
|
28d925f696 |
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,147 +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.
|
||||
#
|
||||
# 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"
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: AIHawk crashed, or did something other than what the README says
|
||||
title: ""
|
||||
---
|
||||
|
||||
**Do not paste personal data.** No CV, name, e-mail address, phone number, sign-in or API key. Replace them with invented ones. If the bug is a security problem, do not file it here: see SECURITY.md.
|
||||
|
||||
**What happened**
|
||||
|
||||
**What you expected**
|
||||
|
||||
**Steps to reproduce**
|
||||
|
||||
1.
|
||||
2.
|
||||
|
||||
**Version and system**
|
||||
|
||||
- `aihawk --version`:
|
||||
- Python version:
|
||||
- Operating system:
|
||||
|
||||
**Log or error text**
|
||||
|
||||
Paste the message from the terminal. Remove anything personal first.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and ideas
|
||||
url: https://github.com/feder-cr/Jobs_Applier_AI_Agent/discussions
|
||||
about: Ask how AIHawk works, or talk over an idea before you build it.
|
||||
- name: Security problem
|
||||
url: https://github.com/feder-cr/Jobs_Applier_AI_Agent/security/policy
|
||||
about: Report it privately. Do not open a public issue for it.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Data source
|
||||
about: Ask for a public source to be removed from AIHawk's job database
|
||||
title: "Data source: "
|
||||
---
|
||||
|
||||
**Do not paste personal data.** No e-mail address, phone number or sign-in. If you need to be contacted, say so and we will reply on this issue. If you would rather not write in public, use the private report in SECURITY.md.
|
||||
|
||||
**Which source** (its name in docs/data-sources.md, such as `riley`, and its address):
|
||||
|
||||
**Who is asking** (your name or handle, and the repository or dataset you own or maintain, so we can check it):
|
||||
|
||||
**What to remove**: the whole source, or only some postings (name the rule or the addresses):
|
||||
|
||||
**Why** (optional): for example, the licence has changed, or the data was published by mistake.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Plugin request
|
||||
about: Ask for AIHawk to apply on a job site it does not know yet
|
||||
title: "Plugin: "
|
||||
---
|
||||
|
||||
**Do not paste personal data.** No CV, name, e-mail address or sign-in.
|
||||
|
||||
**Site name and address** (the site or the careers pages it hosts, such as `jobs.example.com`):
|
||||
|
||||
**An example posting URL**, public, that AIHawk should be able to apply on:
|
||||
|
||||
**What the application form is like**: one page or several steps, whether the form is on the posting or at another address, any captcha or sign-in.
|
||||
|
||||
**Do you have a plugin already?** A plugin is a Markdown file that tells the model how the site's form works. See docs/plugins.md. If you wrote one, say whether you tried it on a real posting and what the form did. A plugin is welcome as a pull request, once it has been tried.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Site not working
|
||||
about: AIHawk cannot fill, or wrongly fills, a job site's form
|
||||
title: "Site not working: "
|
||||
---
|
||||
|
||||
**Do not paste personal data.** The posting's page and the form may hold a real person's details, and so may the log. Remove them, or replace them with invented ones, before you post. Do not paste a CV, a sign-in or an API key.
|
||||
|
||||
**Site** (the site's name, such as Lever or Greenhouse, and the plugin's file name if you know it):
|
||||
|
||||
**Posting URL** (the public link to the job; if it needs a sign-in, say so instead of pasting it):
|
||||
|
||||
**Form URL**, if it is different from the posting (for example the address that ends in `/apply`):
|
||||
|
||||
**What AIHawk did**
|
||||
|
||||
Say which step went wrong: the fields it filled, the question it asked, the "blocked" message, or the send that did not happen.
|
||||
|
||||
**What the form should have done**
|
||||
|
||||
**The log or the screen text**
|
||||
|
||||
Paste what AIHawk showed. Remove personal data first.
|
||||
|
||||
**Did you try the plugin on this site before?** Yes / No. If you wrote a plugin for it, paste its file here, without personal data.
|
||||
@@ -0,0 +1,13 @@
|
||||
## What this changes and why
|
||||
|
||||
## How it was tested
|
||||
|
||||
- [ ] `ruff check .`
|
||||
- [ ] `pytest -m "not browser"`
|
||||
- [ ] `pytest -m browser`, if the change touches the browser, the forms or `apply.py`
|
||||
- [ ] For a plugin: tried on a real posting's form. Say which site, and what the form did, including whether the confirmation showed.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] No personal data in the change: no CV, name, e-mail address, phone number, sign-in or key. Fixtures are invented.
|
||||
- [ ] Nothing here lets the model send a form or an e-mail without the person's yes, or weakens a rule in CONTRIBUTING.md.
|
||||
@@ -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-seven 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": 2099, "allow_skipped": "^generated PowerShell on a real Windows PowerShell " },
|
||||
"win32": { "min_passed": 2102, "allow_skipped": "" }
|
||||
},
|
||||
"postgres": {
|
||||
"linux": { "min_passed": 699, "allow_skipped": "", "require": { "\\bpg\\b": 272 } }
|
||||
},
|
||||
"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": 2223, "allow_skipped": "" }
|
||||
},
|
||||
"playwright": {
|
||||
"linux": { "min_passed": 87, "allow_skipped": "" },
|
||||
"win32": { "min_passed": 87, "allow_skipped": "" }
|
||||
}
|
||||
}
|
||||
@@ -1,69 +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, so a host only ever takes the image of its own inputs. A digest that
|
||||
# already has its release is not built again.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- guest/image-builder/**
|
||||
- virtualization/images/**
|
||||
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@v5
|
||||
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.
|
||||
- run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C guest/dot-agentd build -trimpath -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 and the software guest/image-builder/pins.json and the locks name, each under its own license (THIRD_PARTY_NOTICES.md)."
|
||||
@@ -0,0 +1,36 @@
|
||||
name: job database
|
||||
|
||||
# Every five hours: read the public job databases, merge them and publish the result as a release, which AIHawk reads when it searches.
|
||||
# Five hours because the biggest source, jobhive, publishes around 15:00 UTC and the small new-grad lists every hour; a daily build read them up to a day late.
|
||||
# Minute 23 because GitHub delays schedules at the top of the hour.
|
||||
# Users never scrape anything.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 */5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: job-database
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 200 # shorter than the five hours to the next run, so a hung build does not take its slot
|
||||
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 }}
|
||||
+20
-298
@@ -4,325 +4,47 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
# invisible-playwright-mcp is never pinned: this says on the day a new release of it stops working with AIHawk.
|
||||
schedule:
|
||||
- cron: "41 5 * * *"
|
||||
|
||||
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 }}, Python ${{ matrix.python }})
|
||||
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
|
||||
- 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@v5
|
||||
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 -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
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python: ["3.11", "3.12"]
|
||||
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
|
||||
python-version: ${{ matrix.python }}
|
||||
- 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, 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 hashed
|
||||
# mcp-requirements.lock, 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
|
||||
+12
-26
@@ -1,28 +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
|
||||
|
||||
# Editor and Python leftovers of the gates
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# A search's own files: your CV, your answers and what AIHawk records.
|
||||
.aihawk/
|
||||
aihawk.yaml
|
||||
cv.md
|
||||
answers.yaml
|
||||
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added [docs/sites.md](docs/sites.md): each site's status from real forms and what stops it; the README links to it.
|
||||
- Added [docs/data-sources.md](docs/data-sources.md), each source with its licence and how to ask for its removal, and [docs/configuration.md](docs/configuration.md).
|
||||
- A model error that a smaller batch cannot cure stops at once and says why, instead of ranking by title almost silently.
|
||||
- The job database is shown with its real date.
|
||||
- A posting the judge could not read is no longer passed over for good: a later search judges it.
|
||||
- After a send, the page is read once it has changed and stopped changing, up to 30 seconds, and the verdict sees its address: a slow confirmation page is no longer judged on the old form.
|
||||
- A posting whose title or company looks like screen markup, such as `[/x]`, no longer stops the screen.
|
||||
- A database of your own, named by `database` in `aihawk.yaml`, can no longer make AIHawk read a file on your computer: a posting whose address is not `http` or `https` is left out of a search. The published database already left those out.
|
||||
- A full disk while the job index downloads says the disk is full.
|
||||
- A job whose source published no text is scored on the text of its own page where that can be read, which also finds postings that have closed.
|
||||
- The README shows real captures of the screen.
|
||||
- Sending says, per job, how it went, and the screen says what to do next. The list fits an 80-column terminal, the index download shows its progress, and a job's details can select it or open it.
|
||||
- One command starts AIHawk, and everything else is on the screen: `e` opens the settings.
|
||||
- A form no longer stops to ask. The model fills every required field from what the CV says and implies, and a written field it has nothing for says to see the attached CV.
|
||||
- The checks on the model's calls are gone, for far less code: it is told not to send, and nothing holds it to that. See SECURITY.md.
|
||||
- Less to carry: your own CV goes with every form, in place of one written for each job, and there is one gateway, one key variable and one folder.
|
||||
- A form no longer runs out of steps pressing a key that scrolls nothing: the model is told not to repeat what changed nothing.
|
||||
- What goes wrong in the browser now reaches the screen as itself, a refused key included, in place of "unhandled errors in a TaskGroup". A page that says nothing yet after the send is read again when it changes, and a click that breaks is never called failed, because it may have gone.
|
||||
- Every posting is tried. A plugin helps the model where there is one, and there is no list of sites AIHawk will not touch.
|
||||
- A setting of the wrong type in `aihawk.yaml` is one plain line instead of a traceback.
|
||||
|
||||
## 1.0.0 - 2026-11-04
|
||||
|
||||
The first version of AIHawk in this repository. Give it a resume (PDF, Word or text) and it writes a search plan from it, lists the matching jobs from the published job database, scores the best ones against the CV through OrcaRouter, and fills their application forms in a visible browser. It sends nothing until you select the jobs and press `a` and Enter. Sites are covered by plugins: Markdown files in `src/aihawk/plugins/`, described in [docs/plugins.md](docs/plugins.md).
|
||||
|
||||
This repository was the earlier AIHawk, then the Dots project, and is AIHawk again: this version is a different program from both, does not read their files, and the stars date from the earlier AIHawk. Dots now lives at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Contributing
|
||||
|
||||
AIHawk is a command-line program in Python. The most useful contribution is a plugin for a job site: a Markdown file that tells the model how that site's application form works. Write one by following [docs/plugins.md](docs/plugins.md).
|
||||
|
||||
## Setup
|
||||
|
||||
You need Python 3.11 or newer. CI runs the unit tests on 3.11 and 3.12, on Ubuntu, Windows and macOS.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/feder-cr/Jobs_Applier_AI_Agent
|
||||
cd Jobs_Applier_AI_Agent
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -e ".[dev]"
|
||||
aihawk --help
|
||||
```
|
||||
|
||||
The install pulls in `invisible-playwright-mcp` at its newest release from PyPI. It is not pinned, on purpose: the CI runs every day to show when a new release stops working with AIHawk.
|
||||
|
||||
Running `aihawk` for real needs an OrcaRouter key and makes model calls. The tests need neither: they use scripted fakes (`tests/fakes.py`) and make no network calls to a model.
|
||||
|
||||
AIHawk keeps its files in `~/.aihawk` (on Windows, `%USERPROFILE%\.aihawk`).
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
pytest -m "not browser" # unit tests: no browser, no model
|
||||
pytest -m browser # the real browser on a local form
|
||||
```
|
||||
|
||||
The browser tests run the real `invisible-playwright-mcp` and its Firefox against a form served from `127.0.0.1`, not a live site. On Linux without a display, run them under `xvfb-run -a`, as CI does.
|
||||
|
||||
## Lint
|
||||
|
||||
```sh
|
||||
ruff check .
|
||||
```
|
||||
|
||||
Ruff reads its settings from `[tool.ruff]` in `pyproject.toml`: the default rules only. The line length of 160 is set there but not enforced: `ruff check` does not run E501, so a longer line does not fail it. CI runs this exact command.
|
||||
|
||||
## What CI runs
|
||||
|
||||
The workflow is `.github/workflows/tests.yml`. It runs on every push to `main`, on every pull request, and every day at 05:41 UTC.
|
||||
|
||||
- **unit**, on Ubuntu, Windows and macOS, Python 3.11 and 3.12: `pip install -e ".[dev]"`, then `ruff check .`, then `pytest -m "not browser"`.
|
||||
- **browser**, on Ubuntu: installs the Firefox libraries and xvfb, then `xvfb-run -a -s "-screen 0 1920x1080x24" pytest -m browser`.
|
||||
- **gate**: passes only if both jobs pass.
|
||||
|
||||
The other workflow, `.github/workflows/jobs-database.yml`, builds the job database and publishes it as a release. It runs on a schedule and by hand, not on pull requests.
|
||||
|
||||
## Before you open a pull request
|
||||
|
||||
- Run `ruff check .` and `pytest -m "not browser"`. Run `pytest -m browser` too if your change touches `apply.py`, the browser or the forms.
|
||||
- Keep one change to one purpose. Say in the description what you tried and what you saw.
|
||||
- For a plugin, say which posting you tried it on and what the form did. See [docs/plugins.md](docs/plugins.md) for how to test one without sending anything.
|
||||
|
||||
## What not to send
|
||||
|
||||
- A plugin you have not tried on a real posting's form. A plugin is a prompt, and an untried prompt fills forms wrongly.
|
||||
- Anything that weakens what the person controls: a change that lets the model send a form or an email without the person's yes, that lets a page's text steer the model past the rules in `apply.py`, that reads mail beyond what the mail plugins allow, or that gets around a captcha or a login.
|
||||
- Real personal data in fixtures or examples: no real CVs, names, e-mail addresses, phone numbers or screenshots of someone's application. Invent the data.
|
||||
|
||||
Use the issue templates for bugs and plugin requests. For a security problem, do not open an issue: follow [SECURITY.md](SECURITY.md). Questions go to [Discussions](https://github.com/feder-cr/Jobs_Applier_AI_Agent/discussions).
|
||||
|
||||
## License
|
||||
|
||||
By contributing you agree that your change is released under the [MIT license](LICENSE).
|
||||
@@ -0,0 +1,17 @@
|
||||
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.
|
||||
|
||||
src/aihawk/profile.py, src/aihawk/database.py
|
||||
The search plan's rules and the rarity weight follow the planner of the author's earlier job-search engine; the credit is to that work.
|
||||
|
||||
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, with the rows each gave or the error that left
|
||||
it out that day, and every row names the sources that carried it. Each posting belongs to its employer and links back to the original.
|
||||
docs/data-sources.md lists each source with its licence and how to ask for its removal. AIHawk's MIT licence
|
||||
covers its code, not this data. Job data from Headcount Labs (https://headcountlabs.com), ODbL 1.0, with location
|
||||
data from OpenStreetMap contributors (ODbL) and Overture Maps. Forward Deployed Engineer jobs from FDE Times, CC BY 4.0.
|
||||
@@ -1,826 +1,70 @@
|
||||
<h1 align="center">invisible_dots</h1>
|
||||
|
||||
<p align="center"><b>AI agents that each own a computer.</b><br>
|
||||
A virtual machine on your PC with a desktop, a browser built not to look automated,<br>
|
||||
files, memory and skills that outlast every task, and permissions you set.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/feder-cr/dots/actions/workflows/tests.yml"><img alt="tests" src="https://github.com/feder-cr/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">
|
||||
<a href="#quickstart"><b>Quickstart</b></a> ·
|
||||
<a href="#what-to-ask-a-dot"><b>What to ask</b></a> ·
|
||||
<a href="#how-it-works"><b>How it works</b></a> ·
|
||||
<a href="docs/architecture.md"><b>Architecture</b></a> ·
|
||||
<a href="#security-model-and-known-limits"><b>Security</b></a> ·
|
||||
<a href="#privacy"><b>Privacy</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/images/hero-dark.png">
|
||||
<img alt="A Dot's chat beside its computer: asked to open a browser identity and read example.com, the Dot answers with the page's title and first sentence, and the desktop shows the browser open on that page." src="docs/images/hero-light.png" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
A **Dot** is a persistent AI agent with a computer of its own. You give it a
|
||||
name, a model and a set of permissions; invisible_dots gives it a QEMU virtual
|
||||
machine on your own PC, with a disk that persists, a Linux desktop, a shell, its
|
||||
own files, memory and skills, and browser identities that keep their cookies and
|
||||
logins from one task to the next. You talk to it from a web UI, the command
|
||||
line, an HTTP API, Telegram, or (opt-in) WhatsApp. The model is any model on
|
||||
OpenRouter, paid with your key.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **A computer, not a sandbox for one command.** Each Dot has a
|
||||
hardware-accelerated VM (KVM on Linux, Windows Hypervisor Platform on
|
||||
Windows) with a persistent disk. It powers off after a quiet spell
|
||||
(`idle_timeout`, 15 minutes in the sample) and starts again for the next
|
||||
message, the next task or one of its own automations; disk, identities,
|
||||
memory and skills stay.
|
||||
- **A browser built not to look automated.** The only browser of a Dot is
|
||||
[invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp),
|
||||
on [invisible_playwright](https://github.com/feder-cr/invisible_playwright): a
|
||||
Firefox patched in C++, with the fingerprint set inside the engine instead of
|
||||
injected into the page. Each identity keeps its own profile and fingerprint.
|
||||
- **It learns, as Claude Code does.** The Dot keeps its own memory, notes in
|
||||
`/home/dot/memory`, and its own skills, how it does a kind of task, in
|
||||
`/home/dot/skills`. There is nothing to set: it writes them with its file
|
||||
tools, and its prompt names them.
|
||||
- **You set what it may do.** Every tool belongs to a permission, and each
|
||||
permission is allow, ask or deny. An ask waits for your answer, survives a
|
||||
restart, and lets that one call run once.
|
||||
- **A crash loses nothing you saw accepted.** A restart of the server, a crash
|
||||
of the Dot's engine or a `kill -9` keeps every message and task you were told
|
||||
was accepted, and a tool call cut short is never run twice: the model is told
|
||||
its outcome is unknown and checks before trying again.
|
||||
- **Local and private.** The reasoning happens inside the Dot's VM; the control
|
||||
plane on your PC runs the VMs, the queue, the events, the approvals and the
|
||||
channels. invisible_dots has no server of its own and no telemetry.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> invisible_dots is alpha. No package or hosted service is published: the one
|
||||
> thing published is the golden image, built by CI from this repository's pinned
|
||||
> inputs and downloaded by `image build` when its inputs are yours; everything
|
||||
> else you build on your own machine. The acceptance run (`tests/e2e/run.ts`) is
|
||||
> written but has never been completed on a machine with an accelerated QEMU. On
|
||||
> Windows a Dot's VM boots under the Windows Hypervisor Platform with the CPU
|
||||
> model `host,-vmx,-svm` (measured; plain `-cpu host` pauses it), and the rest
|
||||
> of the lifecycle there is not measured yet
|
||||
> ([architecture: VM definition](docs/architecture.md#34-vm-definition)).
|
||||
|
||||
**On this page:** [Quickstart](#quickstart) · [What to ask a Dot](#what-to-ask-a-dot) ·
|
||||
[What a Dot can do](#what-a-dot-can-do) · [The web UI](#the-web-ui) ·
|
||||
[The command line](#the-command-line) · [Approvals](#approvals) ·
|
||||
[The browser](#the-browser) · [Talk to it from your phone](#talk-to-it-from-your-phone) ·
|
||||
[How it works](#how-it-works) · [Security](#security-model-and-known-limits) ·
|
||||
[Privacy](#privacy) · [Configuration](#configuration) ·
|
||||
[Troubleshooting](#troubleshooting) · [Updating](#updating-and-uninstalling) ·
|
||||
[Status](#status-and-what-is-not-done-yet) · [Development](#development-and-tests)
|
||||
|
||||
## Quickstart
|
||||
|
||||
Install Node, Go and Git, clone, build the command line, run `setup --all`, run
|
||||
`server`, and open the web UI to make the first Dot.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux or Windows, on x86-64, with hardware virtualization on. macOS is not
|
||||
supported.
|
||||
- Node 24 or newer, Git, and Go 1.25 or newer (to build the guest daemon).
|
||||
- QEMU 8.2 or newer; `setup --all` installs it and enables the accelerator.
|
||||
- At least 20 GiB free for the data directory (`doctor` checks it). Each Dot's
|
||||
disk is a copy-on-write overlay that grows as it writes, up to its
|
||||
`computer.disk` (40 GB in the sample).
|
||||
- Memory: `image build` boots a builder VM with 2 vCPUs and 4 GiB, and each
|
||||
running Dot takes its `computer.memory` (4 GB in the sample). Inside a Dot,
|
||||
each open browser takes roughly 0.8 GB, and at most `max_open` (default 3)
|
||||
are open at once.
|
||||
- A data directory path in plain ASCII with no comma. The default is
|
||||
`~/.invisible-dots`, under your account name, so an account named `José`
|
||||
fails at `doctor`; set `INVISIBLE_DOTS_HOME` to a path such as
|
||||
`C:\invisible-dots` before every command, the server included.
|
||||
- An [OpenRouter](https://openrouter.ai) key.
|
||||
|
||||
### 1. Get the tools and the code
|
||||
|
||||
**Windows**, in PowerShell:
|
||||
|
||||
```powershell
|
||||
winget install -e --id OpenJS.NodeJS.LTS; winget install -e --id GoLang.Go; winget install -e --id Git.Git
|
||||
$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
git clone https://github.com/feder-cr/dots
|
||||
cd dots
|
||||
npm ci
|
||||
npm run build --workspace @invisible-dots/cli
|
||||
```
|
||||
|
||||
**Linux** (Ubuntu 24.04), in bash:
|
||||
|
||||
```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/dots
|
||||
cd dots
|
||||
npm ci
|
||||
npm run build --workspace @invisible-dots/cli
|
||||
```
|
||||
|
||||
### 2. Get the host ready, in one command
|
||||
|
||||
The same command in PowerShell and bash, from the repository folder:
|
||||
|
||||
```sh
|
||||
node apps/cli/dist/invisible-dots.mjs setup --all
|
||||
```
|
||||
|
||||
Run it as yourself, not as root or administrator: it asks for the rights it
|
||||
needs once. It runs four steps in order, and each one skips what is already
|
||||
done; you can still run any of them alone:
|
||||
|
||||
| step | what it does | by hand |
|
||||
|---|---|---|
|
||||
| 1. The guest daemon | Builds `dot-agentd`, the program that runs inside every Dot's VM, for Linux whatever your host is. It stops here, before changing anything, when Go is missing, and prints the command that installs it. | `go -C guest/dot-agentd build -trimpath -o bin/dot-agentd ./cmd/dot-agentd` with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` |
|
||||
| 2. QEMU and its accelerator | Checks the host and fixes only what is missing. On **Windows** it enables the Windows Hypervisor Platform and installs QEMU in one elevated step (one UAC prompt). On **Linux** it installs QEMU with `sudo apt-get` (sudo asks for your password). | `invisible-dots setup` |
|
||||
| 3. The web client | Builds it, unless it is built already. | `npm run build --workspace @invisible-dots/web` |
|
||||
| 4. The guest images | Builds the runtime disk (the daemon and the engine), and downloads the golden image (Ubuntu 24.04, the desktop, the browser) that CI built for exactly these inputs, or builds it here when none is published (a changed pin, a fork, no network). A second run with unchanged inputs does nothing. | `invisible-dots image build` (`--no-download` always builds) |
|
||||
|
||||
Two things can stop it on purpose, and both end with the same advice: run the
|
||||
same command again and it carries on.
|
||||
|
||||
- On **Windows**, if enabling the accelerator needs a restart, it says so and
|
||||
exits with code 5 after step 2. Restart, come back to this folder and run it
|
||||
again.
|
||||
- On **Linux**, if you are not in the `kvm` group yet it prints
|
||||
`sudo usermod -aG kvm $USER`. Log out and in again, then run it again.
|
||||
|
||||
Anything else that fails (a missing Go, a failed download, a build error)
|
||||
stops the run with the reason and with nothing after that step run. `doctor`
|
||||
prints one line per check and the command that fixes each failure; `setup
|
||||
--all` ends by telling you what to do next. Ctrl+C stops the image build
|
||||
cleanly, and a second Ctrl+C ends the command at once.
|
||||
|
||||
### 3. Start the server and open the web UI
|
||||
|
||||
```sh
|
||||
node apps/cli/dist/invisible-dots.mjs server
|
||||
```
|
||||
|
||||
It runs in the foreground until Ctrl+C, and serves the web client too. Open
|
||||
http://127.0.0.2:3000: there is no login. The web client listens on 127.0.0.2,
|
||||
a loopback address of your PC that no Dot's VM reaches (a VM reaches only
|
||||
127.0.0.1, as 10.0.2.2), and adds the API token to each API call itself.
|
||||
|
||||
On a computer that is ready except for the key, Home shows a field for your
|
||||
OpenRouter key; then Create a Dot makes the first Dot. The web client is a
|
||||
companion: if it was not built or its port is taken, the server says why and
|
||||
the API and the command line carry on (`server --no-web` leaves it out).
|
||||
|
||||
<details>
|
||||
<summary>Your first Dot from the command line instead</summary>
|
||||
|
||||
In a second terminal in the same folder:
|
||||
|
||||
```sh
|
||||
node apps/cli/dist/invisible-dots.mjs secret openrouter
|
||||
node apps/cli/dist/invisible-dots.mjs init
|
||||
node apps/cli/dist/invisible-dots.mjs create dot.yaml
|
||||
node apps/cli/dist/invisible-dots.mjs message my-first-dot "List the files in your home folder and say how much disk is free."
|
||||
node apps/cli/dist/invisible-dots.mjs logs my-first-dot
|
||||
```
|
||||
|
||||
`secret openrouter` asks for the key (it is never a command-line argument, and
|
||||
what you paste at the prompt is not shown on the screen). `init` writes
|
||||
`dot.yaml`, the Dot's name, instructions, model, resources and permissions, to edit
|
||||
before `create`. The first Dot takes a while to boot; the message waits in the
|
||||
queue until it is ready. `logs` prints the Dot's events and keeps following new
|
||||
ones until Ctrl+C (`--no-follow` prints and exits). It worked when `logs` shows
|
||||
a `message.assistant` event with the answer and what it cost (`spent_usd`).
|
||||
|
||||
In the rest of this page, `invisible-dots` stands for
|
||||
`node apps/cli/dist/invisible-dots.mjs`, and `invisible-dots --help` lists every
|
||||
command. In **bash**, `alias invisible-dots="node '$PWD/apps/cli/dist/invisible-dots.mjs'"`
|
||||
in the repository folder makes it so (again in each new terminal); in
|
||||
**PowerShell**, type `npx invisible-dots` from the repository folder.
|
||||
|
||||
</details>
|
||||
|
||||
## What to ask a Dot
|
||||
|
||||
Anything that needs a computer and a person's judgement, done the way a person
|
||||
would do it, 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.
|
||||
|
||||
The first is the conversation in the picture above. For the second, the Dot sets
|
||||
up an automation (it asks you first), and its computer is started each morning
|
||||
for it and sleeps again afterwards. For the third, its prompt tells it to keep what it
|
||||
learned as a note or a skill of its own, which the next prompt names.
|
||||
|
||||
## What a Dot can do
|
||||
|
||||
Its tools, each behind a permission
|
||||
([architecture: tools](docs/architecture.md#83-tools)):
|
||||
|
||||
- **Run commands** on its own computer: a shell with a timeout, background
|
||||
jobs, and programs on a pseudo-terminal that it reads as a screen of text. It
|
||||
installs a missing Ubuntu package with `sudo dot-install <package>`.
|
||||
- **Read and write files**: read, list, find, grep, write, edit, apply a patch.
|
||||
- **Use its browsers**: create, launch and close identities; navigate (http
|
||||
and https only), read the page, take screenshots, click, type, select,
|
||||
scroll, go back and forward.
|
||||
- **Look at its desktop** with a screenshot.
|
||||
- **Schedule itself**: add, list and remove its own automations (at a time,
|
||||
every interval, or a cron expression). This one asks you first by default;
|
||||
to pause or remove one, ask the Dot.
|
||||
|
||||
Work reaches it three ways: a message in its one persistent conversation, a
|
||||
queued task (with a priority and an optional start time), or one of its own
|
||||
automations firing. Each task has limits on steps, on context and on cost
|
||||
(`max_cost_per_task_usd`). Long threads are summarized, by the Dot's model or
|
||||
by another model you name (`models.summary`).
|
||||
|
||||
**Memory and skills.** The Dot keeps its long-term memory itself, one note per
|
||||
file in `/home/dot/memory`, and finds it again with its file tools; the newest
|
||||
notes are named in its prompt. A skill says how to do a kind of task: a folder
|
||||
with a `SKILL.md`, the layout Claude Code reads, whose name and one-line
|
||||
description are in every prompt and whose steps the Dot reads before a task the
|
||||
skill covers ([architecture: memory](docs/architecture.md#86-memory)). The
|
||||
built-in `invisible-playwright` skill teaches it the browser: one identity per
|
||||
person it acts as, a selector before coordinates before a screenshot, the real
|
||||
pointer and keyboard only, and saying a task is impossible rather than getting
|
||||
blocked. The Dot writes skills of its own in `/home/dot/skills` as it learns,
|
||||
and one of its own replaces a built-in one of the same name.
|
||||
|
||||
> [!NOTE]
|
||||
> Automations run inside the Dot's computer, and the control plane starts the
|
||||
> computer for them. A Dot that is asleep is started shortly before an
|
||||
> automation is due (90 seconds ahead by default), and it is not put to sleep
|
||||
> while one is due, so a job every minute keeps it up. A run whose time passed
|
||||
> while the computer or the server was down is made once, late, when the
|
||||
> computer starts, never once for each occurrence it missed. A computer you
|
||||
> stopped yourself (`invisible-dots computer <dot> stop`, or Stop in the web UI)
|
||||
> stays off, and its automations are paused until you start it again; a message
|
||||
> or a task still starts it.
|
||||
|
||||
A Dot has no web search or fetch tool, no sub-agents and no tool to message
|
||||
anyone: it reads the web through its own browser, and only the control plane
|
||||
talks to chats.
|
||||
|
||||
## The web UI
|
||||
|
||||
The web UI is the same control plane as the command line, with a page for what
|
||||
you would otherwise read as text. It is set in Geist (bundled with it), has a
|
||||
light and a dark theme that follows your system unless you choose, and fits a
|
||||
phone screen.
|
||||
|
||||
**First run.** On a computer that is not ready for a Dot yet, Home shows a
|
||||
setup checklist: each check of `doctor` that fails, with the command that fixes
|
||||
it and a button that copies it, and a field for the OpenRouter key while none is
|
||||
stored. The Settings page shows the same checks, takes a new key (and pushes it
|
||||
to the Dots that are running), and has the theme.
|
||||
|
||||
**Create a Dot** is a form in three steps (Identity, Brain, Computer and
|
||||
safety) or the same configuration as YAML, and it refuses what the
|
||||
configuration schema refuses before anything is sent. The permissions start
|
||||
from one of three presets:
|
||||
|
||||
| preset | what it does |
|
||||
|---|---|
|
||||
| Careful | Asks you before it runs a command or changes a file. |
|
||||
| Balanced | The defaults: works freely on its own computer, and asks before it deletes a browser identity or adds an automation. |
|
||||
| Autonomous | Never asks, except before it deletes a browser identity. |
|
||||
|
||||
**The Inbox** is everything that needs you, from every Dot: the approvals that
|
||||
wait (the one that has waited longest first), a Dot in an error state, a task
|
||||
that failed in the last 24 hours, and a channel that has to be linked again. An
|
||||
approval says what the Dot wants to do, under which permission and why, shows a
|
||||
command, a diff of a file change, an address or a schedule in the form that
|
||||
reads best, and answers with Allow once, Always allow or Deny with a note. With
|
||||
the keyboard, `j` and `k` move between the cards, `a` allows the selected one
|
||||
once and `d` denies it; a command or a deletion is not allowed by a key press
|
||||
alone.
|
||||
|
||||
**A Dot's page** has seven tabs:
|
||||
|
||||
- **Chat**: the one persistent conversation, with a quiet line for each tool the
|
||||
Dot used to answer and approvals you can answer in place. The Dot's computer
|
||||
is always beside it (above it on a narrow screen); both fill the window to
|
||||
the bottom.
|
||||
- **Tasks**: what is running (with its latest progress line and its cost), what
|
||||
is scheduled and queued, and the history.
|
||||
- **Computer**: Screen, Files and Usage. Screen is the Dot's desktop, where an
|
||||
open browser is a window. Files is a read-only walk through `/home/dot`. Usage
|
||||
shows what the computer was given and what it uses, the model spend, the
|
||||
next automation, and Start, Reboot and Stop.
|
||||
- **Skills**: the built-in skills and the Dot's own, read only.
|
||||
- **Channels**: Telegram and WhatsApp, below.
|
||||
- **Activity**: the whole event log as readable lines, filtered by kind,
|
||||
searchable, and exportable as JSON Lines.
|
||||
- **Settings**: the Dot's configuration, as a form or as YAML. Nothing is saved
|
||||
until you have seen the list of what changes, and a configuration changed
|
||||
somewhere else in the meantime is never overwritten.
|
||||
|
||||
The desktop is shown as pictures that the control plane reads from the guest
|
||||
every few seconds while the page is in view, with a LIVE badge and a warning
|
||||
when a picture is more than 15 seconds old. It is a view and not a remote
|
||||
desktop: it says "The Dot has control", because nothing you do in it reaches the
|
||||
computer. Stop asks first, and says that the computer's automations do not run
|
||||
while you have stopped it.
|
||||
|
||||
## The command line
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `invisible-dots list` | every Dot, with its state |
|
||||
| `invisible-dots status <dot>` | a Dot, its computer and its recent tasks |
|
||||
| `invisible-dots message <dot> <text>` | a message in its one persistent conversation |
|
||||
| `invisible-dots task <dot> <text> [--priority N] [--at ISO-8601]` | a queued task; a higher priority runs first, `--at` sets the earliest start |
|
||||
| `invisible-dots tasks <dot>` | its tasks and their status |
|
||||
| `invisible-dots logs <dot> [--tail N] [--no-follow]` | its events, then new ones as they come |
|
||||
| `invisible-dots approvals [--all]` | pending approvals (all of them with `--all`) |
|
||||
| `invisible-dots approve <id> [--always] [--note text]` | let that one call run; `--always` also allows its permission for the Dot from now on |
|
||||
| `invisible-dots reject <id> [--note text]` | refuse it; the model is told no |
|
||||
| `invisible-dots computer <dot> start\|stop\|reboot` | power its computer on or off by hand |
|
||||
| `invisible-dots browser <dot> identities` | its browser identities |
|
||||
| `invisible-dots channel list [--dot <dot>]` | linked chats and who is paired |
|
||||
| `invisible-dots channel remove telegram\|whatsapp --dot <dot>` | unlink a chat; its token or keys and paired people are deleted |
|
||||
| `invisible-dots secret openrouter --dot <dot>` | a key for one Dot instead of the global one |
|
||||
|
||||
`<dot>` is a Dot's name or id, and `--json` gives machine-readable output.
|
||||
Editing a Dot's configuration and deleting a Dot are done in the web UI (its
|
||||
settings tab) or through the API (`PATCH` and `DELETE /api/dots/:id`); the
|
||||
command line has no command for either yet.
|
||||
|
||||
## Approvals
|
||||
|
||||
- Every tool call goes through one policy gate. `allow` runs it, `deny` tells
|
||||
the model no, `ask` stops the turn and records the call with its arguments
|
||||
([architecture: policy](docs/architecture.md#84-policy)).
|
||||
- The defaults are permissive for the Dot's own computer: running commands,
|
||||
files and the browser are allowed, except deleting a browser identity, which
|
||||
asks; automations ask; anything unknown is denied. Set any permission to `ask`
|
||||
or `deny` in the YAML to be asked first or to forbid it.
|
||||
- An approval can be answered in the web UI, the command line, a Telegram
|
||||
button or a WhatsApp reply. The first answer wins, a second is ignored, and
|
||||
the approved call runs at most once. `approve --always` turns that ask into
|
||||
an allow for the Dot.
|
||||
- Pending approvals survive a restart of the server or of the Dot.
|
||||
|
||||
## The browser
|
||||
|
||||
Each browser identity is a separate Firefox profile under
|
||||
`/home/dot/browsers/<id>/`, with its own cookies, storage, logins and
|
||||
fingerprint, the same fingerprint at every launch. It runs on the Dot's desktop,
|
||||
so it appears in the Screen view and in screenshots of the desktop. The Dot
|
||||
creates, opens and closes identities with its tools, and `invisible-dots
|
||||
browser <dot> identities` lists them.
|
||||
|
||||
A browser has no proxy of its own by default, and nothing asks for one: it
|
||||
inherits the egress of the Dot's VM, and its time zone, language and location
|
||||
follow the exit it actually uses. A proxy for one identity is an explicit
|
||||
option, set when that identity is created, as the URL the browser library reads
|
||||
(`http://user:pass@host:port` or `socks5://host:port`); the library, not this
|
||||
project, judges it when the identity launches.
|
||||
|
||||
A Dot can have a VM proxy: the whole computer of the Dot, browser, commands and
|
||||
the engine's requests to OpenRouter included, then goes out through it. Set it
|
||||
in the Dot's settings (VM proxy) or with
|
||||
`invisible-dots secret proxy --dot <dot>` (asked for like the key; `--clear`
|
||||
removes it). It is a SOCKS5 URL, `socks5://user:password@host:port`, stored
|
||||
encrypted and never shown again, and the Dot uses it from its next start. If the
|
||||
proxy cannot be reached, nothing leaves the VM. When a VM proxy and an identity
|
||||
proxy are both set, the identity's proxy is reached through the VM's tunnel.
|
||||
|
||||
Launching is explicit: a page action on an identity that is not open fails, so
|
||||
denying `browser.identity.launch` cannot be undone by navigating. At most 3
|
||||
identities are open at once (roughly 0.8 GB each), the least recently used one
|
||||
closing first, and at most 20 exist. Screenshots go to the model and are never
|
||||
stored.
|
||||
|
||||
How well the browser holds up against bot checks is measured in
|
||||
[invisible_playwright's README](https://github.com/feder-cr/invisible_playwright),
|
||||
on the suites it names. invisible_dots adds no claim of its own: a site can
|
||||
still refuse a Dot for what it does, and the terms of the sites you point it at
|
||||
still apply.
|
||||
|
||||
## Talk to it from your phone
|
||||
|
||||
**Telegram.** Make a bot with @BotFather (one bot per Dot), then:
|
||||
|
||||
```sh
|
||||
invisible-dots channel add telegram --dot my-first-dot
|
||||
invisible-dots channel pair telegram --dot my-first-dot
|
||||
```
|
||||
|
||||
`channel add` asks for the bot's token and stores it encrypted. `channel pair`
|
||||
prints a one-time link, valid ten minutes: open it in Telegram and press Start,
|
||||
and that account is the Dot's owner from then on. Messages from anyone else are
|
||||
dropped before they cost anything. Approvals arrive with Approve and Reject
|
||||
buttons, the arguments cut to 300 characters. Telegram bot chats are not
|
||||
end-to-end encrypted. Text only.
|
||||
|
||||
A channel has three settings, all on by default: `approvals` (ask approvals in
|
||||
the chat), `show_arguments` (show the tool's arguments in them) and
|
||||
`notify_tasks` (what the Dot says on its own: finished and failed tasks, and
|
||||
the answers to its automations). The Channels tab of the web UI has a switch
|
||||
for each, and the API sets them too:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://127.0.0.1:8787/api/dots/my-first-dot/channels/telegram \
|
||||
-H "Authorization: Bearer $(head -1 ~/.invisible-dots/config/api.token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"settings": {"show_arguments": false}}'
|
||||
```
|
||||
|
||||
**WhatsApp** is opt-in, and it needs a warning first.
|
||||
|
||||
> [!WARNING]
|
||||
> This is not an official way to use WhatsApp. It links the Dot as a device of
|
||||
> a personal account through an unofficial client (Baileys, pinned to one
|
||||
> release candidate), which WhatsApp's terms do not allow for automation and
|
||||
> which can get the account restricted or banned. Use a number of its own, such
|
||||
> as a spare SIM or eSIM, never the one you live on.
|
||||
|
||||
Install the client once, from the repository folder, then start the server with
|
||||
`INVISIBLE_DOTS_WHATSAPP=1`:
|
||||
|
||||
```sh
|
||||
npm run whatsapp:install
|
||||
invisible-dots channel link whatsapp --dot my-first-dot
|
||||
invisible-dots channel pair whatsapp --dot my-first-dot
|
||||
```
|
||||
|
||||
`channel link` shows a QR code to scan under WhatsApp > Settings > Linked
|
||||
devices; the linked device's keys are stored encrypted. `channel pair` prints a
|
||||
link that opens a chat with the number and `pair <code>` ready to send. The Dot
|
||||
never writes first, to anyone, answers only people who paired, in private
|
||||
chats, and takes text only. Approvals are answered in words
|
||||
(`yes ap-xxxxxx` or `no ap-xxxxxx`); a bare `yes` is an ordinary message.
|
||||
|
||||
Baileys depends on `libsignal`, which is GPL-3.0, so nothing of it is installed
|
||||
by default: `npm ci` installs neither, and neither is in the bundled command.
|
||||
`npm run whatsapp:install` installs them into `optional/whatsapp/`, pinned to
|
||||
one release by a lock file with the integrity of every package, and the server
|
||||
loads them from there only when WhatsApp is linked
|
||||
([THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md),
|
||||
[architecture: messaging channels](docs/architecture.md#98-messaging-channels)).
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(max-width: 374px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-small-dark.gif">
|
||||
<source media="(max-width: 374px)" srcset="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-small-light.gif">
|
||||
<source media="(max-width: 1239px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-phone-dark.gif">
|
||||
<source media="(max-width: 1239px)" srcset="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-phone-light.gif">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-dark.gif">
|
||||
<img alt="AIHawk. An animation: the app starts from a terminal and narrows 10 million job postings to the 25 that fit a resume; you pick three and say yes once; a Firefox window fills each form and submits it; the app shows them sent. An illustration, with invented companies and an invented person." src="https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/assets/banner-light.gif" width="100%">
|
||||
</picture>
|
||||
<h3 align="center">An AI job application bot: automated job search, then it auto-applies to the jobs that match your resume, while you sleep.</h3>
|
||||
|
||||
<sub>FEATURED IN</sub><br>
|
||||
<a href="https://www.businessinsider.com/aihawk-applies-jobs-for-you-linkedin-risks-inaccuracies-mistakes-2024-11"><b>Business Insider</b></a> ·
|
||||
<a href="https://techcrunch.com/2024/10/10/a-reporter-used-ai-to-apply-to-2843-jobs/"><b>TechCrunch</b></a> ·
|
||||
<a href="https://www.semafor.com/article/09/12/2024/linkedins-have-nots-and-have-bots"><b>Semafor</b></a> ·
|
||||
<a href="https://www.wired.it/article/aihawk-come-automatizzare-ricerca-lavoro/"><b>Wired</b></a> ·
|
||||
<a href="https://www.theverge.com/2024/10/10/24266898/ai-is-enabling-job-seekers-to-think-like-spammers"><b>The Verge</b></a> ·
|
||||
<a href="https://www.vanityfair.it/article/intelligenza-artificiale-candidature-di-lavoro"><b>Vanity Fair</b></a> ·
|
||||
<a href="https://www.404media.co/i-applied-to-2-843-roles-the-rise-of-ai-powered-job-application-bots/"><b>404 Media</b></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
The web UI and the command line call the HTTP API; the scheduler behind it keeps
|
||||
the queue in a database and starts and stops one QEMU VM per Dot; inside the VM,
|
||||
the engine reasons with OpenRouter, runs the model's commands through the
|
||||
computer daemon and drives its browsers on the VM's desktop; Telegram and
|
||||
WhatsApp reach the scheduler through the channel hub.
|
||||
Give it your resume. The job search finds the postings that fit, you pick, and it fills in and sends each job application on the company's own form.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph PC["Your PC: one invisible-dots server process"]
|
||||
WEB["Web UI<br/>127.0.0.2:3000"]
|
||||
CLI["CLI"]
|
||||
API["HTTP API and SSE<br/>127.0.0.1:8787"]
|
||||
SCH["Scheduler<br/>queue, outbox, approvals, sleep and wake"]
|
||||
HUB["Channel hub"]
|
||||
VMM["vm-manager<br/>the QEMU driver"]
|
||||
DB[("PostgreSQL<br/>embedded PGlite by default")]
|
||||
end
|
||||
subgraph VM["One QEMU VM per Dot"]
|
||||
AGD["dot-agentd<br/>the computer daemon, user dotagentd"]
|
||||
ENG["Engine, a nanobot fork<br/>user dotengine, key in memory"]
|
||||
DESK["Xvfb and XFCE desktop"]
|
||||
BR["invisible-playwright-mcp<br/>one per open identity"]
|
||||
end
|
||||
WEB -->|"API calls with the token"| API
|
||||
CLI -->|"API calls with the token"| API
|
||||
API -->|"messages, tasks, answers"| SCH
|
||||
HUB -->|"chat messages and approvals"| SCH
|
||||
SCH -->|"queue, events, approvals"| DB
|
||||
SCH -->|"start, stop, reboot"| VMM
|
||||
VMM -->|"starts and stops"| VM
|
||||
SCH -->|"loopback port forward<br/>token after an HMAC proof"| AGD
|
||||
AGD <-->|"proxy, and the model's commands"| ENG
|
||||
ENG -->|"browser tools over MCP"| BR
|
||||
BR -->|"windows on"| DESK
|
||||
ENG -->|"model requests"| OR["OpenRouter"]
|
||||
HUB <-->|"bot messages"| TG["Telegram"]
|
||||
HUB <-->|"linked device"| WA["WhatsApp, opt-in"]
|
||||
BR -->|"pages, from the VM's egress"| SITES["The web"]
|
||||
```
|
||||
|
||||
- **One process on the host.** `invisible-dots server` runs the API, the
|
||||
scheduler, the VM driver and the channel hub, and starts the web client as a
|
||||
child (`--no-web` leaves it out). The database is an embedded PostgreSQL
|
||||
(PGlite) in the data directory, or an external PostgreSQL 16 or newer through
|
||||
`DATABASE_URL`.
|
||||
- **One VM per Dot.** QEMU with KVM or the Windows Hypervisor Platform; never
|
||||
software emulation. A copy-on-write disk over a golden image, plus a
|
||||
read-only runtime disk with our code. Both images are built from pinned,
|
||||
hashed inputs
|
||||
([architecture: two images](docs/architecture.md#33-two-images-two-lifetimes)).
|
||||
- **The engine** is a hard fork of [nanobot](https://github.com/HKUDS/nanobot)
|
||||
in Python, cut down to its core: the turn runner, OpenRouter, the tools above,
|
||||
automations, skills, and an MCP client that serves only the browser
|
||||
([invisible_engine_dots/UPSTREAM.md](invisible_engine_dots/UPSTREAM.md)).
|
||||
- **Durable on both sides.** The host writes what it tells a guest in the
|
||||
same transaction as the decision, so a restart loses nothing you saw
|
||||
accepted ([architecture: durable queue](docs/architecture.md#92-durable-queue)).
|
||||
The engine keeps its state in one SQLite file and commits each step with the
|
||||
events that describe it; its event stream keeps its order across a
|
||||
`kill -9`. A task cut short by a crash is started again with a note that the
|
||||
last attempt was interrupted, and fails once it has been started three times
|
||||
([architecture: crash recovery](docs/architecture.md#87-crash-recovery)).
|
||||
- **The server can restart without stopping Dots.** QEMU runs detached, and a
|
||||
new server adopts the VMs it finds running. A VM that dies while its Dot has
|
||||
work is started again.
|
||||
|
||||
The full contract every part is written against:
|
||||
[docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Security model and known limits
|
||||
|
||||
What protects you:
|
||||
|
||||
- The OpenRouter key is never written to the image, the disk or the seed. It
|
||||
is pushed to the Dot after every start and held in the engine's memory only
|
||||
([architecture: secrets](docs/architecture.md#43-secrets)).
|
||||
- The engine and the computer daemon each run as a user of their own, and the
|
||||
model's commands as a third, `dot`, which can reach neither: it cannot read
|
||||
the engine's state or the Dot's token, cannot connect to the engine's socket
|
||||
(so it cannot change the Dot's permissions or approve its own calls), cannot
|
||||
stop either daemon and cannot become root
|
||||
([architecture: processes](docs/architecture.md#41-processes-systemd-each-unit-as-the-user-it-names)).
|
||||
The one thing it may do as root is install Ubuntu packages it is missing,
|
||||
`sudo dot-install <package>...`: the command takes package names and nothing
|
||||
else, so no option reaches apt; the packages' own install scripts run as root,
|
||||
and they come from the Ubuntu archive.
|
||||
- The channel between host and guest goes one way: the host calls the guest
|
||||
on a loopback port, with the Dot's token, and only after the guest proves it
|
||||
holds that token. The API needs a bearer token (the command line reads it
|
||||
from `config/api.token` itself); the web UI needs none and listens on
|
||||
127.0.0.2, which no Dot's VM reaches.
|
||||
- Files the host shows from a Dot come only from `/home/dot`, at most 16 MiB,
|
||||
never served as a type a browser would run.
|
||||
- The proxy of an identity, when it has one, is not shown by the host: an
|
||||
identity says only that it has one, an approval shows it as `***`, and no
|
||||
event carries it.
|
||||
- Secrets on the host (the OpenRouter key, channel tokens, WhatsApp keys) are
|
||||
stored encrypted; see [Configuration](#configuration).
|
||||
|
||||
What it does not protect against, by design or not yet:
|
||||
|
||||
- The model's commands run as `dot`, which can read the proxy of an identity
|
||||
that was given one, password included, from the open browser's environment
|
||||
and from the browser server's own session file, and what the browser server
|
||||
says of a proxy it refuses reaches the model and the engine's log as it is.
|
||||
An identity with no proxy, the default, has nothing to read.
|
||||
- The split between the three users is the guest operating system's. A flaw in
|
||||
the guest's kernel, or a way to become root that this repository does not
|
||||
know of, would end it. The VM is what stands between the Dot and your PC
|
||||
then, with the reach into your PC's loopback listed below.
|
||||
- The computer daemon holds three capabilities (`CAP_SETUID`, `CAP_SETGID` and
|
||||
`CAP_KILL`, to run the model's work as `dot`) and may run one command as
|
||||
root, the power-off. A flaw in the daemon would be worth more than one in an
|
||||
ordinary program. Its one door, a TCP port inside the guest, needs the Dot's
|
||||
token, which `dot` cannot read.
|
||||
- Nothing limits how much disk, memory or processor time the model's commands
|
||||
use of their own VM, so a runaway command can starve the Dot's own daemons
|
||||
until the VM is stopped or restarted.
|
||||
- When typing in the browser is set to `ask`, the approval shows the text being
|
||||
typed, password or not.
|
||||
- Each Dot can reach services on your PC's loopback through `10.0.2.2`.
|
||||
Everything invisible_dots listens on there needs a credential the Dot lacks,
|
||||
but nothing else should listen there unauthenticated
|
||||
([architecture: networking](docs/architecture.md#36-networking-and-its-limits),
|
||||
[architecture: browser identities](docs/architecture.md#6-browser-identities)).
|
||||
|
||||
## Privacy
|
||||
|
||||
invisible_dots runs on your machine and has no server of its own. What leaves
|
||||
it, and to whom:
|
||||
|
||||
- **OpenRouter** gets the conversation, the tool results and what the Dot
|
||||
reads, under your key. Requests carry the headers
|
||||
`HTTP-Referer: https://github.com/feder-cr/dots` and `X-Title: invisible_dots`.
|
||||
- **Telegram and WhatsApp** carry the messages and approval prompts of a
|
||||
linked channel, and can read them. Link previews are off, so neither
|
||||
service fetches a link of a message on its own; a person who taps a link
|
||||
opens it themselves.
|
||||
- **The sites a Dot visits** see its browser, coming from the egress of the Dot's
|
||||
VM, or from the identity's proxy when you gave that one identity a proxy.
|
||||
- **Publishers of what the images are made of** (Ubuntu, uv, the Python
|
||||
packages, the browser engine) serve the downloads when you build them; on
|
||||
Windows, `setup` downloads the official QEMU installer.
|
||||
- **The browser layer.**
|
||||
[invisible-playwright-mcp's privacy policy](https://github.com/feder-cr/invisible_playwright_mcp#privacy-policy)
|
||||
says each browser launch fetches a one-line counter file from a GitHub
|
||||
release, carrying no identifier.
|
||||
- **Address-echo services** (`api.ipify.org`, `icanhazip.com`,
|
||||
`checkip.amazonaws.com`) are asked by the browser library at each launch for
|
||||
the public address the browser exits from, to set the time zone and locale,
|
||||
and it keeps its GeoIP database current from its GitHub release.
|
||||
- **The web UI** loads nothing from other sites: its pages, scripts and styles
|
||||
come from the server on your PC, its fonts (Geist) included. A link you
|
||||
press (to @BotFather, to an OpenRouter page) opens that site.
|
||||
- **No telemetry of invisible_dots.** Nothing of its own reports on how you use
|
||||
it: Next.js's build telemetry is off in the web client's own config, so no
|
||||
way of building it sends any, and the Dots' Ubuntu has no crash reporter and
|
||||
no pollinate. The browser layer's launch counter, above, is that library's.
|
||||
|
||||
Your Dots' disks, memory and conversations stay in the data directory and
|
||||
inside their VMs.
|
||||
|
||||
## Configuration
|
||||
|
||||
A Dot is one YAML file, checked by one schema: name, instructions,
|
||||
model, computer (cpu, memory, disk, idle timeout), permissions and
|
||||
limits. Every field and its range is in
|
||||
[architecture: Dot configuration](docs/architecture.md#7-dot-configuration);
|
||||
`invisible-dots init` writes a sample.
|
||||
|
||||
One server runs per data directory. Secrets (the OpenRouter key, channel
|
||||
tokens, WhatsApp keys) are stored with AES-256-GCM under
|
||||
`config/master.key`; anyone who can read the data directory can decrypt them,
|
||||
so it is kept private to your user.
|
||||
|
||||
<details>
|
||||
<summary>The environment variables the host reads</summary>
|
||||
|
||||
| variable | default | what |
|
||||
|---|---|---|
|
||||
| `INVISIBLE_DOTS_HOME` | `~/.invisible-dots` | the data directory: keys, database, images, VM disks, logs. Plain ASCII, no comma |
|
||||
| `INVISIBLE_DOTS_LISTEN` | `127.0.0.1:8787` | where the server's API listens |
|
||||
| `INVISIBLE_DOTS_URL` | `http://127.0.0.1:8787` | where the CLI finds the server |
|
||||
| `INVISIBLE_DOTS_TOKEN` | first line of `config/api.token` | the API token, instead of the file |
|
||||
| `INVISIBLE_DOTS_WEB_LISTEN` | `127.0.0.2:3000` | where the web client listens; keep it off 127.0.0.1, which every Dot's VM reaches |
|
||||
| `INVISIBLE_DOTS_WEB_ALLOWED_HOSTS` | loopback only | other host names the web client answers to |
|
||||
| `INVISIBLE_DOTS_QEMU_DIR` | official installer's folder, then `PATH` | the one folder QEMU is looked for in |
|
||||
| `INVISIBLE_DOTS_WHATSAPP` | off | `1` offers the WhatsApp channel (after `npm run whatsapp:install`) |
|
||||
| `DATABASE_URL` | embedded PGlite | an external PostgreSQL 16 or newer |
|
||||
| `INVISIBLE_DOTS_DEBUG` | off | `1` adds debug lines to the server's log |
|
||||
|
||||
</details>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `invisible-dots doctor` first: one line per check, and the command that fixes
|
||||
a failure.
|
||||
- The server logs to the terminal it runs in; `INVISIBLE_DOTS_DEBUG=1` adds
|
||||
debug lines.
|
||||
- A Dot that does not start: QEMU's own output is in
|
||||
`~/.invisible-dots/logs/qemu-<dot_id>.log`, and the guest's serial console in
|
||||
`~/.invisible-dots/vms/<dot_id>/serial.log` (rewritten at each start).
|
||||
`invisible-dots list` shows each Dot's id.
|
||||
- What a Dot did and why it stopped: `invisible-dots logs <dot>` and
|
||||
`invisible-dots tasks <dot>`, or the Dot's Activity tab in the web UI.
|
||||
- The web UI does not open: `invisible-dots server` says why it did not start
|
||||
it (not built, port taken) in its output. Its address is
|
||||
http://127.0.0.2:3000, not 127.0.0.1.
|
||||
|
||||
## Updating and uninstalling
|
||||
|
||||
**Updating.** Stop the server (Ctrl+C), then in the repository folder:
|
||||
`git pull`, `npm ci`, the builds of the command line
|
||||
(`npm run build --workspace @invisible-dots/cli`), of the web client
|
||||
(`npm run build --workspace @invisible-dots/web`) and of dot-agentd (step 1 of
|
||||
`setup --all` above), `invisible-dots image build`, and `invisible-dots server`
|
||||
again. Running Dots keep
|
||||
running meanwhile: QEMU is not a child of the server. Each VM start takes the
|
||||
newest runtime image (our code), so `invisible-dots computer <dot> reboot`
|
||||
moves a Dot to it. A Dot keeps the golden image it was created on, and new Dots
|
||||
get the newest one; this version has no way to move a Dot to a new golden image.
|
||||
|
||||
**Uninstalling.** Stop each Dot's computer (`invisible-dots computer <dot>
|
||||
stop`) while the server runs, since a VM outlives the server, then stop the
|
||||
server and delete the data directory (`~/.invisible-dots`, or your
|
||||
`INVISIBLE_DOTS_HOME`) and the repository folder. What `setup` installed stays
|
||||
until you remove it yourself: QEMU, the Windows Hypervisor Platform feature on
|
||||
Windows, and your membership of the `kvm` group on Linux.
|
||||
|
||||
## Status and what is not done yet
|
||||
|
||||
Alpha. Nothing is released yet; the golden image is the one thing published.
|
||||
|
||||
- **The real-VM acceptance run has not been run yet.** It exists
|
||||
(`tests/e2e/run.ts`: build, create, browse, approve, kill the VM, restart,
|
||||
scan the disk for the key) and CI keeps its contract in step with the
|
||||
product, but it has not passed on real hardware; until it does, the guest is
|
||||
tested by the container smokes, which run its daemons without a VM.
|
||||
- **Windows is not verified end to end on real hardware**: the VM boots
|
||||
under WHPX (top of this page), the rest is not measured. It runs the same code
|
||||
path as Linux, and every difference between the two is listed in
|
||||
[architecture: one mechanism on every host](docs/architecture.md#11-one-mechanism-on-every-host).
|
||||
- **The install is one command after the checkout, not before it.** Node, Go
|
||||
and Git, the clone, `npm ci` and the build of the command line itself are
|
||||
still typed by hand, because the command needs them to exist before it can
|
||||
run.
|
||||
- **Channels carry text only**, one Telegram bot per Dot; the official
|
||||
WhatsApp Cloud API is a later adapter on the same hub.
|
||||
- **Not in this version**: snapshots and rollback, backups, quotas, network
|
||||
policies, MCP integrations beyond the browser, controlling the desktop or an
|
||||
interactive terminal for you (the web UI shows the desktop as pictures, and
|
||||
nothing you do there reaches the computer), artifacts, several hosts,
|
||||
organisations and roles, macOS hosts
|
||||
([architecture: out of scope](docs/architecture.md#10-out-of-scope-for-this-version)).
|
||||
|
||||
## Development and tests
|
||||
|
||||
Besides the quickstart's tools, development needs Python 3.11 or newer for the
|
||||
engine, and Docker for the smokes.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test # vitest over the TypeScript workspace and tests/repo
|
||||
git config core.hooksPath .githooks # the pre-push gate: typecheck, vitest, go test, prose checks
|
||||
```
|
||||
|
||||
The pre-push gate's prose checks need Python with the pinned invisible-core. The
|
||||
hook looks for it in `tmp/gates-venv` first:
|
||||
`python -m venv tmp/gates-venv`, then
|
||||
`tmp/gates-venv/bin/python -m pip install -r .github/gates-requirements.txt`
|
||||
(on Windows the interpreter is `tmp\gates-venv\Scripts\python.exe`).
|
||||
|
||||
- The engine: `pip install -e ".[dev]"` and `pytest` in
|
||||
`invisible_engine_dots/` (Linux only: it uses unix sockets).
|
||||
- dot-agentd: `go test ./...` in `guest/dot-agentd/`.
|
||||
- The web client's browser tests: build it (`npm run build --workspace
|
||||
@invisible-dots/web`), install the browser they drive (`npx playwright install
|
||||
chromium`) and run `npm run test:e2e --workspace @invisible-dots/web`. Each run
|
||||
starts the real control plane over a fake VM layer and the built web client as
|
||||
the product runs it.
|
||||
- The engine smoke and the browser smoke run the guest's two daemons, and the
|
||||
real invisible-playwright-mcp with its Firefox, in Linux containers:
|
||||
`bash guest/image-builder/test/smoke/run.sh` (add `--suite browser`); only
|
||||
Docker is needed
|
||||
([the smoke tests README](guest/image-builder/test/smoke/README.md)).
|
||||
- The acceptance run on a real VM, `node tests/e2e/run.ts`, needs a Linux host
|
||||
with KVM and an OpenRouter key, and is not part of CI
|
||||
([the acceptance run README](tests/e2e/README.md)).
|
||||
|
||||
<details>
|
||||
<summary>What CI runs, and where the code lives</summary>
|
||||
|
||||
CI runs on pushes to main and on pull requests: typecheck and vitest on Linux
|
||||
and Windows, the database layer against a real PostgreSQL, dot-agentd's Go
|
||||
tests and static build, the engine's pytest, the ISO images read by the Linux
|
||||
kernel, both smokes, the web build with its Playwright tests, and two prose
|
||||
gates (English only, and a README that promises nothing it cannot support). The
|
||||
vitest, Go, pytest and Playwright runs are counted against the floors in
|
||||
`.github/test-floors.json`, and the smokes fail on a skipped check, so a test
|
||||
that stops running fails the build.
|
||||
|
||||
| folder | what |
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| `apps/` | `api`, `scheduler`, `vm-manager`, `web`, `cli` |
|
||||
| `packages/` | shared types and schemas, database, events, channels, the ISO writer, the SDK |
|
||||
| `guest/` | `dot-agentd` (Go) and the image builder |
|
||||
| `invisible_engine_dots/` | the Dot's engine (Python) and its built-in skills |
|
||||
| `virtualization/` | the pinned QEMU, cloud-init templates, base image metadata |
|
||||
| `tests/` | checks over the whole repository, and the real-VM run |
|
||||
| **1. Reads your resume** | Turns it into a search: the titles, the skills and the places that fit you. |
|
||||
| **2. Searches** | 10 million postings from 24 public sources, rebuilt every five hours, searched on your computer. |
|
||||
| **3. Scores** | Reads 100 postings at a time against your resume, scores from 1 to 5 with the gaps and the reasons, and keeps 50 scored ahead of where you are reading. |
|
||||
| **4. You pick** | Select the jobs you want and say yes once. |
|
||||
| **5. Applies** | A real Firefox opens the company's form, fills every required field, attaches your CV and sends. |
|
||||
| **6. Keeps the answer** | What each page said, with a screenshot, for every application. |
|
||||
|
||||
</details>
|
||||
## Why AIHawk
|
||||
|
||||
## Acknowledgements
|
||||
- **The company's own form.** No LinkedIn account and no Easy Apply: it applies where the company takes applications.
|
||||
- **Every posting is tried.** There is no list of sites it refuses, and a site can be taught in one Markdown file: [what has been confirmed](docs/sites.md).
|
||||
- **A browser built to look like yours.** [invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp) is a real Firefox with a person's hands, in a window you can watch.
|
||||
- **One command.** It installs what it needs, and everything else happens on the screen.
|
||||
|
||||
- [nanobot](https://github.com/HKUDS/nanobot), the agent the Dot's engine is a
|
||||
fork of.
|
||||
- [invisible_playwright](https://github.com/feder-cr/invisible_playwright) and
|
||||
[invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp),
|
||||
the Dot's browser.
|
||||
- [QEMU](https://www.qemu.org), [Ubuntu cloud images](https://cloud-images.ubuntu.com),
|
||||
[PGlite](https://pglite.dev), [Next.js](https://nextjs.org) and
|
||||
[Baileys](https://github.com/WhiskeySockets/Baileys).
|
||||
- The Agent Skills layout of [Claude Code](https://github.com/anthropics/claude-code),
|
||||
which the Dot's skills follow.
|
||||
---
|
||||
|
||||
## License
|
||||
## Install
|
||||
|
||||
MIT ([LICENSE](LICENSE)). The engine under `invisible_engine_dots/` is a fork
|
||||
of nanobot, also MIT, under its own [LICENSE](invisible_engine_dots/LICENSE);
|
||||
parts of this repository's history come from Open Multi-Agent, also MIT. Their
|
||||
notices are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). QEMU (GPL-2.0)
|
||||
is installed from its official installer or your distribution and only run as
|
||||
a separate program, never bundled. The guest operating system, the browser
|
||||
engine and the packages in the golden image keep their own licenses; the
|
||||
published golden image is Ubuntu with those packages, and whoever copies it
|
||||
takes on those licenses
|
||||
([architecture: licensing](docs/architecture.md#113-licensing)). Nothing under
|
||||
the GPL is installed by the default `npm install`; the WhatsApp client, which
|
||||
brings a GPL-3.0 dependency, is an opt-in install.
|
||||
Windows, in PowerShell:
|
||||
|
||||
invisible_dots is an independent project, not affiliated with OpenRouter,
|
||||
Telegram, WhatsApp or Meta.
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/run.ps1 | iex
|
||||
```
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://raw.githubusercontent.com/feder-cr/Jobs_Applier_AI_Agent/main/run.sh | sh
|
||||
```
|
||||
|
||||
You need a key: [get one from OrcaRouter](https://www.orcarouter.ai/ref/ref_accd17afb78fc95ac4d7).
|
||||
|
||||
|
||||
---
|
||||
|
||||
[Sites](docs/sites.md) · [Configuration](docs/configuration.md) · [Writing a plugin](docs/plugins.md) · [Wiki](https://github.com/feder-cr/Jobs_Applier_AI_Agent/wiki) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md)
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=feder-cr/Jobs_Applier_AI_Agent&type=Date&theme=dark">
|
||||
<img alt="Star history of AIHawk" src="https://api.star-history.com/svg?repos=feder-cr/Jobs_Applier_AI_Agent&type=Date" width="100%">
|
||||
</picture>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security
|
||||
|
||||
## Report a problem privately
|
||||
|
||||
Do not open a public issue or a pull request for a security problem. Report it privately with GitHub's private vulnerability reporting:
|
||||
|
||||
1. Open the repository's **Security** tab.
|
||||
2. Choose **Report a vulnerability**. Direct link: https://github.com/feder-cr/Jobs_Applier_AI_Agent/security/advisories/new
|
||||
|
||||
Say what you found, how to reproduce it, and what you think a person could lose. Do not include a real person's CV, e-mail or sign-in, or a working API key. Replace them with invented ones.
|
||||
|
||||
No response time is promised. The report stays private until it is fixed or until you and the maintainer agree on what to publish.
|
||||
|
||||
## What matters most here
|
||||
|
||||
AIHawk reads job pages written by strangers, fills their forms, and sends applications for a person. The problems that matter most are these:
|
||||
|
||||
1. **A posting's page steering the model.** The page's text, scripts and hidden fields reach the model as the content of tool results. Anything that lets a page make the model fill something the person did not give, click a control outside the form, or visit a site the plugin does not name is a serious problem.
|
||||
2. **Sending without the person's yes.** The model is told to leave the send to AIHawk, which clicks after the person selects the jobs and presses `a` and Enter. Nothing checks the model's own calls, so a way to make it send more often than that matters most.
|
||||
3. **Reading mail.** The mail plugins (`gmail.md`, `outlook.md`) tell the model to read only: a code or a link from the site the person applied to, received in the last few minutes. That is an instruction, not a guarantee, and nothing in the code holds it to that.
|
||||
4. **Keys and sign-ins.** See below.
|
||||
|
||||
## Where keys and sign-ins are kept
|
||||
|
||||
- The OrcaRouter key comes from the `ORCAROUTER_API_KEY` environment variable, or from a file named `key` in the AIHawk folder.
|
||||
- The AIHawk folder is `~/.aihawk`. It also holds the CV, the answers and the account password (sealed: encrypted in the `answers` table of `data/jobs.db` with a key in `data/secret.key`, so the two files together open it and either alone does not), the job data, what was sent (`applied.csv` and `data/proofs/`) and the browser profile (`browser-profile/`), which keeps the person's sign-ins.
|
||||
- Do not paste a key, a browser profile or a folder from the AIHawk folder into an issue or a pull request.
|
||||
|
||||
## Known and accepted
|
||||
|
||||
- **Nothing checks what the model does in the browser.** It is told not to send and to stay on the form, and a click, Enter, a script or a visit elsewhere goes through. It can send a form before the person's yes or half filled, and a page that addresses it could try to steer it. Report a case where that happens.
|
||||
- **The model fills every required field.** It takes what the CV and the answers say and works out what they imply, and where they give nothing it writes to see the attached CV, picks the closest option or a plausible number, and ticks the boxes the form requires, statements included. These go out in the person's name, unread, after the one yes.
|
||||
- **The mail plugins read the mailbox that the browser is signed into.** That is how a code or a link is taken from the mail. It is limited by the instructions in those plugins, not enforced by the browser or by the code.
|
||||
- **A captcha or a sign-in is not bypassed.** The model reports the form as blocked, and the person deals with it.
|
||||
- **The model is a third-party service.** Everything sent to it, including the CV and what the pages say, leaves the machine. The model's output is not trusted: the person selects, and the send is checked by AIHawk.
|
||||
|
||||
## Before you send
|
||||
|
||||
- **It goes out in your name, unread.** Where your resume says nothing, the model writes "Please see my attached CV", picks the closest option or a plausible number, and ticks the boxes the form requires, the right to work included. You answer for all of it, and some sites do not allow this.
|
||||
- **Nothing checks the model in the browser.** It could send early or half filled: [SECURITY.md](SECURITY.md).
|
||||
- **Your data.** Your CV and the pages go to OrcaRouter and its providers. The rest stays in `~/.aihawk`.
|
||||
- **Downloads.** About 740 MB of job index and 268 MB of browser. Windows and Linux.
|
||||
- **The job data is not ours.** Some sources state no licence, and one asks not to be republished: [every source](docs/data-sources.md).
|
||||
@@ -1,560 +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.
|
||||
```
|
||||
|
||||
## 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`,
|
||||
`apps/web/src/lib/events/tool-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,106 +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, MIN_API_TOKEN_LENGTH, readApiToken, readSecretFile, writeSecretFile, type HostPaths } from "@invisible-dots/shared";
|
||||
|
||||
/** The same variable the CLI and the web server read, so one value configures all three. */
|
||||
export const API_TOKEN_ENV = ENV.TOKEN;
|
||||
|
||||
/** Tokens shorter than this are refused (the one rule of shared readApiToken). */
|
||||
export const MIN_TOKEN_LENGTH = MIN_API_TOKEN_LENGTH;
|
||||
|
||||
/** 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/scheduler";
|
||||
|
||||
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,518 +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 { ControlPlaneError, errorMessage, silentLogger, type Logger, type Scheduler } from "@invisible-dots/scheduler";
|
||||
import { STREAM_ERROR_EVENT } from "@invisible-dots/sdk";
|
||||
import type {
|
||||
ApprovalsAnswer,
|
||||
ChannelPairingAnswer,
|
||||
ChannelRecord,
|
||||
ChannelsAnswer,
|
||||
DoctorAnswer,
|
||||
DotsAnswer,
|
||||
EventsAnswer,
|
||||
FilesListAnswer,
|
||||
HealthResponse,
|
||||
HostFacts,
|
||||
IdentitiesAnswer,
|
||||
MessagesAnswer,
|
||||
TasksAnswer,
|
||||
UsageAnswer,
|
||||
} from "@invisible-dots/sdk/types";
|
||||
import {
|
||||
APPROVAL_LIST_LIMIT,
|
||||
CONVERSATION_LIST_LIMIT,
|
||||
APPROVAL_STATUSES,
|
||||
CHANNEL_KINDS,
|
||||
LIST_ORDERS,
|
||||
MAX_EVENT_PAGE,
|
||||
TASK_LIST_LIMIT,
|
||||
type ApprovalStatus,
|
||||
type ChannelKind,
|
||||
type DoctorCheck,
|
||||
type ListOrder,
|
||||
type SkillListAnswer,
|
||||
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";
|
||||
|
||||
export const API_VERSION = "0.1.0";
|
||||
|
||||
/** 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) => {
|
||||
const text = bodyOf(request).text;
|
||||
if (typeof text !== "string") throw bad("text must be a string");
|
||||
return reply.code(202).send(await scheduler.sendMessage(request.params.id, 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 }) };
|
||||
});
|
||||
|
||||
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> => ({ tools: await 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 }),
|
||||
);
|
||||
|
||||
// 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);
|
||||
if (note !== undefined && typeof note !== "string") throw bad("note must be a string");
|
||||
if (always !== undefined && always !== true) throw bad("always must be true");
|
||||
return scheduler.resolveApproval(request.params.id, decision, {
|
||||
...(note !== undefined ? { note } : {}),
|
||||
...(always !== undefined ? { 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) => {
|
||||
let dotId: string | undefined;
|
||||
if (request.query.dot_id) {
|
||||
const dot = await scheduler.db.dots.resolve(request.query.dot_id);
|
||||
if (!dot && !request.query.dot_id.includes("_")) throw new ControlPlaneError(404, "not_found", `Dot "${request.query.dot_id}" not found`);
|
||||
dotId = dot?.id ?? request.query.dot_id;
|
||||
}
|
||||
// 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));
|
||||
|
||||
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 { errorMessage, prefixedStderrLogger, Scheduler, type ComputerDriver, type Logger, type SchedulerOptions } from "@invisible-dots/scheduler";
|
||||
import { ensureDir, ENV, hostPaths, type HostPaths } 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,155 +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 { access, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ComputerDriver,
|
||||
ComputerSpecInput,
|
||||
ComputerState,
|
||||
CreatedComputer,
|
||||
GuestApi,
|
||||
GuestEndpoint,
|
||||
Logger,
|
||||
StartedComputer,
|
||||
WaitForHealthOptions,
|
||||
} from "@invisible-dots/scheduler";
|
||||
import { imageLabel, imageVersionOf, type HealthAnswer, type HostPaths, type ImageKind } 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), fileExists(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> {}
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,721 +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 none = await api.listTools(dot.id);
|
||||
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)).some((t) => !t.offered), "permissions pushed");
|
||||
expect(Object.fromEntries((await api.listTools(dot.id)).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("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 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 } 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 { openrouter_api_key: string }).openrouter_api_key);
|
||||
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,299 +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 });
|
||||
|
||||
const together = Promise.all([api.doctor(), api.doctor(), api.doctor()]);
|
||||
await waitFor(() => probes === 1, "the first request to start the probe");
|
||||
// Time for the other two requests to reach the server while the probe is held.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
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: "DISABLED",
|
||||
});
|
||||
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,271 +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 { prefixedStderrLogger, Scheduler } from "@invisible-dots/scheduler";
|
||||
import { FAKE_MAX_IDENTITIES, FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
|
||||
import { computerResources, hostPaths, parseDotConfig, type HostPaths } from "@invisible-dots/shared";
|
||||
import { silentLogger, 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("sk-or-x");
|
||||
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,643 +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, type DotSummary, type InvisibleDotsClient, type TaskRecord } from "@invisible-dots/sdk";
|
||||
import { CHANNEL_KINDS, DEFAULT_WEB_LISTEN, ENV, type ChannelKind, type ChannelRecord, type ComputerAnswer, type StoredEvent } from "@invisible-dots/shared";
|
||||
import { STORE_OPENROUTER_KEY } from "@invisible-dots/vm-manager";
|
||||
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";
|
||||
|
||||
export const CLI_VERSION = "0.1.0";
|
||||
|
||||
/**
|
||||
* 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 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 computer <dot> start|stop|reboot
|
||||
invisible-dots browser <dot> identities list the Dot's browser identities
|
||||
invisible-dots approvals [--all] list pending (or all) approvals
|
||||
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 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 --dot <dot>
|
||||
print a one-time link (valid 10 minutes) that pairs your Telegram account to the Dot
|
||||
invisible-dots channel remove telegram --dot <dot>
|
||||
unlink: the token 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.
|
||||
|
||||
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
|
||||
context_tokens: 32000
|
||||
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" },
|
||||
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" },
|
||||
};
|
||||
|
||||
/** How a channel's account reads: a Telegram bot as @name, the number linked to WhatsApp as +number. */
|
||||
function accountLabel(channel: ChannelRecord): string {
|
||||
if (channel.account === null) return "-";
|
||||
return channel.kind === "whatsapp" ? `+${channel.account}` : `@${channel.account}`;
|
||||
}
|
||||
|
||||
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),
|
||||
c.peers.length === 0 ? "-" : c.peers.map((p) => p.label).join(", "),
|
||||
c.status_detail ? oneLine(c.status_detail, 80) : "",
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 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 }): 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`);
|
||||
if (/\s/.test(value)) throw new UsageError(`the ${what.noun} on stdin contains whitespace; pass only the ${what.noun}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
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 file = resolve(io.cwd, need(args, 0, "<file.yaml>"));
|
||||
let yaml: string;
|
||||
try {
|
||||
yaml = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
throw new UsageError(`cannot read ${file}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`);
|
||||
}
|
||||
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 "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)]);
|
||||
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.slice(0, 10));
|
||||
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 "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 approvals = await (await api()).listApprovals(values.all ? undefined : "pending");
|
||||
out(
|
||||
approvals,
|
||||
approvals.length === 0
|
||||
? values.all
|
||||
? "no approvals\n"
|
||||
: "no pending approvals\n"
|
||||
: pad([
|
||||
["ID", "STATUS", "DOT", "TOOL", "REASON"],
|
||||
...approvals.map((a) => [a.id, a.status, a.dot_id, a.tool, oneLine(a.reason)]),
|
||||
]),
|
||||
);
|
||||
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 "secret": {
|
||||
const kind = need(args, 0, "openrouter|proxy");
|
||||
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 or proxy`);
|
||||
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 +${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;
|
||||
});
|
||||
let recent: StoredEvent[] = [];
|
||||
let after = 0;
|
||||
for (;;) {
|
||||
const page = await c.events(dot.id, { after, limit: 1000 });
|
||||
recent = [...recent, ...page].slice(-Math.max(tail, 1));
|
||||
if (page.length < 1000) break;
|
||||
after = page.at(-1)!.id;
|
||||
}
|
||||
const last = recent.at(-1)?.id ?? after;
|
||||
for (const event of tail === 0 ? [] : recent) 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,188 +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,
|
||||
tokenPath: hostPaths(io.env).apiTokenPath,
|
||||
...(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,78 +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 } from "@invisible-dots/shared";
|
||||
import type { Logger } from "@invisible-dots/vm-manager";
|
||||
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,95 +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>;
|
||||
/** Where `invisible-dots server` writes the API token, which the web client asks for. */
|
||||
tokenPath: string;
|
||||
/** 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 and sign in with the first line of
|
||||
${deps.tokenPath}
|
||||
(the server creates that file the first time it starts)
|
||||
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,176 +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 { 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,
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 sleep(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,656 +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 { 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,
|
||||
};
|
||||
const 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 }[] = [];
|
||||
|
||||
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);
|
||||
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}/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`: {
|
||||
const after = Number(url.searchParams.get("after") ?? 0);
|
||||
return send(res, 200, { events: events.filter((e) => e.id > after) });
|
||||
}
|
||||
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 = [];
|
||||
});
|
||||
|
||||
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).toMatch(/^\d+\.\d+\.\d+\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");
|
||||
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 () => {
|
||||
expect((await cli(["approvals"])).stdout).toContain("apr_1");
|
||||
expect(requests.at(-1)?.query.get("status")).toBe("pending");
|
||||
await cli(["approvals", "--all"]);
|
||||
expect(requests.at(-1)?.query.get("status")).toBeNull();
|
||||
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 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"\}$/);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 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,121 +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 TOKEN_PATH = "/home/someone/.invisible-dots/config/api.token";
|
||||
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: {},
|
||||
tokenPath: TOKEN_PATH,
|
||||
...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(`open http://${DEFAULT_WEB_LISTEN} in your browser and sign in with the first line of\n ${TOKEN_PATH}`);
|
||||
expect(result.text).toContain("the server creates that file the first time it starts");
|
||||
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,63 +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 "./support.js";
|
||||
|
||||
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 {
|
||||
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;
|
||||
this.log.info("task claimed", { taskId: claimed.task.id, dotId: claimed.task.dot_id });
|
||||
this.deliver(claimed.task.dot_id);
|
||||
}
|
||||
} 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,163 +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,
|
||||
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(openrouterApiKey: string): 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,76 +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 and the time of its next automation.
|
||||
*/
|
||||
import type { Repositories } from "@invisible-dots/database";
|
||||
import { TERMINAL_TASK_STATES, type AgentState, type DotState, type OutboundEvent } 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", "DISABLED", "IDLE"];
|
||||
|
||||
export interface AppliedGuestEvent {
|
||||
/** A task reached a terminal state, so the Dot may take the next one. */
|
||||
taskSettled: boolean;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
default:
|
||||
return { taskSettled: false };
|
||||
}
|
||||
}
|
||||
@@ -1,278 +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 { guestErrorCode, guestErrorStatus } from "./driver.js";
|
||||
import type { Lifecycle } from "./lifecycle.js";
|
||||
import { errorMessage, type Clock, type Logger } from "./support.js";
|
||||
|
||||
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;
|
||||
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";
|
||||
}
|
||||
|
||||
export class InboundDelivery {
|
||||
readonly #db: Database;
|
||||
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.#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. */
|
||||
async #giveUp(row: InboundRecord, reason: string): Promise<void> {
|
||||
let settled = false;
|
||||
await this.#db.transaction(async (tx) => {
|
||||
await tx.inbound.drop(row.event.id, reason);
|
||||
if (row.event.type === "task.created" && row.task_id) {
|
||||
settled = (await tx.tasks.transition(row.task_id, "FAILED", { error: reason })) !== null;
|
||||
}
|
||||
});
|
||||
if (row.event.type === "task.created") this.#log.error("task delivery failed for good", { taskId: row.task_id, error: reason });
|
||||
if (settled) this.#onTaskSettled();
|
||||
}
|
||||
|
||||
/** 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,874 +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 } from "./guest-events.js";
|
||||
import { ControlPlaneError, errorMessage, KeyedMutex, sleep, type Clock, type Logger } 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 = new Set<Promise<unknown>>();
|
||||
#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.#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 === "DISABLED" || 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 (memory only in the
|
||||
* guest, so lost with every agent restart) and the runtime config, both
|
||||
* read from the database now. The one place that does this, for READY,
|
||||
* for a changed key 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`);
|
||||
try {
|
||||
await guest.pushSecrets(key);
|
||||
} catch (error) {
|
||||
// Never the guest's own words here: whatever answered may have echoed the key.
|
||||
throw new Error(`the guest did not take the OpenRouter key (${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.
|
||||
*/
|
||||
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;
|
||||
await this.#push(dotId, await this.guest(dotId));
|
||||
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);
|
||||
}
|
||||
|
||||
#runInBackground(what: string, dotId: string, work: () => Promise<unknown>): void {
|
||||
if (this.#closed) return;
|
||||
const tracked: Promise<unknown> = work()
|
||||
.catch((error) => this.#log.error(`${what} failed`, { dotId, error: errorMessage(error) }))
|
||||
.finally(() => this.#background.delete(tracked));
|
||||
this.#background.add(tracked);
|
||||
}
|
||||
|
||||
/** Whether work this class started in the background is still running. */
|
||||
get busy(): boolean {
|
||||
return this.#background.size > 0;
|
||||
}
|
||||
|
||||
/** Wait for the work this class started in the background (tests and shutdown). */
|
||||
async settle(): Promise<void> {
|
||||
while (this.#background.size > 0) await Promise.allSettled([...this.#background]);
|
||||
}
|
||||
|
||||
#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.#runInBackground("recording a VM that stopped by itself", dotId, () => this.#exited(dotId));
|
||||
return;
|
||||
}
|
||||
await sleep(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.#runInBackground("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 } = 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 = stored ? await applyGuestEvent(tx, dotId, event) : { taskSettled: false };
|
||||
return { stored, applied };
|
||||
});
|
||||
if (stored) this.#events.publish(stored);
|
||||
if (applied.taskSettled) this.#onWorkPossible();
|
||||
if (stored && event.type === "agent.started") {
|
||||
// The agent process restarted inside a running VM: it lost the key it keeps in memory.
|
||||
this.#runInBackground("pushing the key again after an agent restart", dotId, async () => {
|
||||
try {
|
||||
await this.syncGuest(dotId);
|
||||
} catch (error) {
|
||||
this.markSuspect(dotId);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
for (const controller of this.#pumps.values()) controller.abort();
|
||||
await Promise.all(this.#pumpDone.values());
|
||||
await this.settle();
|
||||
await this.#driver.close();
|
||||
}
|
||||
}
|
||||
@@ -1,881 +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, 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,
|
||||
checkOpenRouterKey,
|
||||
COMPUTER_STOPPED,
|
||||
computerIsUp,
|
||||
computerResources,
|
||||
CONVERSATION_LIST_LIMIT,
|
||||
DotConfigError,
|
||||
isIdentityAnswer,
|
||||
isStoredEventType,
|
||||
MAX_HOST_FILE_BYTES,
|
||||
newId,
|
||||
parseDotConfig,
|
||||
parseMessageOrigin,
|
||||
parseSize,
|
||||
TASK_CANCELLED_SYSTEM_EVENT,
|
||||
TERMINAL_TASK_STATES,
|
||||
vmName,
|
||||
type ApprovalStatus,
|
||||
type BrowserIdentity,
|
||||
type CreateBrowserIdentityRequest,
|
||||
type DotConfig,
|
||||
type FilesListAnswer,
|
||||
type InboundEvent,
|
||||
type ListOrder,
|
||||
type MessageOrigin,
|
||||
type StoredEvent,
|
||||
type SystemAnswer,
|
||||
type Skill,
|
||||
type ToolInfo,
|
||||
} 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 {
|
||||
ControlPlaneError,
|
||||
errorMessage,
|
||||
notFound,
|
||||
silentLogger,
|
||||
systemClock,
|
||||
type Clock,
|
||||
type Logger,
|
||||
} 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 = new Set<Promise<unknown>>();
|
||||
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.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,
|
||||
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.#track(work);
|
||||
this.#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.#runInBackground("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.#runInBackground("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();
|
||||
const pending = [...this.#background];
|
||||
if (pending.length === 0) {
|
||||
// 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 Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
|
||||
#track<T>(work: Promise<T>): Promise<T> {
|
||||
const tracked = work.finally(() => this.#background.delete(tracked));
|
||||
tracked.catch(() => {});
|
||||
this.#background.add(tracked);
|
||||
return work;
|
||||
}
|
||||
|
||||
/** Run something in the background and log its failure. */
|
||||
#runInBackground(what: string, dotId: string, work: () => Promise<unknown>): void {
|
||||
this.#track(
|
||||
work().catch((error) => {
|
||||
this.#log.error(`${what} failed`, { dotId, error: errorMessage(error) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 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.#runInBackground("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);
|
||||
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.#pushConfig(saved.updated);
|
||||
return saved.updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dot's saved config changed (a PATCH, an "always allow"; `dot.updated` was logged with the change): push it to
|
||||
* the guest. A failed push does not fail the change: the config is pushed again on the next READY.
|
||||
*/
|
||||
async #pushConfig(dot: DotRecord): Promise<void> {
|
||||
try {
|
||||
await this.lifecycle.syncGuest(dot.id);
|
||||
} catch (error) {
|
||||
this.#log.warn("config saved but the push to the guest failed; it is pushed again on the next READY", {
|
||||
dotId: dot.id,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
this.lifecycle.markSuspect(dot.id);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDot(idOrName: string): Promise<AcceptedAnswer> {
|
||||
const dot = await this.requireDot(idOrName);
|
||||
this.#runInBackground("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: string, 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);
|
||||
}
|
||||
|
||||
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.#runInBackground("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.#runInBackground("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.#runInBackground("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. */
|
||||
async listTools(idOrName: string): Promise<ToolInfo[]> {
|
||||
const { dotId, guest } = await this.#runningGuest(idOrName);
|
||||
return (await this.#guestCall(dotId, "list tools", () => guest.listTools())).tools;
|
||||
}
|
||||
|
||||
/** 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?: string; always?: true } = {},
|
||||
): Promise<ApprovalRecord> {
|
||||
const { note, always } = answer;
|
||||
if (note !== undefined && typeof note !== "string") {
|
||||
throw new ControlPlaneError(400, "invalid_request", "note must be a string");
|
||||
}
|
||||
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 !== undefined && always !== true) {
|
||||
throw new ControlPlaneError(400, "invalid_request", "always must be true");
|
||||
}
|
||||
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);
|
||||
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.#pushConfig(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 };
|
||||
}
|
||||
|
||||
/** 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) });
|
||||
this.lifecycle.markSuspect(dotId);
|
||||
}
|
||||
}
|
||||
// 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.#runInBackground("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.#runInBackground("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,86 +0,0 @@
|
||||
/** Small pieces the scheduler is built from: clock, logger, a per-key lock and the error types the API maps to HTTP. */
|
||||
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const systemClock: Clock = { now: () => new Date() };
|
||||
|
||||
/** The logger shape vm-manager uses too, so one logger serves the whole process. */
|
||||
export interface Logger {
|
||||
debug(message: string, fields?: Record<string, unknown>): void;
|
||||
info(message: string, fields?: Record<string, unknown>): void;
|
||||
warn(message: string, fields?: Record<string, unknown>): void;
|
||||
error(message: string, fields?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export function prefixedStderrLogger(prefix: string, debug = false): Logger {
|
||||
const write = (level: string, message: string, fields?: Record<string, unknown>) => {
|
||||
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
|
||||
process.stderr.write(`${new Date().toISOString()} [${prefix}] ${level} ${message}${suffix}\n`);
|
||||
};
|
||||
return {
|
||||
debug: debug ? (m, f) => write("debug", m, f) : () => {},
|
||||
info: (m, f) => write("info", m, f),
|
||||
warn: (m, f) => write("warn", m, f),
|
||||
error: (m, f) => write("error", m, f),
|
||||
};
|
||||
}
|
||||
|
||||
export const silentLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** An error with the HTTP status and `error` code the API answers with (section 9.6). */
|
||||
export class ControlPlaneError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ControlPlaneError";
|
||||
}
|
||||
}
|
||||
|
||||
export const notFound = (what: string, id: string) => new ControlPlaneError(404, "not_found", `${what} "${id}" not found`);
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -1,847 +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,
|
||||
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 RefusedEvent,
|
||||
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;
|
||||
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 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.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.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"): 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: "browser.identity.delete",
|
||||
arguments: { identity_id: "shop-abc123" },
|
||||
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(key: string): Promise<void> {
|
||||
this.#reachable("pushSecrets");
|
||||
this.openrouterKey = key;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
return { tools: this.tools.map((tool) => ({ ...tool, offered: offered(tool) })) };
|
||||
}
|
||||
|
||||
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,172 +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 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,378 +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 } from "@invisible-dots/shared";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { Scheduler, type Logger, type SchedulerOptions } from "../src/index.js";
|
||||
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 (key: string) => {
|
||||
throw new FakeGuestError(502, `upstream said: bad request body {"openrouter_api_key":"${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 \(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,136 +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,
|
||||
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[]; 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("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,127 +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
|
||||
limits:
|
||||
max_steps_per_task: 30
|
||||
context_tokens: 64000
|
||||
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) });
|
||||
}
|
||||
|
||||
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, 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", "model", "models", "name", "permissions"]);
|
||||
expect(Object.keys(minimal!)).not.toContain("instructions");
|
||||
expect(minimal).not.toHaveProperty("computer");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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" };
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* The host report (architecture section 11.1): every check, in the
|
||||
* contract's order, each answering ok / missing / failed and naming the
|
||||
* command that fixes it. It runs in the API process (`GET /api/doctor`) and
|
||||
* in `invisible-dots doctor` alike. Doctor only reads: it never creates a
|
||||
* directory, never opens the database and never changes a setting.
|
||||
*
|
||||
* Status meanings, so every check uses them the same way (`DoctorStatus`):
|
||||
* - ok: present and working.
|
||||
* - missing: absent; the fix line installs or creates it.
|
||||
* - failed: present but not working, or the check itself could not tell.
|
||||
*/
|
||||
import { ENV, type DoctorAnswer, type DoctorCheck, type DoctorCheckId, type DoctorStatus } from "@invisible-dots/shared";
|
||||
import { SETUP_COMMAND, STORE_OPENROUTER_KEY } from "./errors.js";
|
||||
import { isSupportedQemuVersion, MIN_QEMU_VERSION_TEXT, parseQemuVersion, type Accelerator, type QemuVersion } from "./host.js";
|
||||
import { CPU_MODEL, qemuPathProblem } from "./qemu-args.js";
|
||||
import { firstLine, type RunOptions, type RunResult } from "./runner.js";
|
||||
|
||||
/** `npm run build --workspace @invisible-dots/web`: what leaves the web client where `invisible-dots server` serves it from. */
|
||||
export const WEB_BUILD_COMMAND = "npm run build --workspace @invisible-dots/web";
|
||||
|
||||
export interface WebBuild {
|
||||
/** The server script `node` runs. */
|
||||
entry: string;
|
||||
/** The first file or directory of the build that does not exist; undefined when the build is complete. */
|
||||
missing: string | undefined;
|
||||
}
|
||||
|
||||
/** Where the QEMU programs were found, each searched separately (section 3.1). */
|
||||
export interface FoundQemu {
|
||||
system?: string;
|
||||
img?: string;
|
||||
/** The places searched, in order, for the message when something is not found. */
|
||||
searched: string[];
|
||||
/** INVISIBLE_DOTS_QEMU_DIR is set, so it was the only place searched. */
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export type Runner = (command: string, args: readonly string[], options?: RunOptions) => Promise<RunResult>;
|
||||
|
||||
export interface DoctorDeps {
|
||||
/** `process.versions.node`. */
|
||||
nodeVersion: string;
|
||||
/** The vm-manager's choice for this host (section 1.1); throws on a host invisible_dots does not run on. */
|
||||
accelerator(): Accelerator;
|
||||
findQemu(): Promise<FoundQemu>;
|
||||
run: Runner;
|
||||
/** The host side of the accelerator: /dev/kvm or the HypervisorPlatform feature (accelerator-access.ts). */
|
||||
acceleratorAccess(): Promise<DoctorCheck>;
|
||||
/** INVISIBLE_DOTS_HOME, as the server and the image builder will use it. */
|
||||
home: string;
|
||||
/** Free space for INVISIBLE_DOTS_HOME, measured on the nearest directory that exists. */
|
||||
freeSpace(): Promise<{ path: string; bytes: number }>;
|
||||
/** The golden image and the runtime ISO against their manifests, one result each. */
|
||||
images(): Promise<DoctorCheck[]>;
|
||||
/** Where the built web client is, and the first file of it that is missing. Only a process that serves the web client (`invisible-dots server`) has one: without it the report has no web row. */
|
||||
webBuild?(): Promise<WebBuild>;
|
||||
/** Whether an OpenRouter key is stored. */
|
||||
openRouterKey(): Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
export const MIN_NODE_MAJOR = 24;
|
||||
/**
|
||||
* One golden image (a few GiB once provisioned) plus the overlays of a few
|
||||
* Dots, which grow with what each Dot stores. Below this a first `image
|
||||
* build` or a busy Dot runs the disk full.
|
||||
*/
|
||||
export const MIN_FREE_BYTES = 20 * 1024 ** 3;
|
||||
/**
|
||||
* The probe boots the empty machine's firmware, which takes a fraction of a
|
||||
* second with a working accelerator (0.2 s measured with QEMU 8.2 and KVM);
|
||||
* a QEMU still running after this long has a virtual CPU that does not run.
|
||||
*/
|
||||
export const PROBE_TIMEOUT_MS = 30_000;
|
||||
const VERSION_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Starts QEMU with the accelerator and the CPU model of the Dots (section
|
||||
* 3.4) on their machine type with no devices at all, and lets the virtual
|
||||
* CPU run: the firmware finds nothing to boot, `-boot reboot-timeout=0`
|
||||
* makes it reset at once, and `-no-reboot` turns that reset into QEMU
|
||||
* exiting with code 0. So exit code 0 means the accelerator opened AND ran
|
||||
* guest code, which a QEMU held before its first instruction could never
|
||||
* show; there is no monitor to talk to, the same as for a Dot. `-accel` is
|
||||
* given alone, so QEMU cannot fall back to software emulation.
|
||||
*
|
||||
* Measured with QEMU 8.2.2 and KVM: exit 0 after 0.2 s, and the firmware's
|
||||
* debug output reads "No bootable device. Retrying in 0 seconds. Rebooting."
|
||||
* Without `-no-reboot`, or without the reboot timeout, QEMU never exits.
|
||||
*
|
||||
* Not `-machine none`: measured with QEMU 11.1 on Windows, WHPX with
|
||||
* `-machine none` aborts with "X86_MACHINE: Object ... is not an instance of
|
||||
* type x86-machine" (exit 3) even where WHPX works, and it has no CPU to run.
|
||||
*/
|
||||
export function acceleratorProbeArgs(accelerator: Accelerator): string[] {
|
||||
return [
|
||||
"-nodefaults",
|
||||
"-no-user-config",
|
||||
"-machine",
|
||||
"q35",
|
||||
"-accel",
|
||||
accelerator,
|
||||
"-cpu",
|
||||
CPU_MODEL,
|
||||
"-display",
|
||||
"none",
|
||||
"-no-reboot",
|
||||
"-boot",
|
||||
"reboot-timeout=0",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PATH alone can hold dozens of directories, so the line names the search
|
||||
* order of section 3.1 and how many places it covered rather than all of them.
|
||||
*/
|
||||
function notFound(found: FoundQemu): string {
|
||||
const count = found.searched.length;
|
||||
if (count === 1 && found.searched[0]!.startsWith("(")) return `not found ${found.searched[0]}`;
|
||||
if (found.configured) return `not found in ${ENV.QEMU_DIR} (${found.searched[0] ?? ""}), the only place searched while it is set`;
|
||||
return `not found in the official installer's directory or on PATH (${count} ${count === 1 ? "directory" : "directories"} searched)`;
|
||||
}
|
||||
|
||||
function versionText(version: QemuVersion): string {
|
||||
return `${version.major}.${version.minor}.${version.micro}`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
function result(id: DoctorCheckId, label: string, status: DoctorStatus, detail: string, fix?: string): DoctorCheck {
|
||||
return fix === undefined || status === "ok" ? { id, label, status, detail } : { id, label, status, detail, fix };
|
||||
}
|
||||
|
||||
export function checkNode(version: string): DoctorCheck {
|
||||
const major = Number(version.split(".")[0]);
|
||||
if (Number.isInteger(major) && major >= MIN_NODE_MAJOR) return result("node", "Node", "ok", version);
|
||||
return result("node", "Node", "failed", `${version} is older than ${MIN_NODE_MAJOR}`, `install Node ${MIN_NODE_MAJOR} or newer from https://nodejs.org`);
|
||||
}
|
||||
|
||||
async function checkQemuSystem(found: FoundQemu, run: Runner): Promise<DoctorCheck> {
|
||||
const label = "QEMU";
|
||||
const minimum = MIN_QEMU_VERSION_TEXT;
|
||||
if (!found.system) {
|
||||
return result("qemu", label, "missing", `qemu-system-x86_64 ${notFound(found)}`, SETUP_COMMAND);
|
||||
}
|
||||
const answer = await run(found.system, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS });
|
||||
const version = answer.code === 0 ? parseQemuVersion(answer.stdout) : undefined;
|
||||
if (!version) {
|
||||
const why = answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`);
|
||||
return result("qemu", label, "failed", `${found.system} --version did not report a version (${why})`, SETUP_COMMAND);
|
||||
}
|
||||
const text = versionText(version);
|
||||
if (!isSupportedQemuVersion(version)) {
|
||||
return result(
|
||||
"qemu",
|
||||
label,
|
||||
"failed",
|
||||
`${found.system} is version ${text}, older than ${minimum}`,
|
||||
`${SETUP_COMMAND}, or set INVISIBLE_DOTS_QEMU_DIR to a QEMU ${minimum} or newer`,
|
||||
);
|
||||
}
|
||||
return result("qemu", label, "ok", `${text} at ${found.system}`);
|
||||
}
|
||||
|
||||
async function checkQemuImg(found: FoundQemu, run: Runner): Promise<DoctorCheck> {
|
||||
if (!found.img) return result("qemu-img", "qemu-img", "missing", `qemu-img ${notFound(found)}`, SETUP_COMMAND);
|
||||
const answer = await run(found.img, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS });
|
||||
const version = answer.code === 0 ? parseQemuVersion(answer.stdout) : undefined;
|
||||
if (!version) {
|
||||
const why = answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`);
|
||||
return result("qemu-img", "qemu-img", "failed", `${found.img} --version did not report a version (${why})`, SETUP_COMMAND);
|
||||
}
|
||||
return result("qemu-img", "qemu-img", "ok", `${versionText(version)} at ${found.img}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs whenever QEMU is ready, also when the host-side check said the
|
||||
* accelerator is missing: that check reads a precondition, the probe reads
|
||||
* the result. Measured on Windows 11 with VirtualMachinePlatform enabled
|
||||
* (WSL 2): HypervisorPlatform reports disabled and WHPX still works.
|
||||
*/
|
||||
async function probeAccelerator(deps: DoctorDeps, qemu: DoctorCheck, access: DoctorCheck, system: string | undefined): Promise<DoctorCheck> {
|
||||
const label = "accelerator probe";
|
||||
if (qemu.status !== "ok" || !system) {
|
||||
return result("accelerator-probe", label, qemu.status, `not run: QEMU is not ready`, qemu.fix);
|
||||
}
|
||||
const accelerator = deps.accelerator();
|
||||
const command = `qemu-system-x86_64 -accel ${accelerator} -cpu ${CPU_MODEL} -machine q35`;
|
||||
const answer = await deps.run(system, acceleratorProbeArgs(accelerator), { timeoutMs: PROBE_TIMEOUT_MS });
|
||||
if (answer.code === 0 && !answer.timedOut) return result("accelerator-probe", label, "ok", `${command} ran the firmware and exited`);
|
||||
const why = answer.timedOut
|
||||
? `did not exit within ${PROBE_TIMEOUT_MS / 1000} s, so the virtual CPU does not run${firstLine(answer.stderr) ? ` (QEMU said: ${firstLine(answer.stderr)})` : ""}`
|
||||
: (answer.startError?.message ?? (firstLine(answer.stderr) || `exit code ${answer.code}`));
|
||||
const fix =
|
||||
access.status !== "ok" && access.fix
|
||||
? access.fix
|
||||
: "enable hardware virtualization (Intel VT-x or AMD-V) in the firmware settings; after enabling the accelerator, restart the computer";
|
||||
return result("accelerator-probe", label, "failed", `${command} failed: ${why}`, fix);
|
||||
}
|
||||
|
||||
/** A host-side "missing" that the probe contradicts is reported as what it is: QEMU uses the accelerator. */
|
||||
function confirmedByProbe(access: DoctorCheck, probe: DoctorCheck): DoctorCheck {
|
||||
if (access.status !== "missing" || probe.status !== "ok") return access;
|
||||
return result("accelerator", access.label, "ok", `${access.detail}, but the probe below shows QEMU uses the accelerator`);
|
||||
}
|
||||
|
||||
const DATA_DIRECTORY = "data directory";
|
||||
|
||||
/**
|
||||
* INVISIBLE_DOTS_HOME: a path QEMU can be given (the same rule a Dot's start
|
||||
* applies, qemuPathProblem), with enough free space. A home under an account
|
||||
* name with an accent fails here, before an image build or a Dot does.
|
||||
*/
|
||||
export function checkDataDirectory(home: string, space: { path: string; bytes: number }): DoctorCheck {
|
||||
const problem = qemuPathProblem(home);
|
||||
if (problem) {
|
||||
return result("disk", DATA_DIRECTORY, "failed", `${home} ${problem}`, `set ${ENV.HOME} to a directory whose path is plain ASCII without commas`);
|
||||
}
|
||||
const detail = `${formatBytes(space.bytes)} free at ${space.path}`;
|
||||
if (space.bytes >= MIN_FREE_BYTES) return result("disk", DATA_DIRECTORY, "ok", detail);
|
||||
return result(
|
||||
"disk",
|
||||
DATA_DIRECTORY,
|
||||
"failed",
|
||||
`${detail}, less than ${formatBytes(MIN_FREE_BYTES)}`,
|
||||
`free some space, or set ${ENV.HOME} to a directory on a larger disk`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The web client `invisible-dots server` serves: built where the server looks for it. */
|
||||
export function checkWebBuild(build: WebBuild): DoctorCheck {
|
||||
if (!build.missing) return result("web", "web client", "ok", `built at ${build.entry}`);
|
||||
return result("web", "web client", "missing", `not built: ${build.missing} does not exist`, WEB_BUILD_COMMAND);
|
||||
}
|
||||
|
||||
/** Runs one check; a check that throws is reported as failed instead of ending the report. */
|
||||
async function guarded(id: DoctorCheckId, label: string, check: () => Promise<DoctorCheck>): Promise<DoctorCheck> {
|
||||
try {
|
||||
return await check();
|
||||
} catch (error) {
|
||||
return result(id, label, "failed", `the check itself failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every check of section 11.1, in its order. */
|
||||
export async function runDoctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
|
||||
const results: DoctorCheck[] = [checkNode(deps.nodeVersion)];
|
||||
let found: FoundQemu = { searched: [] };
|
||||
try {
|
||||
found = await deps.findQemu();
|
||||
} catch (error) {
|
||||
found = { searched: [`(the search failed: ${(error as Error).message})`] };
|
||||
}
|
||||
const qemu = await guarded("qemu", "QEMU", () => checkQemuSystem(found, deps.run));
|
||||
results.push(qemu);
|
||||
results.push(await guarded("qemu-img", "qemu-img", () => checkQemuImg(found, deps.run)));
|
||||
const access = await guarded("accelerator", "accelerator", () => deps.acceleratorAccess());
|
||||
const probe = await guarded("accelerator-probe", "accelerator probe", () => probeAccelerator(deps, qemu, access, found.system));
|
||||
results.push(confirmedByProbe(access, probe), probe);
|
||||
results.push(await guarded("disk", DATA_DIRECTORY, async () => checkDataDirectory(deps.home, await deps.freeSpace())));
|
||||
try {
|
||||
results.push(...(await deps.images()));
|
||||
} catch (error) {
|
||||
const detail = `the check itself failed: ${(error as Error).message}`;
|
||||
results.push(result("golden-image", "golden image", "failed", detail), result("runtime-image", "runtime ISO", "failed", detail));
|
||||
}
|
||||
const webBuild = deps.webBuild;
|
||||
if (webBuild) results.push(await guarded("web", "web client", async () => checkWebBuild(await webBuild())));
|
||||
results.push(await guarded("openrouter", "OpenRouter key", () => deps.openRouterKey()));
|
||||
return results;
|
||||
}
|
||||
|
||||
export function allOk(results: readonly DoctorCheck[]): boolean {
|
||||
return results.every((r) => r.status === "ok");
|
||||
}
|
||||
|
||||
/** The report as the API and `doctor --json` send it. */
|
||||
export function doctorAnswer(results: readonly DoctorCheck[]): DoctorAnswer {
|
||||
return { ok: allOk(results), checks: [...results] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenRouter key row from what the control plane knows (`openrouter_configured` of its health), asked of
|
||||
* the scheduler in the API and of the running server by the CLI.
|
||||
*/
|
||||
export function openRouterCheck(configured: boolean): DoctorCheck {
|
||||
const base = { id: "openrouter", label: "OpenRouter key" } as const;
|
||||
return configured ? { ...base, status: "ok", detail: "stored" } : { ...base, status: "missing", detail: "no key stored", fix: STORE_OPENROUTER_KEY };
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/** Errors raised by the vm-manager. Each one says which command or VM it is about, and what fixes it when something can. */
|
||||
|
||||
/** The commands that prepare a host (architecture section 11); errors and the doctor report about the host name them. */
|
||||
export const DOCTOR_COMMAND = "invisible-dots doctor";
|
||||
export const SETUP_COMMAND = "invisible-dots setup";
|
||||
/** Stores the OpenRouter key: in a terminal it asks for it, one line; piped, it reads standard input. */
|
||||
export const STORE_OPENROUTER_KEY = "invisible-dots secret openrouter";
|
||||
|
||||
/** A host command exited non-zero, timed out or could not be started. */
|
||||
export class CommandError extends Error {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly exitCode: number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly timedOut: boolean;
|
||||
/** The errno code when the command could not be started at all (ENOENT when it is not installed). */
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(init: {
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
exitCode: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
timedOut?: boolean;
|
||||
code?: string;
|
||||
message?: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
const line = [init.command, ...init.args].join(" ");
|
||||
const stderr = (init.stderr ?? "").trim();
|
||||
const reason =
|
||||
init.message ??
|
||||
(init.timedOut
|
||||
? "timed out"
|
||||
: init.code === "ENOENT"
|
||||
? `command not found (run "${DOCTOR_COMMAND}")`
|
||||
: `exited with code ${init.exitCode}`);
|
||||
super(`${line}: ${reason}${stderr ? `: ${stderr}` : ""}`, init.cause === undefined ? undefined : { cause: init.cause });
|
||||
this.name = "CommandError";
|
||||
this.command = init.command;
|
||||
this.args = init.args;
|
||||
this.exitCode = init.exitCode;
|
||||
this.stdout = init.stdout ?? "";
|
||||
this.stderr = init.stderr ?? "";
|
||||
this.timedOut = init.timedOut ?? false;
|
||||
this.code = init.code;
|
||||
}
|
||||
|
||||
/** Whether the command itself is missing from the host. */
|
||||
get notFound(): boolean {
|
||||
return this.code === "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
/** A feature the contract places out of scope for this version (architecture section 10). */
|
||||
export class NotImplementedError extends Error {
|
||||
constructor(feature: string) {
|
||||
super(`${feature} is out of scope for this version (architecture section 10)`);
|
||||
this.name = "NotImplementedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The operation needs the VM in another state, e.g. a disk resize on a running VM. */
|
||||
export class VmStateError extends Error {
|
||||
readonly dotId: string;
|
||||
constructor(dotId: string, message: string) {
|
||||
super(`dot ${dotId}: ${message}`);
|
||||
this.name = "VmStateError";
|
||||
this.dotId = dotId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalid input to the vm-manager (a bad dot id, a shrinking disk, a missing image). */
|
||||
export class VmManagerError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "VmManagerError";
|
||||
}
|
||||
}
|
||||
|
||||
/** QEMU (qemu-system-x86_64 or qemu-img) is not installed where the vm-manager looks (section 3.1). */
|
||||
export class QemuNotFoundError extends Error {
|
||||
readonly binary: string;
|
||||
readonly searched: readonly string[];
|
||||
constructor(binary: string, searched: readonly string[]) {
|
||||
super(
|
||||
`${binary} was not found (looked in: ${searched.length > 0 ? searched.join(", ") : "nothing"}). ` +
|
||||
`Run "${SETUP_COMMAND}" to install QEMU, or set INVISIBLE_DOTS_QEMU_DIR to the directory that holds it; ` +
|
||||
`"${DOCTOR_COMMAND}" checks the result.`,
|
||||
);
|
||||
this.name = "QemuNotFoundError";
|
||||
this.binary = binary;
|
||||
this.searched = searched;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QEMU could not use the hardware accelerator. There is no fallback to
|
||||
* software emulation (section 1.1): the start fails and says what fixes it.
|
||||
*/
|
||||
export class AcceleratorUnavailableError extends Error {
|
||||
readonly accelerator: string;
|
||||
readonly qemuOutput: string;
|
||||
constructor(accelerator: string, qemuOutput: string) {
|
||||
super(
|
||||
`QEMU could not use the ${accelerator} accelerator, and invisible_dots never falls back to software emulation. ` +
|
||||
`Run "${DOCTOR_COMMAND}" to see what is missing and "${SETUP_COMMAND}" to fix it. QEMU said: ${qemuOutput.trim() || "nothing"}`,
|
||||
);
|
||||
this.name = "AcceleratorUnavailableError";
|
||||
this.accelerator = accelerator;
|
||||
this.qemuOutput = qemuOutput;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The accelerator does not run the Dots' CPU model (`CPU_MODEL`, section
|
||||
* 3.4). Section 3.4 forbids replacing it with a guessed one, so this is an
|
||||
* error to report, not to work around.
|
||||
*/
|
||||
export class CpuModelError extends Error {
|
||||
readonly accelerator: string;
|
||||
readonly qemuOutput: string;
|
||||
constructor(accelerator: string, cpuModel: string, qemuOutput: string) {
|
||||
super(
|
||||
`QEMU rejected "-cpu ${cpuModel}" with the ${accelerator} accelerator. invisible_dots does not substitute another CPU model ` +
|
||||
`(architecture section 3.4); run "${DOCTOR_COMMAND}". QEMU said: ${qemuOutput.trim() || "nothing"}`,
|
||||
);
|
||||
this.name = "CpuModelError";
|
||||
this.accelerator = accelerator;
|
||||
this.qemuOutput = qemuOutput;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QEMU exited during start, never set up its port forward, or started but
|
||||
* never brought the guest up. `qemuOutput` is what QEMU wrote to its log and
|
||||
* `serialOutput` the end of the guest's serial console: together they are
|
||||
* what says why, since nothing else watches a VM's insides (section 3.4).
|
||||
*/
|
||||
export class VmStartError extends Error {
|
||||
readonly dotId: string;
|
||||
readonly qemuOutput: string;
|
||||
readonly serialOutput: string;
|
||||
constructor(dotId: string, message: string, qemuOutput = "", options?: { cause?: unknown; serialOutput?: string }) {
|
||||
const output = qemuOutput.trim();
|
||||
const serial = (options?.serialOutput ?? "").trim();
|
||||
super(
|
||||
`dot ${dotId}: ${message}${output ? `. QEMU said: ${output}` : ""}${serial ? `. The serial console ends with: ${serial}` : ""}`,
|
||||
options?.cause === undefined ? undefined : { cause: options.cause },
|
||||
);
|
||||
this.name = "VmStartError";
|
||||
this.dotId = dotId;
|
||||
this.qemuOutput = qemuOutput;
|
||||
this.serialOutput = options?.serialOutput ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
/** A guest route answered with an error status, or with something that is not the expected shape. */
|
||||
export class GuestRequestError extends Error {
|
||||
readonly status: number;
|
||||
/** The `error` code of an `{ error, message }` body, when the guest sent one. */
|
||||
readonly code: string | undefined;
|
||||
readonly route: string;
|
||||
|
||||
constructor(route: string, status: number, message: string, code?: string) {
|
||||
super(`${route}: ${status === 0 ? "" : `HTTP ${status}: `}${message}`);
|
||||
this.name = "GuestRequestError";
|
||||
this.route = route;
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
/**
|
||||
* The host's client for one Dot: every dot-agentd route (architecture 5.2) and
|
||||
* every agent route behind `/v1/agent` (5.3), over HTTP/1.1 to
|
||||
* 127.0.0.1:<guest port>, which QEMU forwards to dot-agentd's TCP port 1024
|
||||
* (5.1). Every request carries the Dot's bearer token: the port is not a
|
||||
* credential, any local process and any guest can reach it.
|
||||
*/
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { request, type IncomingMessage, type OutgoingHttpHeaders } from "node:http";
|
||||
import {
|
||||
abortableSleep,
|
||||
AGENTD_ROUTES,
|
||||
AGENT_ROUTES,
|
||||
FILE_TOO_LARGE,
|
||||
GUEST_PROOF_CONTEXT,
|
||||
GUEST_UNPROVEN,
|
||||
GuestHealthTimeoutError,
|
||||
isFatalGuestError,
|
||||
parseOutboundEvent,
|
||||
pollGuestHealth,
|
||||
PREPARE_SLEEP_TIMEOUT_MS,
|
||||
REFUSED_PROBLEM_MAX,
|
||||
SseParser,
|
||||
type AgentHealthAnswer,
|
||||
type AgentStateAnswer,
|
||||
type BrowserIdentity,
|
||||
type BrowserIdentityListAnswer,
|
||||
type CreateBrowserIdentityRequest,
|
||||
type DotRuntimeConfig,
|
||||
type ExecAnswer,
|
||||
type ExecRequest,
|
||||
type FileListAnswer,
|
||||
type HealthAnswer,
|
||||
type InboundEvent,
|
||||
type OutboundEvent,
|
||||
type PollGuestHealthOptions,
|
||||
type PostEventAnswer,
|
||||
type ProofAnswer,
|
||||
type RefusedEvent,
|
||||
type SystemAnswer,
|
||||
type SkillListAnswer,
|
||||
type ToolListAnswer,
|
||||
} from "@invisible-dots/shared";
|
||||
import { GuestRequestError } from "./errors.js";
|
||||
import { silentLogger, type Logger } from "./logger.js";
|
||||
import { GUEST_PORT_HOST } from "./ports.js";
|
||||
|
||||
export interface GuestClientOptions {
|
||||
/** Per-request timeout. Default 30 s; exec adds its own timeout on top. */
|
||||
timeoutMs?: number;
|
||||
logger?: Logger;
|
||||
/** Default 127.0.0.1, where QEMU binds the forward. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface EventStreamOptions {
|
||||
/** Resume after this outbound `seq`; the stream sends only newer events. Default 0. */
|
||||
after?: number;
|
||||
/**
|
||||
* Called, in order with the events, for a message the host refused, and awaited before the stream goes on. A refused
|
||||
* message with a `seq` is passed once (the stream resumes after it); one without cannot be told apart on a reconnect.
|
||||
*/
|
||||
onRefused?: (info: RefusedEvent) => void | Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
/** First reconnect delay; doubles up to `maxReconnectDelayMs`. Default 500 ms. */
|
||||
reconnectDelayMs?: number;
|
||||
maxReconnectDelayMs?: number;
|
||||
/** Called before each reconnect, e.g. to log it. */
|
||||
onReconnect?: (info: { after: number; attempt: number; error: Error }) => void;
|
||||
}
|
||||
|
||||
/** The proof a guest holding `token` gives for `nonce` (`GET /v1/proof`, section 5.1). */
|
||||
export function guestProof(token: string, nonce: string): string {
|
||||
return createHmac("sha256", token).update(`${GUEST_PROOF_CONTEXT}${nonce}`).digest("hex");
|
||||
}
|
||||
|
||||
interface Call {
|
||||
/** Sent without the token; only the proof route is. */
|
||||
unauthenticated?: boolean;
|
||||
method?: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
rawBody?: Uint8Array;
|
||||
contentType?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
/** The most the answer may hold; a larger one ends the call with `FILE_TOO_LARGE` and is not buffered. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
status: number;
|
||||
body: Buffer;
|
||||
/** The media type of the answer, without parameters, lower case; "" when it sent none. */
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
function filesQuery(path: string): string {
|
||||
return `?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
function parseError(route: string, answer: Pick<Answer, "status" | "body">): GuestRequestError {
|
||||
const text = answer.body.toString("utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown };
|
||||
if (typeof parsed.error === "string") {
|
||||
return new GuestRequestError(
|
||||
route,
|
||||
answer.status,
|
||||
typeof parsed.message === "string" ? `${parsed.error}: ${parsed.message}` : parsed.error,
|
||||
parsed.error,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: the raw text is the most useful message.
|
||||
}
|
||||
const hint = answer.status === 401 ? " (the Dot token was refused)" : "";
|
||||
return new GuestRequestError(route, answer.status, `${text.trim().slice(0, 500) || "no body"}${hint}`);
|
||||
}
|
||||
|
||||
export class GuestClient {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
private readonly token: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly logger: Logger;
|
||||
/** Settles once whatever listens on the port proved it holds the token; until then the token is never sent. */
|
||||
private proven: Promise<void> | undefined;
|
||||
|
||||
constructor(port: number, token: string, options: GuestClientOptions = {}) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`GuestClient needs a TCP port, got ${port}`);
|
||||
if (!token) throw new Error("GuestClient needs the Dot token");
|
||||
this.host = options.host ?? GUEST_PORT_HOST;
|
||||
this.port = port;
|
||||
this.token = token;
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.logger = options.logger ?? silentLogger;
|
||||
}
|
||||
|
||||
/** Where this client sends requests, for logs and errors. */
|
||||
get address(): string {
|
||||
return `${this.host}:${this.port}`;
|
||||
}
|
||||
|
||||
private open(method: string, path: string, headers: OutgoingHttpHeaders, signal: AbortSignal | undefined) {
|
||||
// No keep-alive agent: after a VM restart the same port may lead to a new guest, and a pooled socket would not.
|
||||
return request({ host: this.host, port: this.port, method, path, headers, agent: false, signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make whatever listens on the port prove it holds the Dot token before
|
||||
* the token is sent there (section 5.1). A port is not a credential: after
|
||||
* a host restart or a QEMU that died, another process can listen on a
|
||||
* port the host still has on record. A failed proof is GUEST_UNPROVEN,
|
||||
* which no caller retries; a guest that is not up yet is retried as usual.
|
||||
*/
|
||||
async prove(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<void> {
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const path = `${AGENTD_ROUTES.proof}?nonce=${nonce}`;
|
||||
const answer = await this.json<ProofAnswer>({ path, unauthenticated: true, ...options });
|
||||
const expected = Buffer.from(guestProof(this.token, nonce), "utf8");
|
||||
const given = Buffer.from(typeof answer?.proof === "string" ? answer.proof : "", "utf8");
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
|
||||
throw new GuestRequestError(
|
||||
`GET ${AGENTD_ROUTES.proof}`,
|
||||
0,
|
||||
`${this.address} is not this Dot's guest: it could not prove it holds the Dot token, so the token was not sent to it`,
|
||||
GUEST_UNPROVEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureProven(signal: AbortSignal | undefined): Promise<void> {
|
||||
this.proven ??= this.prove({ signal }).catch((error: unknown) => {
|
||||
// A guest still booting fails the same way; the next request asks again.
|
||||
this.proven = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.proven;
|
||||
}
|
||||
|
||||
private async send(call: Call): Promise<Answer> {
|
||||
if (!call.unauthenticated) await this.ensureProven(call.signal);
|
||||
return this.sendRaw(call);
|
||||
}
|
||||
|
||||
private sendRaw(call: Call): Promise<Answer> {
|
||||
const method = call.method ?? "GET";
|
||||
const route = `${method} ${call.path}`;
|
||||
const headers: OutgoingHttpHeaders = call.unauthenticated ? {} : { authorization: `Bearer ${this.token}` };
|
||||
let body: Buffer | undefined;
|
||||
if (call.rawBody !== undefined) {
|
||||
body = Buffer.from(call.rawBody);
|
||||
headers["content-type"] = call.contentType ?? "application/octet-stream";
|
||||
} else if (call.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(call.body), "utf8");
|
||||
headers["content-type"] = "application/json";
|
||||
}
|
||||
if (body !== undefined) headers["content-length"] = body.length;
|
||||
const timeoutMs = call.timeoutMs ?? this.timeoutMs;
|
||||
|
||||
return new Promise<Answer>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (error: Error | undefined, answer?: Answer) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
req.destroy();
|
||||
reject(error);
|
||||
} else if (answer!.status < 200 || answer!.status >= 300) {
|
||||
reject(parseError(route, answer!));
|
||||
} else {
|
||||
resolve(answer!);
|
||||
}
|
||||
};
|
||||
const req = this.open(method, call.path, headers, call.signal);
|
||||
const timer = setTimeout(() => finish(new GuestRequestError(route, 0, `no answer from ${this.address} within ${timeoutMs} ms`)), timeoutMs);
|
||||
req.on("response", (res: IncomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
const tooLarge = () => new GuestRequestError(route, 413, `the answer is larger than ${call.maxBytes} bytes`, FILE_TOO_LARGE);
|
||||
// An error answer is small and its text is the reason; only a successful body is held to the limit.
|
||||
const limited = call.maxBytes !== undefined && (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300;
|
||||
if (limited && Number(res.headers["content-length"]) > call.maxBytes!) return finish(tooLarge());
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (limited && received > call.maxBytes!) return finish(tooLarge());
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("error", (error) => finish(new GuestRequestError(route, 0, `answer interrupted: ${error.message}`)));
|
||||
res.on("end", () => finish(undefined, { status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: String(res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase() }));
|
||||
});
|
||||
req.on("error", (error: NodeJS.ErrnoException) => {
|
||||
const hint = error.code === "ECONNREFUSED" ? " (nothing listens on the guest port: is the VM running?)" : "";
|
||||
finish(new GuestRequestError(route, 0, `${this.address}: ${error.message}${hint}`, error.code));
|
||||
});
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
private async json<T>(call: Call): Promise<T> {
|
||||
const answer = await this.send(call);
|
||||
const text = answer.body.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new GuestRequestError(`${call.method ?? "GET"} ${call.path}`, answer.status, `answer is not JSON: ${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async noContent(call: Call): Promise<void> {
|
||||
await this.send(call);
|
||||
}
|
||||
|
||||
private agentPath(path: string): string {
|
||||
return `${AGENTD_ROUTES.agent}${path}`;
|
||||
}
|
||||
|
||||
// dot-agentd (5.2)
|
||||
|
||||
health(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<HealthAnswer> {
|
||||
return this.json({ path: AGENTD_ROUTES.health, ...options });
|
||||
}
|
||||
|
||||
system(): Promise<SystemAnswer> {
|
||||
return this.json({ path: AGENTD_ROUTES.system });
|
||||
}
|
||||
|
||||
exec(body: ExecRequest): Promise<ExecAnswer> {
|
||||
// The command may legitimately run for timeout_ms; give the transport that long plus a margin.
|
||||
const timeoutMs = body.timeout_ms === undefined ? undefined : body.timeout_ms + this.timeoutMs;
|
||||
return this.json({ method: "POST", path: AGENTD_ROUTES.exec, body, timeoutMs });
|
||||
}
|
||||
|
||||
/** The file's bytes; with `maxBytes`, a larger file is refused (413 `FILE_TOO_LARGE`) without being read in full. */
|
||||
async readFile(path: string, options: { maxBytes?: number } = {}): Promise<Buffer> {
|
||||
return (await this.send({ path: `${AGENTD_ROUTES.files}${filesQuery(path)}`, maxBytes: options.maxBytes })).body;
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
const rawBody = typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
return this.noContent({ method: "PUT", path: `${AGENTD_ROUTES.files}${filesQuery(path)}`, rawBody });
|
||||
}
|
||||
|
||||
listFiles(path: string): Promise<FileListAnswer> {
|
||||
return this.json({ path: `${AGENTD_ROUTES.filesList}${filesQuery(path)}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the guest to power itself off (`/usr/bin/sudo -n /usr/bin/systemctl poweroff`). The 202
|
||||
* comes back before the shutdown begins; QEMU exits when the guest is off,
|
||||
* which is what the vm-manager then waits for.
|
||||
*/
|
||||
powerOff(timeoutMs?: number): Promise<void> {
|
||||
return this.noContent({ method: "POST", path: AGENTD_ROUTES.poweroff, timeoutMs });
|
||||
}
|
||||
|
||||
/** PNG bytes of display :0. */
|
||||
async screenshot(): Promise<Buffer> {
|
||||
return (await this.send({ path: AGENTD_ROUTES.screenshot })).body;
|
||||
}
|
||||
|
||||
// invisible-dots-agent (5.3), through /v1/agent
|
||||
|
||||
agentHealth(): Promise<AgentHealthAnswer> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.health) });
|
||||
}
|
||||
|
||||
pushSecrets(openrouterApiKey: string): Promise<void> {
|
||||
return this.noContent({
|
||||
method: "POST",
|
||||
path: this.agentPath(AGENT_ROUTES.secrets),
|
||||
body: { openrouter_api_key: openrouterApiKey },
|
||||
});
|
||||
}
|
||||
|
||||
putConfig(config: DotRuntimeConfig): Promise<void> {
|
||||
return this.noContent({ method: "PUT", path: this.agentPath(AGENT_ROUTES.config), body: config });
|
||||
}
|
||||
|
||||
postEvent(event: InboundEvent): Promise<PostEventAnswer> {
|
||||
return this.json({ method: "POST", path: this.agentPath(AGENT_ROUTES.events), body: event });
|
||||
}
|
||||
|
||||
state(): Promise<AgentStateAnswer> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.state) });
|
||||
}
|
||||
|
||||
listBrowserIdentities(): Promise<BrowserIdentityListAnswer> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.browserIdentities) });
|
||||
}
|
||||
|
||||
createBrowserIdentity(body: CreateBrowserIdentityRequest): Promise<BrowserIdentity> {
|
||||
return this.json({ method: "POST", path: this.agentPath(AGENT_ROUTES.browserIdentities), body });
|
||||
}
|
||||
|
||||
getBrowserIdentity(id: string): Promise<BrowserIdentity> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.browserIdentity(id)) });
|
||||
}
|
||||
|
||||
deleteBrowserIdentity(id: string): Promise<void> {
|
||||
return this.noContent({ method: "DELETE", path: this.agentPath(AGENT_ROUTES.browserIdentity(id)) });
|
||||
}
|
||||
|
||||
/**
|
||||
* JPEG bytes of the open identity's window. The route's contract is a JPEG (architecture section 5.3), and the engine
|
||||
* is its one owner: it answers `frame_failed` for any other media type, so the host labels the bytes `image/jpeg`
|
||||
* all the way to the browser without looking again.
|
||||
*/
|
||||
async getBrowserIdentityFrame(id: string): Promise<Buffer> {
|
||||
return (await this.send({ path: this.agentPath(AGENT_ROUTES.browserIdentityFrame(id)) })).body;
|
||||
}
|
||||
|
||||
closeBrowserIdentity(id: string): Promise<void> {
|
||||
return this.noContent({ method: "POST", path: this.agentPath(AGENT_ROUTES.browserIdentityClose(id)) });
|
||||
}
|
||||
|
||||
/** The Dot's tools and whether the model is offered each now. */
|
||||
listTools(): Promise<ToolListAnswer> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.tools) });
|
||||
}
|
||||
|
||||
/** The Dot's skills, the built-in ones and its own, each with its whole file. */
|
||||
listSkills(): Promise<SkillListAnswer> {
|
||||
return this.json({ path: this.agentPath(AGENT_ROUTES.skills) });
|
||||
}
|
||||
|
||||
/** Flushes state and closes browser sessions; can take a while with several browsers open. */
|
||||
prepareSleep(timeoutMs = PREPARE_SLEEP_TIMEOUT_MS): Promise<void> {
|
||||
return this.noContent({ method: "POST", path: this.agentPath(AGENT_ROUTES.prepareSleep), timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbound event stream, as an async iterator that survives disconnects:
|
||||
* on a network error or end of stream it reconnects with `?after=<last
|
||||
* seq seen>`, so an event is neither lost nor delivered twice. It ends when
|
||||
* `signal` aborts or the consumer stops iterating. Not retried: a 401 or
|
||||
* 404 (a wrong token or a wrong guest does not get better by waiting), a
|
||||
* failed proof, and a refused connection: QEMU listens on the guest port
|
||||
* for as long as it runs, so nothing listening means the VM is gone and
|
||||
* the caller has to look at the VM, not at this port.
|
||||
*/
|
||||
async *events(options: EventStreamOptions = {}): AsyncGenerator<OutboundEvent, void, undefined> {
|
||||
let after = options.after ?? 0;
|
||||
const firstDelay = options.reconnectDelayMs ?? 500;
|
||||
const maxDelay = options.maxReconnectDelayMs ?? 15_000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal;
|
||||
while (!signal?.aborted) {
|
||||
const stream = this.openStream(after, signal);
|
||||
let lastError: Error | undefined;
|
||||
try {
|
||||
for await (const item of stream) {
|
||||
attempt = 0;
|
||||
if ("refused" in item) {
|
||||
const { seq } = item.refused;
|
||||
if (seq !== null && seq <= after) continue;
|
||||
await options.onRefused?.(item.refused);
|
||||
if (seq !== null) after = seq;
|
||||
continue;
|
||||
}
|
||||
const event = item.event;
|
||||
if (event.seq <= after) continue;
|
||||
after = event.seq;
|
||||
yield event;
|
||||
}
|
||||
lastError = new Error("event stream ended");
|
||||
} catch (error) {
|
||||
if (error instanceof GuestRequestError && (error.status === 401 || error.status === 404)) throw error;
|
||||
if (isFatalGuestError(error) || (error as { code?: unknown }).code === "ECONNREFUSED") throw error;
|
||||
lastError = error as Error;
|
||||
} finally {
|
||||
await stream.return(undefined);
|
||||
}
|
||||
if (signal?.aborted) return;
|
||||
attempt++;
|
||||
options.onReconnect?.({ after, attempt, error: lastError });
|
||||
this.logger.warn("guest event stream reconnecting", { guest: this.address, after, attempt, reason: lastError.message });
|
||||
await abortableSleep(Math.min(firstDelay * 2 ** (attempt - 1), maxDelay), signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** One connection to `/events/stream`, parsed into events and the messages that are none; ends when the connection ends. */
|
||||
private async *openStream(
|
||||
after: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): AsyncGenerator<{ event: OutboundEvent } | { refused: RefusedEvent }, void, undefined> {
|
||||
const path = `${this.agentPath(AGENT_ROUTES.eventsStream)}?after=${after}`;
|
||||
const route = `GET ${path}`;
|
||||
await this.ensureProven(signal);
|
||||
const req = this.open("GET", path, { authorization: `Bearer ${this.token}`, accept: "text/event-stream" }, signal);
|
||||
try {
|
||||
const response = await new Promise<IncomingMessage>((resolve, reject) => {
|
||||
req.once("response", resolve);
|
||||
req.once("error", (error: NodeJS.ErrnoException) =>
|
||||
reject(new GuestRequestError(route, 0, `${this.address}: ${error.message}`, error.code)),
|
||||
);
|
||||
req.end();
|
||||
});
|
||||
if ((response.statusCode ?? 0) !== 200) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of response) chunks.push(chunk as Buffer);
|
||||
throw parseError(route, { status: response.statusCode ?? 0, body: Buffer.concat(chunks) });
|
||||
}
|
||||
response.setEncoding("utf8");
|
||||
const parser = new SseParser();
|
||||
for await (const chunk of response) {
|
||||
for (const message of parser.feed(chunk as string)) {
|
||||
if (message.data === "") continue;
|
||||
let event: OutboundEvent;
|
||||
try {
|
||||
event = parseOutboundEvent(JSON.parse(message.data));
|
||||
} catch (error) {
|
||||
// One malformed message must not stall the stream forever; it is logged, handed to the consumer to be
|
||||
// recorded, and skipped.
|
||||
this.logger.error("guest sent an invalid event, skipped", { id: message.id, error: (error as Error).message });
|
||||
yield { refused: refusedOf(message.data, error as Error) };
|
||||
continue;
|
||||
}
|
||||
yield { event };
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a refused message is called: the `seq` and `type` it carried, when it was JSON that carried them, and why it was refused. */
|
||||
function refusedOf(data: string, error: Error): RefusedEvent {
|
||||
let seq: number | null = null;
|
||||
let type: string | null = null;
|
||||
try {
|
||||
const raw = JSON.parse(data) as { seq?: unknown; type?: unknown };
|
||||
if (typeof raw.seq === "number" && Number.isSafeInteger(raw.seq) && raw.seq > 0) seq = raw.seq;
|
||||
if (typeof raw.type === "string") type = raw.type.slice(0, 64);
|
||||
} catch {
|
||||
// Not JSON: nothing of it can be named.
|
||||
}
|
||||
const problem = error.message.length > REFUSED_PROBLEM_MAX ? `${error.message.slice(0, REFUSED_PROBLEM_MAX - 3)}...` : error.message;
|
||||
return { seq, type, problem };
|
||||
}
|
||||
|
||||
export type WaitForGuestHealthOptions = PollGuestHealthOptions;
|
||||
|
||||
export { GuestHealthTimeoutError };
|
||||
|
||||
/**
|
||||
* Poll `GET /v1/health` until the guest is up: the shared rule and loop of
|
||||
* `pollGuestHealth`, over this client. The first request of a client asks
|
||||
* the listener for its proof, so a port taken over by another process fails
|
||||
* at once, without the token ever being sent to it.
|
||||
*/
|
||||
export function waitForGuestHealth(client: GuestClient, options: WaitForGuestHealthOptions = {}): Promise<HealthAnswer> {
|
||||
return pollGuestHealth(client, options);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* The doctor's dependencies on the real machine (architecture section 11.1):
|
||||
* QEMU discovery, the accelerator, the free disk space and the process
|
||||
* runner. What needs more than the vm-manager knows, the images' manifests
|
||||
* and the OpenRouter key, is handed in by the caller: the API process asks
|
||||
* its own Scheduler, `invisible-dots doctor` asks the running server.
|
||||
*/
|
||||
import { stat, statfs } from "node:fs/promises";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { ENV, type DoctorCheck } from "@invisible-dots/shared";
|
||||
import { checkAcceleratorAccess, hostAccessDeps } from "./accelerator-access.js";
|
||||
import type { DoctorDeps, FoundQemu } from "./doctor.js";
|
||||
import { QemuNotFoundError } from "./errors.js";
|
||||
import { accelerator, findQemu, isExecutableFile, QEMU_SYSTEM_NAMES, qemuSearchDirs } from "./host.js";
|
||||
import { runProcess } from "./runner.js";
|
||||
|
||||
/**
|
||||
* The vm-manager's discovery, with each program reported separately for
|
||||
* doctor. findQemu stops at the first program it cannot find; the probe
|
||||
* passed as `exists` remembers qemu-system-x86_64 when it was found and only
|
||||
* qemu-img is missing, so doctor can still check its version.
|
||||
*/
|
||||
export async function locateQemu(env: Record<string, string | undefined>): Promise<FoundQemu> {
|
||||
const found = new Set<string>();
|
||||
const exists = async (path: string) => {
|
||||
const ok = await isExecutableFile(path);
|
||||
if (ok) found.add(path);
|
||||
return ok;
|
||||
};
|
||||
const configured = Boolean(env[ENV.QEMU_DIR]?.trim());
|
||||
try {
|
||||
const programs = await findQemu(env, exists);
|
||||
return { ...programs, searched: qemuSearchDirs(env), configured };
|
||||
} catch (error) {
|
||||
if (!(error instanceof QemuNotFoundError)) throw error;
|
||||
const system = [...found].find((path) => (QEMU_SYSTEM_NAMES as readonly string[]).includes(basename(path)));
|
||||
return { ...(system ? { system } : {}), searched: [...error.searched], configured };
|
||||
}
|
||||
}
|
||||
|
||||
/** Free space where INVISIBLE_DOTS_HOME is or will be: its nearest existing directory, since doctor creates nothing. */
|
||||
export async function freeSpace(home: string): Promise<{ path: string; bytes: number }> {
|
||||
let path = home;
|
||||
for (;;) {
|
||||
const info = await stat(path).catch(() => undefined);
|
||||
if (info?.isDirectory()) break;
|
||||
const parent = dirname(path);
|
||||
if (parent === path) break;
|
||||
path = parent;
|
||||
}
|
||||
const fs = await statfs(path);
|
||||
return { path, bytes: fs.bavail * fs.bsize };
|
||||
}
|
||||
|
||||
export interface HostDoctorOptions {
|
||||
env: Record<string, string | undefined>;
|
||||
/** INVISIBLE_DOTS_HOME, as `hostPaths` resolved it. */
|
||||
home: string;
|
||||
/** The golden image and the runtime ISO against their manifests, one row each. */
|
||||
images(): Promise<DoctorCheck[]>;
|
||||
/** The OpenRouter key row (`openRouterCheck` for a known answer). */
|
||||
openRouterKey(): Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
export function hostDoctorDeps(options: HostDoctorOptions): DoctorDeps {
|
||||
return {
|
||||
nodeVersion: process.versions.node,
|
||||
// Asked when a check needs it: on an unsupported host it throws, and that check reports it.
|
||||
accelerator: () => accelerator(),
|
||||
findQemu: () => locateQemu(options.env),
|
||||
run: runProcess,
|
||||
acceleratorAccess: () => checkAcceleratorAccess(hostAccessDeps(options.env)),
|
||||
home: options.home,
|
||||
freeSpace: () => freeSpace(options.home),
|
||||
images: options.images,
|
||||
openRouterKey: options.openRouterKey,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 938 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user