-
Notifications
You must be signed in to change notification settings - Fork 162
fix(cli): stop uninstall and kill --port destroying what they do not own - #3361
fix(cli): stop uninstall and kill --port destroying what they do not own #3361kovtcharov wants to merge 4 commits into
Conversation
Two commands could destroy things outside GAIA with no attacker involved —
one misconfigured env var, or one mistyped port.
`GAIA_HOME=$HOME` turned `gaia uninstall --purge` into a plan targeting
`$HOME/venv` and `$HOME/documents`, and `documents` resolves case-insensitively
onto the real `Documents` folder on Windows and APFS. The containment guard in
`_remove_path` could not catch it: `_safe_roots()` returns the GAIA home itself,
so the check passed by construction. `build_plan` now refuses a home that is,
or contains, the user's home directory or a filesystem root, and `--purge`
additionally requires an existing home to look like GAIA's. `venv/` is
deliberately not proof of ownership, so a `GAIA_HOME` aimed at a project
checkout keeps its virtualenv.
A relocated `~/.gaia/documents` also aborted the purge half-way: the
containment check resolved the link *target*, raising an uncaught RuntimeError
after `venv` and `chat` were already gone. Links are now deleted as links.
Junctions need their own detection — `Path.is_symlink()` returns False for one,
and a junction is the only way to relocate a directory on Windows without
Developer Mode.
`kill_process_by_port` substring-matched `f":{port}"` against whole `netstat`
lines, so `--port 80` also matched `:8009` foreign addresses and TIME_WAIT
rows; on a dev box it selected 2 rows for `:80` and 164 for `:443`, then killed
the first. The Unix path was worse: `lsof -ti:PORT` returns both ends of every
connection and every pid got `kill -9`. Now the columns are parsed, only
LISTENING sockets whose local port matches exactly are considered, lsof is
restricted with `-sTCP:LISTEN`, and the owning process must be GAIA's or
Lemonade's before it is terminated — the identity check
`LemonadeEmbedded._daemon_alive` already makes. Subprocess output decodes with
`errors="replace"`, so OEM-codepage Windows no longer reports a UTF-8 decode
error in place of a port result.
Closes amd#3355
Request changes
This PR fixes two genuinely dangerous behaviours — gaia uninstall --purge planning deletions into a misconfigured GAIA_HOME, and gaia kill --port killing anything whose netstat line merely contained the number. Both fixes are well-reasoned and the test suite is unusually good. One defect blocks it.
The new symlink/junction detection is inverted on Python 3.10 and 3.11. It uses a helper that only exists from Python 3.12 onward, and the fallback path for older versions ends up reporting that every path is a link. On Linux and macOS under 3.10/3.11 that turns gaia uninstall --purge into a command that deletes loose files but leaves venv, chat, and documents behind with error lines and a failure exit code. The project supports 3.10+, but CI only runs 3.12 and the author's manual run was on Windows, so nothing here would have caught it. Fix: make the fallback answer "not a link" when the platform has no reparse-tag concept, and add one test that exercises the pre-3.12 path.
Second, smaller: the "does this directory actually belong to GAIA?" check only runs for --purge. gaia uninstall --venv with GAIA_HOME pointed at a project checkout still deletes that project's venv/ — the exact outcome the PR says it prevents. Either apply the check to both tiers or say in the docs that it's purge-only.
Real-world evidence
No evidence-bundle.md was produced for this run, so nothing was exercised on my side; the verdict rests on static review plus the CLI transcripts the author included in the PR description. Those transcripts are relevant and matched to the surface — GAIA_HOME=$HOME gaia uninstall --purge --dry-run refusing with exit 64, the unchanged default plan, and gaia kill --port 80 against a machine with live :8009 connections. They support the two fixes as far as they go, but they were all captured on Windows under one Python version, which is precisely the axis the blocking defect above lives on. No UI or MCP surface is touched, and the author marked both N/A with a reason.
🔍 Technical details
🔴 Critical
_is_link() returns True for every path on Python < 3.12 (POSIX) — src/gaia/installer/uninstall_command.py:552
os.path.isjunction is 3.12+, so on 3.10/3.11 the fallback runs. On POSIX st_reparse_tag is absent → None, and stat.IO_REPARSE_TAG_MOUNT_POINT is also absent on non-Windows builds → None. The comparison becomes None == None:
$ python3.12 -c "import stat; print(hasattr(stat,'IO_REPARSE_TAG_MOUNT_POINT'))"
False # Linux — the constant is Windows-only in _stat
(Verified in this checkout: reparse_tag is None, the marker is None, equal -> True.)
Failure path on Linux/macOS + 3.10/3.11, gaia uninstall --purge:
_is_link(~/.gaia/venv)→ True →_remove_pathtakes the link branch (uninstall_command.py:652)_unlink_link→path.unlink()raisesIsADirectoryError→os.rmdir()raisesENOTEMPTY- caught at
uninstall_command.py:664→[error] failed to remove ...,all_ok = False→EXIT_FS_ERROR venv/,chat/,documents/all survive; the uninstall is a no-op that reports failure
setup.py:348 declares python_requires=">=3.10" with 3.10/3.11 classifiers; every unit-test workflow pins python-version: '3.12', so this is invisible to CI.
def _is_link(path: Path) -> bool:
"""Whether ``path`` is a symlink or a Windows junction.
``Path.is_symlink()`` is False for a junction, and a junction is the only
way to relocate a directory on Windows without Developer Mode.
"""
if path.is_symlink():
return True
if not sys.platform.startswith("win"):
return False
isjunction = getattr(os.path, "isjunction", None) # 3.12+
if isjunction is not None:
return bool(isjunction(path))
try:
reparse_tag = getattr(path.lstat(), "st_reparse_tag", None)
except OSError:
return False
# The constant is Windows-only, so pin the value rather than getattr-ing it.
return reparse_tag is not None and reparse_tag == 0xA0000003
Worth a test that pins the pre-3.12 branch, e.g. monkeypatch.delattr(os.path, "isjunction", raising=False) around an ordinary directory and asserting _is_link(...) is False.
🟡 Important
Identity check skips Tier 2 — uninstall_command.py:353
_assert_purgeable_home(home, require_marker=purge) means gaia uninstall --venv with GAIA_HOME=/path/to/some/project deletes /path/to/some/project/venv with no marker check. The structural guard still fires (home dir / root), but GAIA_HOME_MARKERS deliberately excludes venv precisely so a pointed-at checkout keeps its virtualenv — and Tier 2 is the tier that actually deletes it. Either pass require_marker=True for both tiers (a not-yet-created home is already exempted by the resolved.is_dir() guard, so the no-op case stays quiet), or state the purge-only scope in docs/reference/cli.mdx so the note doesn't overpromise.
🟢 Minor
-
chat,logs, andconfig.jsonare weak ownership markers (uninstall_command.py:459). AGAIA_HOMEaimed at a real data folder that happens to contain any of them passes_has_gaia_markerand becomes purgeable.config.json/gaia.log/memory.dbplus theelectron-*files carry the signal; the three generic ones mostly widen the hole. -
The
--portdoc note overstates the protection (docs/reference/cli.mdx:437). It's accurate that a database orsvchostgets refused, but the allowlist admits anypython/node/electronprocess — so a user's own Node dev server on a mistyped port is stillkill -9'd. One clause noting that the check is interpreter-level would keep the note honest. -
Unreadable PID column reads as "nothing listening" (
cli.py:128).netstat -tulpnprints-for processes the caller doesn't own, so theint()raises, the row is dropped, and the user is toldNo process is listening on port Nwhen something is. A distinct message when rows matched but no PID was readable would be more actionable.
Strengths
- The security argument is made in the right place:
_safe_rootsnow carries a note explaining that it is vacuous until_assert_purgeable_homehas run, so the next reader can't mistake the containment guard for sufficient on its own. TestListenerLookupCallShapeasserts the shape of the outgoinglsofargv (-sTCP:LISTENpresent,-ti:PORTabsent) rather than just that the tool was invoked — exactly the boundary-validity testing CLAUDE.md asks for, and it's what makes thelsofexit-1-vs-failure distinction reviewable.- Fixtures are real trimmed
netstatoutput covering the actual regression shapes::80vs:8009,TIME_WAIT, UDP rows, IPv6 dedupe, and a localizedABHÖRENstate column. - Error messages name what failed, what to do, and the resolved path —
Cannot inspect port N: netstat is not on PATH...is a real improvement over the oldNo process found running on port Ncatch-all.
The first commit fixed one of three copies of the port matcher, which left
`docs/reference/cli.mdx` claiming targeting that two other call sites did not
have.
`stop_server` (reached by `python -m gaia.api.app stop`) carried the same
defect: `f":{port}" in line` selected a listener on `:8080` for `--port 80`,
matched the foreign-address column, and taskkill'd whatever it found with no
check on the owner. On a machine where port 4001 is in use, `--port 400` picked
its pid out of 10 substring hits.
Rather than patch a second copy, the targeting rules move to `gaia.ports` and
both `kill_process_by_port` and `stop_server` call it. `stop_server` keeps its
own signalling — SIGTERM on POSIX so the server can wind its workers down —
because only the selection was wrong.
`src/gaia/util.py` held the third and worst copy: it also killed on
"ESTABLISHED" in line, so it terminated processes merely *connected* to the
port, and it had no allowlist. Nothing imports it (`lemonade_client` defines
its own `kill_process_on_port` that shadows the name), so it is deleted rather
than left as a trap for the next person who greps for "kill port".
`lemonade_client.kill_process_on_port` is the fourth site and is left alone: it
compares `conn.laddr.port == port` exactly via psutil, so it never had the
substring bug.
Refs amd#3355
🔴 The _is_link() fix is still broken on Python 3.10/3.11 + Linux/macOS. Line 567 reads return reparse_tag == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None). On any POSIX host, stat.IO_REPARSE_TAG_MOUNT_POINT doesn't exist (confirmed: Python 3.12 Linux, hasattr → False), so getattr yields None; and st_reparse_tag doesn't exist on POSIX, so reparse_tag is also None. The comparison is None == None → True — every regular directory is still reported as a link. The isjunction guard only saves you on 3.12+; the fallback fires on 3.10/3.11 exactly as before. The purge-then-delete-nothing failure mode the first review described is unchanged.
The fix is a one-liner: guard the fallback to Windows-only before touching reparse tags, or use reparse_tag is not None and reparse_tag == 0xA0000003 (hardcoded — the constant is Windows-only and pinning avoids the None equality trap). No test for the pre-3.12 branch was added either, which the prior review explicitly requested.
🔍 Technical details
src/gaia/installer/uninstall_command.py:567
# current — still broken on pre-3.12 POSIX return reparse_tag == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None) # both sides are None on Linux → True for every plain directory
if not sys.platform.startswith("win"):
return False
return reparse_tag is not None and reparse_tag == 0xA0000003
Or as a one-liner in the return:
return (
reparse_tag is not None
and reparse_tag == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", reparse_tag + 1)
)
(The reparse_tag + 1 default makes the equality false when the constant is absent; the is not None guard makes the whole expression false when st_reparse_tag is absent.)
Simplest and clearest: sys.platform guard + hardcoded 0xA0000003.
A test for the pre-3.12 path would be:
def test_is_link_returns_false_for_plain_dir_when_isjunction_absent(self, tmp_path, monkeypatch): monkeypatch.delattr(os.path, "isjunction", raising=False) assert not uc._is_link(tmp_path)
`_is_link`'s pre-3.12 fallback compared two absent attributes: getattr(path.lstat(), "st_reparse_tag", None) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None) On POSIX neither exists, so it evaluated `None == None` and reported every file and directory as a link. `_unlink_link` then tried `unlink()` on a populated `~/.gaia/venv`, fell through to `rmdir`, and failed with "[Errno 39] Directory not empty" — every removal in the suite errored and `gaia uninstall` returned exit 2 instead of 0. Linux CI caught it on py3.10 and py3.11; py3.12+ and Windows take the `os.path.isjunction` branch and never reach the comparison, which is why it passed locally. Junctions are a Windows concept, so the fallback now returns False off Windows before touching lstat, and refuses to compare against a missing tag constant. The regression tests simulate the pre-3.12 POSIX environment on any interpreter — no `os.path.isjunction`, no `IO_REPARSE_TAG_MOUNT_POINT`, and an lstat result without `st_reparse_tag` — so this branch is covered on the Windows and 3.12+ lanes too rather than only where it happens to run. All three fail against the previous commit. Refs amd#3355
The identity check was gated on `require_marker=purge`, so it only ran for Tier 3. `gaia uninstall --venv` with GAIA_HOME pointed at a project checkout still deleted that project's `venv/` — the exact outcome the guard exists to prevent, and `venv` is deliberately not a marker precisely because it is the one deletion target that routinely belongs to someone else. Both tiers now run the check, and the error names the flag that was actually used. The structural guard (home directory, drive root) already covered both. Nothing legitimate is refused by extending it. Both installers shadow the environment variable with a hardcoded path -- `installer/scripts/install.sh` sets `GAIA_HOME="$HOME/.gaia"` and `install.ps1` sets `"$env:USERPROFILE\.gaia"` -- so a venv is only ever created under `~/.gaia`, which passes on its name alone. A custom GAIA_HOME that GAIA has actually used carries a marker; one that does not exist yet skips the check as a no-op. Refs amd#3355
kovtcharov
commented
Sep 4, 2026
Both items are now closed on the branch — 56f41d87.
The pre-3.12 link detection is fixed. You were right about the mechanism and about how it would have escaped notice. The platform guard now runs before any reparse-tag read, and a missing tag constant is treated as "not a link" rather than compared against. Linux CI caught it on py3.10 and py3.11 exactly as you predicted; both lanes are green now.
The pre-3.12 regression test exists, and it deliberately runs on every lane rather than only where that branch happens to execute.
The ownership check now covers --venv too, which was your second point. I chose applying it over documenting it: a guard that protects the destructive tier but not the adjacent one isn't really a guard, and venv is precisely the target that routinely belongs to someone else.
Thanks — the first point was a real defect that my own testing could not have surfaced.
🔍 Technical details
_is_link() — uninstall_command.py:561
if path.is_symlink(): return True isjunction = getattr(os.path, "isjunction", None) # 3.12+ if isjunction is not None: return bool(isjunction(path)) if not sys.platform.startswith("win"): return False # ← platform guard, before any lstat mount_point_tag = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None) if mount_point_tag is None: return False # ← never compare against a missing constant try: return getattr(path.lstat(), "st_reparse_tag", None) == mount_point_tag except OSError: return False
Two independent closes on the None == None trap. I kept getattr(stat, ...) over a hardcoded 0xA0000003 because the guarded form can't produce a false positive and the named constant documents itself; either is correct.
The regression test — test_uninstall_command.py:1280
TestLinkDetectionFallback (4 tests). The posix_pre_312 fixture at :1289 simulates pre-3.12 POSIX on any interpreter: it deletes os.path.isjunction, deletes stat.IO_REPARSE_TAG_MOUNT_POINT, sets sys.platform to linux, and — the part that matters — wraps Path.lstat so the result raises AttributeError for st_reparse_tag.
That last piece was necessary. My first attempt only deleted os.path.isjunction, and it passed against the broken code: Windows lstat() still returns a real st_reparse_tag of 0, so the comparison was 0 == None, not None == None. I verified the finished tests by reverting the fix and re-running — 3 of the 4 fail against the previous commit, all 4 pass now. :1330 is the end-to-end one: a populated ~/.gaia/venv must actually be removed and the run must exit 0.
Ownership check on both tiers — uninstall_command.py:278, :362
_assert_purgeable_home no longer takes require_marker; build_plan calls it for both tiers and passes tier= so the error names the flag actually used. Nothing legitimate is refused by extending it: both installers shadow the environment variable with a hardcoded path (installer/scripts/install.sh:11 sets GAIA_HOME="$HOME/.gaia", install.ps1:7 sets "$env:USERPROFILE\.gaia"), so a venv is only ever created under ~/.gaia, which passes on its name alone. A custom GAIA_HOME that GAIA has actually used carries a marker; one that doesn't exist yet skips the check as a no-op.
Coverage: test_a_venv_alone_is_not_proof_that_gaia_owns_the_directory (:1005) is parametrized over both tiers and asserts the right flag appears in the message; :1024 is the end-to-end --venv refusal; two more confirm --venv still works on a marked custom home and on the default ~/.gaia.
One correction
The review states "CI only runs 3.12" and "every unit-test workflow pins python-version: '3.12'". test_unit.yml in fact runs a py3.10 / py3.11 / py3.12 matrix plus a macOS smoke lane — which is why this was caught before merge rather than after. The conclusion was right; the reason it was invisible was that my local box is Windows/py3.13, where the buggy branch is unreachable.
kovtcharov
commented
Sep 4, 2026
This 🔴 was written against 70678f66 and no longer applies to the branch — _is_link() was fixed in 31d58e48, before this comment landed. Flagging explicitly so nobody stops at the red mark and assumes it's outstanding.
The diagnosis was correct, and it's fixed the way you suggested: the platform guard now runs before any reparse-tag read.
The pre-3.12 test you asked for exists too — TestLinkDetectionFallback, 4 tests.
🔍 Technical details
Current uninstall_command.py:561 (the cited line 567 is now if path.is_symlink():):
isjunction = getattr(os.path, "isjunction", None) # 3.12+ if isjunction is not None: return bool(isjunction(path)) if not sys.platform.startswith("win"): # :572 — your suggested guard return False mount_point_tag = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None) if mount_point_tag is None: # :576 — second, independent close return False try: return getattr(path.lstat(), "st_reparse_tag", None) == mount_point_tag except OSError: return False
I kept the named constant rather than hardcoding 0xA0000003: with both guards in place the equality can't be reached with either side None, and the name documents itself. Functionally equivalent to your suggestion.
On the test — your proposed version would not have caught this.
monkeypatch.delattr(os.path, "isjunction", raising=False) assert not uc._is_link(tmp_path)
That's what I wrote first, and it passed against the broken code. On Windows lstat() returns a real st_reparse_tag of 0, so the comparison is 0 == None → False, and the bug hides. Reproducing it off-Linux needs the missing attribute simulated on both sides.
posix_pre_312 (test_uninstall_command.py:1289) does that: deletes os.path.isjunction, deletes stat.IO_REPARSE_TAG_MOUNT_POINT, sets sys.platform to linux, and wraps Path.lstat so the result raises AttributeError for st_reparse_tag. Verified by reverting the fix: 3 of 4 fail against 70678f66, all 4 pass on 56f41d87. :1330 covers the end-to-end symptom — a populated ~/.gaia/venv must be removed and the run must exit 0.
CI on 31d58e48 and later is green on py3.10, py3.11, py3.12 and the macOS smoke lane.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Two
gaiacommands stop destroying things they do not own:gaia uninstall --purgeno longer builds a plan that targets yourDocumentsfolder whenGAIA_HOMEis misconfigured, and every "stop what's on this port" path no longer terminates a process that merely has a connection involving that number.Why
Neither of these needs an attacker.
GAIA_HOMEis already the documented state root for the security store and the embedded Lemonade daemon, so pointing it at~is a plausible misconfiguration — and doing so turned--purgeintorm -rf ~/venv ~/documents, wheredocumentsresolves case-insensitively onto the realDocumentson Windows and macOS. The containment guard that exists to prevent exactly this was vacuous, because the only root it allowed was the misconfigured home.The port matcher had the same shape of problem:
":80" in lineover a wholenetstatline also matches a:8009foreign address, so on a normal machinegaia kill --port 80force-killed a browser. On Unix it was worse —lsof -ti:PORTlists both ends of every connection and the codekill -9'd all of them, which is how a single mistyped port took out the Agent UI backend, the daemon, and anygaia chatconnected to Lemonade. #789 reported theshell=Truehalf of this code and was closed by switching to list arguments; the substring match survived the rewrite untouched.That matcher had been copy-pasted four times. Fixing one copy and documenting the new behaviour would have shipped a false claim, so this PR consolidates them.
Linked issue
Closes #3355
Changes
gaia uninstall64, with an error namingGAIA_HOMEand the resolved path. The resolved root is printed above the plan and in the--purgeconfirmation prompt.--venvand--purgeadditionally require an existing home to be GAIA's (named.gaia, or holding something GAIA created). Applying the ownership check to Tier 2 as well was a deliberate choice over documenting it as purge-only:--venvdeletes$GAIA_HOME/venv, so guarding only--purgewould still eat a project checkout's virtualenv — the outcome this PR claims to prevent.venv/is deliberately not a marker, since it is the one deletion target that routinely belongs to someone else. Nothing legitimate is refused: both installers shadow the env var with a hardcoded path (install.sh:11,install.ps1:7), so a venv is only ever created under~/.gaia, which passes on its name alone; a home that does not exist yet is a no-op.~/.gaiais deleted as a link, leaving its target alone. Previously the containment check resolved the link target, so a relocated~/.gaia/documentsraised an uncaughtRuntimeError— aftervenvandchatwere already gone — exiting 1 instead of the documentedEXIT_FS_ERROR. Junctions need their own detection:Path.is_symlink()returns False for one, and a junction is the only way to relocate a directory on Windows without Developer Mode. A containment refusal is now reported and downgrades the exit code without aborting the rest of the plan.Port targeting — one implementation instead of four
New
src/gaia/ports.pyholds the rules: only a socket in theLISTENINGstate whose local port matches exactly,lsof -nP -iTCP:N -sTCP:LISTEN -t, and an owning-process check before anything is signalled (the same identity checkLemonadeEmbedded._daemon_alivealready makes). Output decodes witherrors="replace", so OEM-codepage Windows no longer reports a UTF-8 decode error in place of a port result. Both call sites now use it:cli.kill_process_by_portgaia kill --port,gaia kill --lemonade,gaia api stopkill -9'd both ends of every connectionapi.app.stop_serverpython -m gaia.api.app stopstop_serverkeeps its own signalling (SIGTERM on POSIX, so the server winds its workers down) — only the selection was wrong.Deleted
src/gaia/util.py— the third and worst copy. It matched the port by substring and killed on"ESTABLISHED" in line, so it terminated processes merely connected to the port, with no allowlist. Nothing imports it:lemonade_clientdefines its ownkill_process_on_portthat shadows the name, andgrep -rn "gaia\.util\b\|from gaia import util" --include=*.py src/ tests/ hub/is empty before and after. Deleted rather than left as a trap for the next person who greps for "kill port".llm/lemonade_client.py:kill_process_on_portis deliberately left alone — the fourth site. It comparesconn.laddr.port == portexactly through psutil, so it never had the substring bug. It filters no connection state and has no allowlist, but a server's own established sockets share its local port, and the default 13305 sits outside the ephemeral range. Worth revisiting if that port ever becomes freely configurable into ephemeral territory; out of scope here.Test plan
pytest tests/unit/cli/test_kill_process_by_port.py -q— 35 passed. Fixturenetstat/lsofoutput covers the:80-matches-:8009case, foreign-address andTIME_WAITrows, UDP rows, IPv6, a localized state column, lsof's exit-1-means-no-match, and the-sTCP:LISTENcall shape.pytest tests/unit/api/test_stop_server.py -q— 8 passed. Covers the:8080-listener-vs---port 80case, the owner refusal, and missing tooling.pytest tests/unit/installer/test_uninstall_command.py -q— 61 passed, 6 failed, 3 errors. The junction tests build a real junction withmklink /Jon the real filesystem (pyfakefs only models POSIX symlinks) and skip off Windows. All 6 failures and 3 errors are pre-existing on Windows (pyfakefsanchors a rootless fake path onto the real drive letter); the same set fails onupstream/mainatabe87edc, where 38 passed.pytest tests/unit/cli/ tests/unit/api/ tests/unit/installer/ tests/unit/test_check_security_gates.py -q— 459 passed, 15 skipped, plus the pre-existing set above andtest_sh_parses_under_dash(needsdash).GAIA_HOME=$HOME gaia uninstall --purge --dry-run --yes— refuses, exit 64.gaia kill --port 400on a machine with a listener on:4001— reports nothing listening instead of killing it.python -m gaia.api.app stop --port 80with a listener on:8080— leaves it alone.python util/lint.py --black --isortandflake8 --select=Fon the changed files — clean.util/lint.py --allexits 1 on 9 pre-existing pylintno-membererrors for POSIX-onlyos.killpg/os.geteuidindaemon/sidecars/andinstaller/lemonade_installer.py, reproduced identically on a pristine tree;uvx pylinton the four files this PR changes reports nothing.Evidence
gaia uninstall, on the sameGAIA_HOME:Before the fix, the first command produced a seven-line plan whose
documentsentry resolved to the realDocumentsdirectory and passed the containment guard.Port targeting, against the live
netstaton the test machine — which happens to have a service on:4001:stop_server, with a real listener on:8080:Checklist
Closes #N/Fixes #N/Refs #N).python util/lint.py --all,pytest tests/unit/).docs/reference/cli.mdxdocuments theGAIA_HOMErequirement and the--porttargeting rules for both stop paths.No LLM-affecting surface is touched (no prompts, tool registration, tool docstrings, error classification, or model selection), so no
gaia eval agentrun applies.