Files
Octop/tests/integration/test_filesystem_api.py
智浪淘沙 fb35284327 fix(dashboard): browse every drive in the storage-root picker on Windows (#1429)
The picker derived a single browse root from `Path.home().anchor`, so a
user whose profile lives on `C:` could not reach `D:` at all. That was
structural, not a policy filter: `host_fs_tree_root()` returns one path,
`/api/filesystem/defaults` hands it over as `tree_root`, and
`rootDirTree.sanitizeTree` keeps exactly one tree and returns
`[cleaned]` - every sibling of that root was dropped on each update, so
even a hand-injected `D:/` node could not survive. `/filesystem/dirs`
only ever listed children of `C:/`, and the router exposed no endpoint
that enumerates drives.

Enumerate ready drive roots instead:

* `host_dirs.host_browse_roots()` returns `/` on POSIX and every ready
  drive on Windows, falling back to the home anchor when none respond so
  the picker never renders an empty tree. Probing uses `os.path.isdir`
  rather than `GetLogicalDrives` so mapped network drives are covered
  too, with no ctypes call.
* `GET /api/filesystem/roots` exposes that list, and
  `/api/filesystem/defaults` now also returns `browse_roots`. A
  `workspace_root_dir` policy jail still collapses both to that one path,
  which is the point of the policy.
* `RootDirSelect` takes `treeRoots` and renders a forest.
  `sanitizeTree` keeps one tree per root while still deduping globally -
  that set is what fixes antd#37228 duplicate rows, and drive roots are
  distinct paths, so global uniqueness is still the right rule.
  `ancestorDirPaths` now takes the longest containing root, so `D:/x/y`
  expands under `D:/` even though `C:/` is listed first. Single-string
  roots keep working, so the existing tests stayed valid.

Second, the hint text promised a sandbox that does not exist off Linux:
`ensure_bubblewrap` returns `not_linux` with "bubblewrap jail is
Linux-only; execute uses plain local_shell", so a non-root `root_dir`
only bounds the agent's *tool* paths while the agent process keeps
running with the server account's own filesystem access. That is a weaker
guarantee than the copy claimed, and worth correcting on its own.
`/api/filesystem/defaults` now reports `jail_enforced`, and
`backendRootDirJailHint` splits into a real-sandbox variant and
`backendRootDirPathLimitHint` for hosts without one. `backendRootDirDesc`
no longer says `/` unconditionally, which was only true on POSIX.

Drive enumeration and `shutil.which` sit behind `_ready_drive_roots()` /
`_bwrap_on_path()` seams: patching `os.path.isdir` or `shutil.which`
globally breaks pytest's own path handling mid-test.
2026-10-02 22:03:22 +08:00

416 lines
13 KiB
Python

"""Integration tests for /api/filesystem (host root_dir pickers)."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import httpx
import pytest
posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX root '/' probe behavior")
@pytest.mark.asyncio
async def test_list_host_dirs_requires_auth(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
) -> None:
client, _srv, _auth = env
r = await client.get("/api/filesystem/dirs")
assert r.status_code == 401, r.text
@pytest.mark.asyncio
async def test_list_host_dirs_lists_children(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
(tmp_path / "alpha").mkdir()
(tmp_path / "beta").mkdir()
(tmp_path / "notes.txt").write_text("x", encoding="utf-8")
monkeypatch.setattr(
"octop.infra.utils.host_dirs.normalize_host_path",
lambda path: Path(path).resolve(),
)
r = await client.get(
f"/api/filesystem/dirs?path={tmp_path.as_posix()}",
headers=auth,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["path"] == tmp_path.resolve().as_posix()
names = [entry["name"] for entry in body["entries"]]
assert {"alpha", "beta"}.issubset(names)
@pytest.mark.asyncio
async def test_list_host_dirs_rejects_proc(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
) -> None:
client, auth = env_admin_client
r = await client.get("/api/filesystem/dirs?path=/proc", headers=auth)
assert r.status_code == 400, r.text
assert r.json()["error"]["code"] == "WORKSPACE_OP_UNSUPPORTED"
@pytest.mark.asyncio
async def test_probe_host_dir_requires_auth(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
) -> None:
client, _srv, _auth = env
r = await client.post("/api/filesystem/probe", json={"path": "/"})
assert r.status_code == 401, r.text
@pytest.mark.asyncio
async def test_ensure_bwrap_requires_auth(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
) -> None:
client, _srv, _auth = env
r = await client.post("/api/filesystem/ensure-bwrap")
assert r.status_code == 401, r.text
@pytest.mark.asyncio
async def test_ensure_bwrap_returns_status_shape(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
monkeypatch.setattr(
"octop.api.routers.filesystem.ensure_bubblewrap",
lambda: {"status": "skipped", "reason": "not_linux", "detail": "test"},
)
r = await client.post("/api/filesystem/ensure-bwrap", headers=auth)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "skipped"
assert body["reason"] == "not_linux"
assert "detail" in body
@pytest.mark.asyncio
async def test_filesystem_defaults_for_admin(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from octop.infra.utils.host_dirs import (
host_browse_roots,
host_fs_tree_root,
host_jail_enforced,
)
client, auth = env_admin_client
home = tmp_path / "os_home"
home.mkdir()
monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
monkeypatch.setenv("OCTOP_IN_CONTAINER", "0")
r = await client.get("/api/filesystem/defaults", headers=auth)
assert r.status_code == 200, r.text
body = r.json()
assert body["default_root_dir"] == host_fs_tree_root()
assert body["tree_root"] == host_fs_tree_root()
assert body["browse_roots"] == host_browse_roots()
assert body["jail_enforced"] == host_jail_enforced()
assert body["in_container"] is False
assert "home" not in body
assert "allow_outside_home" not in body
@pytest.mark.asyncio
async def test_filesystem_roots_lists_every_drive_not_just_home_drive(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Regression: the picker used to be pinned to the home drive's root.
``host_fs_tree_root`` derives a single root from ``Path.home()``, so a user
whose profile lives on ``C:`` could not browse ``D:`` at all. The roots list
must enumerate drives independently of where home happens to be.
"""
client, auth = env_admin_client
home = tmp_path / "os_home"
home.mkdir()
monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
monkeypatch.setenv("OCTOP_IN_CONTAINER", "0")
r = await client.get("/api/filesystem/roots", headers=auth)
assert r.status_code == 200, r.text
roots = r.json()["roots"]
assert roots, "roots must never be empty — the picker needs a usable root"
from octop.infra.utils.host_dirs import host_browse_roots
assert roots == host_browse_roots()
# Every entry is an absolute, POSIX-serialized path the /dirs API accepts.
for root in roots:
assert root.startswith("/") or root.endswith(":/")
@pytest.mark.asyncio
async def test_filesystem_roots_requires_auth(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
) -> None:
client, _srv, _auth = env
r = await client.get("/api/filesystem/roots")
assert r.status_code == 401, r.text
@pytest.mark.asyncio
async def test_filesystem_defaults_in_container(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from octop.infra.utils.host_dirs import host_fs_tree_root
client, auth = env_admin_client
home = tmp_path / "os_home"
home.mkdir()
monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
monkeypatch.setenv("OCTOP_IN_CONTAINER", "1")
r = await client.get("/api/filesystem/defaults", headers=auth)
assert r.status_code == 200, r.text
body = r.json()
assert body["default_root_dir"] == host_fs_tree_root()
assert body["tree_root"] == host_fs_tree_root()
assert body["in_container"] is True
assert "home" not in body
assert "allow_outside_home" not in body
@pytest.mark.asyncio
async def test_non_admin_can_list_outside_home(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from octop.infra.utils.host_dirs import host_fs_tree_root
from tests.support.auth import create_user
client, _srv, admin_auth = env
home = tmp_path / "os_home"
home.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
monkeypatch.setenv("OCTOP_IN_CONTAINER", "0")
user_auth = await create_user(client, admin_auth, username="alice", password="TestPass12")
defaults = await client.get("/api/filesystem/defaults", headers=user_auth)
assert defaults.status_code == 200, defaults.text
body = defaults.json()
assert body["default_root_dir"] == host_fs_tree_root()
assert body["tree_root"] == host_fs_tree_root()
assert body["in_container"] is False
assert "home" not in body
assert "allow_outside_home" not in body
listed = await client.get(
f"/api/filesystem/dirs?path={outside.as_posix()}",
headers=user_auth,
)
assert listed.status_code == 200, listed.text
ok = await client.get(
f"/api/filesystem/dirs?path={home.as_posix()}",
headers=user_auth,
)
assert ok.status_code == 200, ok.text
probe = await client.post(
"/api/filesystem/probe",
headers=user_auth,
json={"path": outside.as_posix()},
)
assert probe.status_code == 200, probe.text
assert probe.json()["ok"] is True
@pytest.mark.asyncio
@posix_only
async def test_probe_host_dir_ok_for_slash(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
) -> None:
client, auth = env_admin_client
r = await client.post("/api/filesystem/probe", headers=auth, json={"path": "/"})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "path": "/"}
@pytest.mark.asyncio
async def test_probe_host_dir_ok_for_writable_dir(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
monkeypatch.setattr(
"octop.infra.utils.host_dirs.normalize_host_path",
lambda path: Path(path).resolve(),
)
r = await client.post(
"/api/filesystem/probe",
headers=auth,
json={"path": str(tmp_path)},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "path": tmp_path.resolve().as_posix()}
@pytest.mark.asyncio
async def test_probe_host_dir_rejects_file(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
file_path = tmp_path / "notes.txt"
file_path.write_text("x", encoding="utf-8")
monkeypatch.setattr(
"octop.infra.utils.host_dirs.normalize_host_path",
lambda path: Path(path).resolve(),
)
r = await client.post(
"/api/filesystem/probe",
headers=auth,
json={"path": str(file_path)},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is False
assert body["code"] == "not_directory"
@pytest.mark.asyncio
async def test_mkdir_host_dir_requires_auth(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
) -> None:
client, _srv, _auth = env
r = await client.post("/api/filesystem/mkdir", json={"path": "/"})
assert r.status_code == 401, r.text
@pytest.mark.asyncio
async def test_mkdir_host_dir_creates_child(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
monkeypatch.setattr(
"octop.infra.utils.host_dirs.normalize_host_path",
lambda path: Path(path).resolve(),
)
r = await client.post(
"/api/filesystem/mkdir",
headers=auth,
json={"path": str(tmp_path), "base_name": "New Folder"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["name"] == "New Folder"
assert (tmp_path / "New Folder").is_dir()
assert Path(body["path"]).resolve() == (tmp_path / "New Folder").resolve()
@pytest.mark.asyncio
async def test_rename_host_dir_renames_child(
env_admin_client: tuple[httpx.AsyncClient, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, auth = env_admin_client
target = tmp_path / "New Folder"
target.mkdir()
monkeypatch.setattr(
"octop.infra.utils.host_dirs.normalize_host_path",
lambda path: Path(path).resolve(),
)
r = await client.post(
"/api/filesystem/rename",
headers=auth,
json={"path": str(target), "new_name": "workspace"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["name"] == "workspace"
assert (tmp_path / "workspace").is_dir()
assert not target.exists()
@pytest.mark.asyncio
async def test_filesystem_respects_user_workspace_root(
env: tuple[httpx.AsyncClient, Any, dict[str, str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OCTOP_IN_CONTAINER", "0")
from tests.support.auth import TEST_PASSWORD, create_user
client, _srv, admin_auth = env
jail = tmp_path / "jail"
nested = jail / "ok"
outside = tmp_path / "outside"
jail.mkdir()
nested.mkdir()
outside.mkdir()
user_auth = await create_user(client, admin_auth, username="fs_policy", password=TEST_PASSWORD)
listed = (await client.get("/api/users", headers=admin_auth)).json()
uid = next(u["id"] for u in listed if u["username"] == "fs_policy")
patched = await client.patch(
f"/api/users/{uid}",
headers=admin_auth,
json={"workspace_root_dir": jail.as_posix()},
)
assert patched.status_code == 200, patched.text
defaults = await client.get("/api/filesystem/defaults", headers=user_auth)
assert defaults.status_code == 200, defaults.text
body = defaults.json()
assert body["tree_root"] == jail.resolve().as_posix()
assert body["default_root_dir"] == jail.resolve().as_posix()
# A policy jail collapses the picker to exactly that one path.
assert body["browse_roots"] == [jail.resolve().as_posix()]
assert "home" not in body
assert "allow_outside_home" not in body
jailed_roots = await client.get("/api/filesystem/roots", headers=user_auth)
assert jailed_roots.status_code == 200, jailed_roots.text
assert jailed_roots.json()["roots"] == [jail.resolve().as_posix()]
inside = await client.get(
f"/api/filesystem/dirs?path={nested.as_posix()}",
headers=user_auth,
)
assert inside.status_code == 200, inside.text
listed_out = await client.get(
f"/api/filesystem/dirs?path={outside.as_posix()}",
headers=user_auth,
)
assert listed_out.status_code == 400, listed_out.text
probe = await client.post(
"/api/filesystem/probe",
headers=user_auth,
json={"path": outside.as_posix()},
)
assert probe.status_code == 200, probe.text
assert probe.json()["ok"] is False
assert probe.json()["code"] == "outside_root"