Skip to content

Navigation Menu

Sign in
Sign up

fix(cli): stop uninstall and kill --port destroying what they do not own - #3361

Open
kovtcharov wants to merge 4 commits into
amd:main from
kovtcharov:fix/destructive-cli-paths
Open

fix(cli): stop uninstall and kill --port destroying what they do not own #3361
kovtcharov wants to merge 4 commits into
amd:main from
kovtcharov:fix/destructive-cli-paths

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Summary

Two gaia commands stop destroying things they do not own: gaia uninstall --purge no longer builds a plan that targets your Documents folder when GAIA_HOME is 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_HOME is 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 --purge into rm -rf ~/venv ~/documents, where documents resolves case-insensitively onto the real Documents on 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 line over a whole netstat line also matches a :8009 foreign address, so on a normal machine gaia kill --port 80 force-killed a browser. On Unix it was worse — lsof -ti:PORT lists both ends of every connection and the code kill -9'd all of them, which is how a single mistyped port took out the Agent UI backend, the daemon, and any gaia chat connected to Lemonade. #789 reported the shell=True half 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 uninstall

  • Refuses to plan any deletion when the resolved GAIA home is the user's home directory, contains it, or is a drive/filesystem root — exit 64, with an error naming GAIA_HOME and the resolved path. The resolved root is printed above the plan and in the --purge confirmation prompt.
  • Both --venv and --purge additionally 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: --venv deletes $GAIA_HOME/venv, so guarding only --purge would 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.
  • A symlink or Windows junction under ~/.gaia is deleted as a link, leaving its target alone. Previously the containment check resolved the link target, so a relocated ~/.gaia/documents raised an uncaught RuntimeError — after venv and chat were already gone — exiting 1 instead of the documented EXIT_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.py holds the rules: only a socket in the LISTENING state whose local port matches exactly, lsof -nP -iTCP:N -sTCP:LISTEN -t, and an owning-process check before anything is signalled (the same identity check LemonadeEmbedded._daemon_alive already makes). Output decodes with errors="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:

Call site Reached by Before
cli.kill_process_by_port gaia kill --port, gaia kill --lemonade, gaia api stop substring match; Windows killed the first hit, Unix kill -9'd both ends of every connection
api.app.stop_server python -m gaia.api.app stop same substring defect, plus no owner check at all

stop_server keeps 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_client defines its own kill_process_on_port that shadows the name, and grep -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_port is deliberately left alone — the fourth site. It compares conn.laddr.port == port exactly 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. Fixture netstat/lsof output covers the :80-matches-:8009 case, foreign-address and TIME_WAIT rows, UDP rows, IPv6, a localized state column, lsof's exit-1-means-no-match, and the -sTCP:LISTEN call shape.
  • pytest tests/unit/api/test_stop_server.py -q — 8 passed. Covers the :8080-listener-vs---port 80 case, 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 with mklink /J on the real filesystem (pyfakefs only models POSIX symlinks) and skip off Windows. All 6 failures and 3 errors are pre-existing on Windows (pyfakefs anchors a rootless fake path onto the real drive letter); the same set fails on upstream/main at abe87edc, 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 and test_sh_parses_under_dash (needs dash).
  • GAIA_HOME=$HOME gaia uninstall --purge --dry-run --yes — refuses, exit 64.
  • gaia kill --port 400 on a machine with a listener on :4001 — reports nothing listening instead of killing it.
  • python -m gaia.api.app stop --port 80 with a listener on :8080 — leaves it alone.
  • python util/lint.py --black --isort and flake8 --select=F on the changed files — clean. util/lint.py --all exits 1 on 9 pre-existing pylint no-member errors for POSIX-only os.killpg / os.geteuid in daemon/sidecars/ and installer/lemonade_installer.py, reproduced identically on a pristine tree; uvx pylint on the four files this PR changes reports nothing.

Evidence

  • Agent exposed in the Agent UI — N/A, no UI surface is touched.
  • MCP tools / servers — N/A, no MCP surface is touched.
  • CLI

gaia uninstall, on the same GAIA_HOME:

$ GAIA_HOME=C:/Users/14255 gaia uninstall --purge --dry-run --yes
error: Refusing to uninstall: the GAIA home resolves to C:\Users14255,円 which is your
home directory (or contains it). Purging it would delete C:\Users14255円\documents — on
Windows and macOS that is your real Documents folder. GAIA_HOME is set to
'C:/Users/14255'. Point GAIA_HOME at a dedicated GAIA data directory (the default is
C:\Users14255円\.gaia).
exit=64
$ gaia uninstall --purge --dry-run --yes # GAIA_HOME unset — unchanged
GAIA home: C:\Users14255円\.gaia
[dry-run] Would remove:
 (--purge) C:\Users14255円\.gaia\venv
 (--purge) C:\Users14255円\.gaia\chat
 (--purge) C:\Users14255円\.gaia\documents
 ...
exit=0

Before the fix, the first command produced a seven-line plan whose documents entry resolved to the real Documents directory and passed the containment guard.

Port targeting, against the live netstat on the test machine — which happens to have a service on :4001:

--- port 400 ---
 OLD: 10 matching rows; would kill PID 26344
 TCP 0.0.0.0:4001 0.0.0.0:0 LISTENING 26344
 NEW: []
$ gaia kill --port 400
❌ No process is listening on port 400
$ netstat -ano | grep LISTENING | grep ':4001 '
 TCP 0.0.0.0:4001 0.0.0.0:0 LISTENING 26344 # untouched
$ gaia kill --port 135
❌ Refusing to kill 2540 (svchost.exe) on port 135: not a GAIA or Lemonade process.
 Stop it with its own tooling, or kill it by PID if that is really what you want.

stop_server, with a real listener on :8080:

 TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 53768
$ python -m gaia.api.app stop --port 80 # must not touch the :8080 listener
i️ No API server found running on port 80
 TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 53768 # still alive
$ python -m gaia.api.app stop --port 8080 # the real target
🛑 Stopped API server process (PID: 53768)
✅ API server stopped
  • HTTP API / REST — N/A. Both stop paths are CLI entry points; covered above.

Checklist

  • I have linked a GitHub issue above (Closes #N / Fixes #N / Refs #N).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally (python util/lint.py --all, pytest tests/unit/).
  • I have attached real-world evidence matched to the surface I changed (see Evidence above), or marked each surface N/A.
  • I have updated documentation if user-visible behavior changed — docs/reference/cli.mdx documents the GAIA_HOME requirement and the --port targeting 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 agent run applies.

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 
@github-actions github-actions Bot added documentation Documentation changes cli CLI changes tests Test changes labels Sep 4, 2026

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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_path takes the link branch (uninstall_command.py:652)
  • _unlink_linkpath.unlink() raises IsADirectoryErroros.rmdir() raises ENOTEMPTY
  • caught at uninstall_command.py:664[error] failed to remove ..., all_ok = FalseEXIT_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 2uninstall_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

  1. chat, logs, and config.json are weak ownership markers (uninstall_command.py:459). A GAIA_HOME aimed at a real data folder that happens to contain any of them passes _has_gaia_marker and becomes purgeable. config.json/gaia.log/memory.db plus the electron-* files carry the signal; the three generic ones mostly widen the hole.

  2. The --port doc note overstates the protection (docs/reference/cli.mdx:437). It's accurate that a database or svchost gets refused, but the allowlist admits any python/node/electron process — so a user's own Node dev server on a mistyped port is still kill -9'd. One clause noting that the check is interpreter-level would keep the note honest.

  3. Unreadable PID column reads as "nothing listening" (cli.py:128). netstat -tulpn prints - for processes the caller doesn't own, so the int() raises, the row is dropped, and the user is told No process is listening on port N when 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_roots now carries a note explaining that it is vacuous until _assert_purgeable_home has run, so the next reader can't mistake the containment guard for sufficient on its own.
  • TestListenerLookupCallShape asserts the shape of the outgoing lsof argv (-sTCP:LISTEN present, -ti:PORT absent) rather than just that the tool was invoked — exactly the boundary-validity testing CLAUDE.md asks for, and it's what makes the lsof exit-1-vs-failure distinction reviewable.
  • Fixtures are real trimmed netstat output covering the actual regression shapes: :80 vs :8009, TIME_WAIT, UDP rows, IPv6 dedupe, and a localized ABHÖREN state 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 old No process found running on port N catch-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 

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🔴 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 == NoneTrue — 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)

Ovtcharov added 2 commits September 4, 2026 12:00
`_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 

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@kovtcharov-amd kovtcharov-amd Awaiting requested review from kovtcharov-amd kovtcharov-amd is a code owner

Assignees

No one assigned

Labels

cli CLI changes documentation Documentation changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Destructive CLI paths: --purge can delete ~/Documents; kill --port kills unrelated processes

1 participant

AltStyle によって変換されたページ (->オリジナル) /