diff --git a/.gitignore b/.gitignore
index 2c40c5a4..3a4491df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,13 +72,7 @@ src/octop/dashboard/assets/*
!src/octop/dashboard/__init__.py
!src/octop/dashboard/.gitkeep
-# 美团生活助手专家:随包分发 pt-passport 运行时(Docker 镜像无 npm,
-# 依赖此预装产物执行认证;包内文件由来源 tgz 固定,不会被误提交污染)
-# 注意:git 不会进入被忽略的目录,因此必须先反排除 node_modules 目录本身
-!src/octop/infra/agents/experts/library/meituan-living-assistant/
-!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/
-!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/
-!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/
+# Ship Meituan pt-passport runtime; override global node_modules/ ignore.
!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/
!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/**
@@ -98,6 +92,10 @@ logs/
# Local monorepo dev helpers (not for upstream)
scripts/dev-local-link.sh
+# Local downloaded platform-tools for mobile DinD testing
+.tools/
+.tools-sdk/
+
# Local agent planning docs (not for upstream)
docs/superpowers/
scripts/lh_image_install_octop_tencentos.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75a81a9b..5f812c6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@
## [Unreleased]
+## [0.9.26] - 2026-08-23
+
+### 新增
+
+- 远程手机(实验性):安装时探测主机移动能力(`capabilities.mobile`,物理机 / Redroid / KVM);能力开启后开放 `GET /api/settings/capabilities` 与 `/api/mobile/*`。控制台「远程手机」支持 adb H.264/JPEG 推流、触控、画质预设、设备信息与 AI 助手面板;智能体移动工具绑定当前远程手机会话
+- 控制台布局支持经典 / 极简模式,聊天记录统一承载;用户可选填邮箱(邀请 / 登录);知识库支持应用内编辑 markdown / txt;远程桌面与远程手机合并为统一控制入口
+
+### 修复
+
+- 邀请链接统一为 `/invite?code=`,修复邀请页居中与移动端在 overflow-hidden 壳下的滚动;启动前显示 logo 加载动画;设置页邮箱输入图标对齐;移除聊天坞中的远程手机入口
+- 加固 dashboard 鉴权与请求层(setup 锁定 503、401 刷新)、登录页与 AuthGuard 体验;按服务器能力门控移动端功能;远程控制中枢页签文案缩短为「服务器 / 手机」
+
## [0.9.25] - 2026-08-21
### 新增
diff --git a/README.md b/README.md
index 32432a8e..c2d43c75 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
diff --git a/dashboard/index.html b/dashboard/index.html
index 1b25efbd..c2902f0b 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -30,6 +30,109 @@
Octop
+
+
"
+ return json.dumps({"device": serial, "xml": out}, ensure_ascii=False)
+ except Exception as exc:
+ return json.dumps({"error": str(exc)}, ensure_ascii=False)
+
+ async def mobile_handoff_to_user(
+ reason: Annotated[
+ str,
+ Field(description="Why the user must take over (login, captcha, payment, etc.)."),
+ ],
+ ) -> str:
+ try:
+ ctx = _tool_ctx()
+ locale = str(ctx.get("locale") or "en")
+ _require_mobile_access(config, ctx, user_repo)
+ msg = tr(
+ "mobile.handoff_message",
+ locale,
+ reason=reason.strip() or tr("mobile.handoff_default", locale),
+ )
+ return json.dumps({"handoff": True, "message": msg}, ensure_ascii=False)
+ except Exception as exc:
+ return json.dumps({"error": str(exc)}, ensure_ascii=False)
+
+ if not find_adb():
+ return []
+
+ return [
+ StructuredTool.from_function(
+ coroutine=mobile_screenshot,
+ name=MOBILE_SCREENSHOT,
+ description="Capture the connected Android device screen (PNG saved under the agent workspace when possible).",
+ ),
+ StructuredTool.from_function(
+ coroutine=mobile_tap,
+ name=MOBILE_TAP,
+ description="Tap a coordinate on the connected Android device screen.",
+ ),
+ StructuredTool.from_function(
+ coroutine=mobile_swipe,
+ name=MOBILE_SWIPE,
+ description="Swipe on the connected Android device screen.",
+ ),
+ StructuredTool.from_function(
+ coroutine=mobile_launch_app,
+ name=MOBILE_LAUNCH_APP,
+ description="Launch an Android app by package name via adb.",
+ ),
+ StructuredTool.from_function(
+ coroutine=mobile_ui_dump,
+ name=MOBILE_UI_DUMP,
+ description="Dump the Android UI hierarchy (uiautomator XML) for element discovery.",
+ ),
+ StructuredTool.from_function(
+ coroutine=mobile_handoff_to_user,
+ name=MOBILE_HANDOFF,
+ description="Ask the human user to complete login, captcha, or other manual steps on the device.",
+ ),
+ ]
diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py
index f76ebc6b..656e9ae0 100644
--- a/src/octop/infra/server.py
+++ b/src/octop/infra/server.py
@@ -21,6 +21,7 @@
from octop.infra.db.migrate import run_migrations
from octop.infra.db.services import SharedServices, build_shared_services
from octop.infra.gateway.gateway import Gateway
+from octop.infra.mobile.config_probe import ensure_mobile_capabilities_probed
from octop.infra.proactive.scheduler import ProactiveCareScheduler
from octop.infra.proactive.service import ProactiveCareService
from octop.infra.setup import password_file as _wizard_pw
@@ -148,7 +149,7 @@ async def start(self) -> None:
apply_env_file(env_file_path(self.paths.root))
self._setup_logging()
- config = load_config(self.paths.config)
+ config = ensure_mobile_capabilities_probed(self.paths.config)
self.config = config
self.expert_catalog = ExpertCatalog(
diff --git a/src/octop/infra/users/email.py b/src/octop/infra/users/email.py
new file mode 100644
index 00000000..5196fd68
--- /dev/null
+++ b/src/octop/infra/users/email.py
@@ -0,0 +1,34 @@
+"""Email normalization and light validation for local users."""
+
+from __future__ import annotations
+
+import re
+
+from octop.infra.errors import ErrorCode, OctopError
+
+# Practical address shape (not full RFC 5322).
+_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+
+
+def normalize_email(raw: str | None) -> str | None:
+ """Strip and lowercase; empty / whitespace-only becomes ``None``."""
+ if raw is None:
+ return None
+ cleaned = raw.strip().lower()
+ return cleaned or None
+
+
+def validate_email_format(email: str) -> None:
+ """Raise ``OctopError(EMAIL_INVALID)`` when *email* is not a plausible address."""
+ if not _EMAIL_RE.match(email):
+ raise OctopError(ErrorCode.EMAIL_INVALID, "invalid email address", status=400)
+
+
+def parse_optional_email(raw: str | None, *, required_valid: bool = True) -> str | None:
+ """Normalize optional email input; optionally validate non-empty values."""
+ email = normalize_email(raw)
+ if email is None:
+ return None
+ if required_valid:
+ validate_email_format(email)
+ return email
diff --git a/src/octop/infra/users/invites.py b/src/octop/infra/users/invites.py
index 3ffb745b..969a748d 100644
--- a/src/octop/infra/users/invites.py
+++ b/src/octop/infra/users/invites.py
@@ -55,8 +55,8 @@ def generate_invite_code() -> str:
def invite_path(code: str) -> str:
- """Canonical share path (SPA root query), matching common invite links."""
- return f"/?invite={code}"
+ """Canonical share path for invite redemption UI."""
+ return f"/invite?code={code}"
def build_invite_url(base_url: str, code: str) -> str:
@@ -155,7 +155,10 @@ def redeem(
display_name: str | None,
locale: str | None,
register_user: Callable[[User], None],
+ email: str | None = None,
) -> User:
+ from octop.infra.users.email import parse_optional_email
+
cleaned_code = (code or "").strip()
cleaned_username = (username or "").strip()
if not cleaned_code:
@@ -167,6 +170,7 @@ def redeem(
name = display_name.strip() if isinstance(display_name, str) else None
if name == "":
name = None
+ normalized_email = parse_optional_email(email)
try:
user_id, _invite = self._repo.redeem_creating_user(
code=cleaned_code,
@@ -174,6 +178,7 @@ def redeem(
password_hash=hash_password(password),
display_name=name,
locale=loc,
+ email=normalized_email,
)
except LookupError as exc:
raise OctopError(ErrorCode.INVITE_INVALID, "invite not found") from exc
@@ -184,6 +189,11 @@ def redeem(
ErrorCode.USERNAME_TAKEN,
f"username {cleaned_username!r} already exists",
) from exc
+ if reason == "email_taken":
+ raise OctopError(
+ ErrorCode.EMAIL_TAKEN,
+ f"email {normalized_email!r} already exists",
+ ) from exc
if reason == "used":
raise OctopError(ErrorCode.INVITE_USED, "invite already used") from exc
if reason == "expired":
diff --git a/src/octop/infra/users/manager.py b/src/octop/infra/users/manager.py
index 4e907843..d28a90f2 100644
--- a/src/octop/infra/users/manager.py
+++ b/src/octop/infra/users/manager.py
@@ -21,6 +21,7 @@
from octop.infra.db.repos.users import UserRepo
from octop.infra.db.services import SharedServices
from octop.infra.errors import ErrorCode, OctopError
+from octop.infra.users.email import normalize_email, parse_optional_email
from octop.infra.users.identity import Role, User
from octop.infra.users.password import hash_password, validate_password_policy, verify_password
from octop.infra.users.permissions import validate_permission_keys
@@ -63,10 +64,7 @@ def allocate_username(repo: UserRepo, claims: dict[str, Any], subject: str) -> s
def _normalized_claim_email(claims: dict[str, Any]) -> str | None:
- email = claims.get("email")
- if not isinstance(email, str):
- return None
- return email.strip().lower() or None
+ return normalize_email(claims.get("email") if isinstance(claims.get("email"), str) else None)
def _claim_display_name(claims: dict[str, Any]) -> str | None:
@@ -129,6 +127,7 @@ async def create(
display_name: str | None = None,
locale: str | None = None,
permissions: builtins.list[str] | None = None,
+ email: str | None = None,
) -> User:
if not username:
raise OctopError(ErrorCode.USERNAME_TAKEN, "username must not be empty")
@@ -138,20 +137,38 @@ async def create(
keys = validate_permission_keys(permissions or [])
except ValueError as exc:
raise OctopError(ErrorCode.FORBIDDEN, str(exc), status=400) from exc
+ normalized_email = parse_optional_email(email)
async with self._lock:
if self._services.user_repo.get_by_username(username) is not None:
raise OctopError(
ErrorCode.USERNAME_TAKEN,
f"username {username!r} already exists",
)
- uid = self._services.user_repo.create(
- username=username,
- password_hash=hash_password(password),
- role=role.value,
- display_name=display_name,
- locale=loc,
- permissions=keys,
- )
+ if (
+ normalized_email is not None
+ and self._services.user_repo.get_by_email(normalized_email) is not None
+ ):
+ raise OctopError(
+ ErrorCode.EMAIL_TAKEN,
+ f"email {normalized_email!r} already exists",
+ )
+ try:
+ uid = self._services.user_repo.create(
+ username=username,
+ password_hash=hash_password(password),
+ role=role.value,
+ display_name=display_name,
+ locale=loc,
+ email=normalized_email,
+ permissions=keys,
+ )
+ except Exception as exc:
+ if _is_unique_violation(exc) and normalized_email is not None:
+ raise OctopError(
+ ErrorCode.EMAIL_TAKEN,
+ f"email {normalized_email!r} already exists",
+ ) from exc
+ raise
user = User(
id=uid,
username=username,
@@ -176,6 +193,7 @@ async def create_from_invite(
password: str,
display_name: str | None = None,
locale: str | None = None,
+ email: str | None = None,
) -> User:
from octop.infra.users.invites import InviteService
@@ -186,6 +204,7 @@ async def create_from_invite(
password=password,
display_name=display_name,
locale=locale,
+ email=email,
register_user=self.register_cached_user,
)
@@ -296,7 +315,14 @@ async def resolve_or_create_sso_user(
return cached_user
async def authenticate(self, username: str, password: str) -> User | None:
- row = self._services.user_repo.get_by_username(username)
+ identifier = (username or "").strip()
+ if not identifier:
+ return None
+ row = self._services.user_repo.get_by_username(identifier)
+ if row is None:
+ email = normalize_email(identifier)
+ if email is not None:
+ row = self._services.user_repo.get_by_email(email)
if row is None:
return None
now = int(time.time())
@@ -320,7 +346,9 @@ async def authenticate(self, username: str, password: str) -> User | None:
lockout_seconds=self._login_lockout_seconds,
now=now,
)
- self._services.audit_repo.write(actor=username, action="auth.failed", target=username)
+ self._services.audit_repo.write(
+ actor=row.username, action="auth.failed", target=row.username
+ )
if retry_after > 0:
minutes = max(1, (retry_after + 59) // 60)
raise OctopError(
@@ -330,7 +358,7 @@ async def authenticate(self, username: str, password: str) -> User | None:
)
return None
self._services.user_repo.clear_login_lockout(row.id)
- user = self._users.get(username)
+ user = self._users.get(row.username)
if user is None:
user = User(
id=row.id,
@@ -340,8 +368,8 @@ async def authenticate(self, username: str, password: str) -> User | None:
locale=normalize_locale(row.locale),
permissions=list(row.permissions),
)
- self._users[username] = user
- self._services.audit_repo.write(actor=username, action="auth.login")
+ self._users[row.username] = user
+ self._services.audit_repo.write(actor=row.username, action="auth.login")
return user
async def change_password(self, username: str, old: str, new: str) -> None:
@@ -413,6 +441,33 @@ async def set_display_name(self, username: str, display_name: str | None) -> Non
if current is not None:
current.display_name = display_name
+ async def set_email(self, username: str, email: str | None) -> None:
+ row = self._services.user_repo.get_by_username(username)
+ if row is None:
+ raise OctopError(ErrorCode.NOT_FOUND, "user not found")
+ normalized = parse_optional_email(email)
+ if normalized is not None:
+ owner = self._services.user_repo.get_by_email(normalized)
+ if owner is not None and owner.id != row.id:
+ raise OctopError(
+ ErrorCode.EMAIL_TAKEN,
+ f"email {normalized!r} already exists",
+ )
+ try:
+ self._services.user_repo.set_email(row.id, normalized)
+ except Exception as exc:
+ if _is_unique_violation(exc) and normalized is not None:
+ raise OctopError(
+ ErrorCode.EMAIL_TAKEN,
+ f"email {normalized!r} already exists",
+ ) from exc
+ raise
+ self._services.audit_repo.write(
+ actor=ACTOR_ADMIN,
+ action="user.set_email",
+ target=username,
+ )
+
async def set_locale(self, username: str, locale: str) -> None:
row = self._services.user_repo.get_by_username(username)
if row is None:
diff --git a/src/octop/infra/users/permissions.py b/src/octop/infra/users/permissions.py
index cba45724..6b1d26ae 100644
--- a/src/octop/infra/users/permissions.py
+++ b/src/octop/infra/users/permissions.py
@@ -57,6 +57,7 @@ def _p(
"terminal": _p("terminal", "control", "工作台/终端", "Workbench / Terminal"),
"browser": _p("browser", "control", "工作台/浏览器", "Workbench / Browser"),
"desktop": _p("desktop", "control", "远程桌面", "Remote Desktop"),
+ "mobile": _p("mobile", "control", "远程手机", "Remote Phone"),
# --- admin: grouped by page, chip = tab title ---
"users": _p(
"users",
diff --git a/src/octop/infra/utils/host_dirs.py b/src/octop/infra/utils/host_dirs.py
index 68528b81..32aae08c 100644
--- a/src/octop/infra/utils/host_dirs.py
+++ b/src/octop/infra/utils/host_dirs.py
@@ -27,12 +27,12 @@
def host_path_text(path: Path) -> str:
"""Serialize a host path for API/UI (POSIX separators, even on Windows)."""
- return path.expanduser().resolve().as_posix()
+ return Path(os.path.realpath(os.path.expanduser(str(path)))).as_posix()
def host_home_dir() -> Path:
"""Absolute home directory of the OS user running the Octop process."""
- return Path.home().expanduser().resolve()
+ return Path(os.path.realpath(os.path.expanduser(str(Path.home()))))
def host_fs_tree_root(*, allow_outside_home: bool) -> str:
@@ -50,30 +50,48 @@ def host_fs_tree_root(*, allow_outside_home: bool) -> str:
return host_path_text(Path(home.anchor))
+def _path_within_base(resolved: str, base: str) -> bool:
+ """True when *resolved* equals *base* or is a subdirectory (normcase-safe).
+
+ Uses ``startswith`` after ``os.path.realpath`` so CodeQL treats this as a
+ path-injection containment barrier (``Path.resolve`` / ``relative_to`` are
+ not modeled as sanitizers).
+ """
+ resolved_n = os.path.normcase(resolved)
+ base_n = os.path.normcase(base)
+ if resolved_n == base_n:
+ return True
+ if base_n.endswith(os.sep):
+ return resolved_n.startswith(base_n)
+ return resolved_n.startswith(base_n + os.sep)
+
+
def is_within_host_home(resolved: Path, *, home: Path | None = None) -> bool:
"""True when *resolved* is the host home directory or a subdirectory."""
- base = (home or host_home_dir()).resolve()
- target = resolved.resolve()
- try:
- target.relative_to(base)
- return True
- except ValueError:
- if os.name != "nt":
- return False
- # Windows paths are case-insensitive.
- base_s = os.path.normcase(str(base))
- target_s = os.path.normcase(str(target))
- return target_s == base_s or target_s.startswith(base_s + os.sep)
+ base = os.path.realpath(str(home or host_home_dir()))
+ target = os.path.realpath(str(resolved))
+ return _path_within_base(target, base)
def normalize_host_path(path: str) -> Path:
+ """Canonical absolute path via ``os.path.realpath`` (CodeQL-recognized)."""
raw = path.strip() or ("/" if os.name == "posix" else str(Path.home().anchor))
- return Path(raw).expanduser().resolve()
+ return Path(os.path.realpath(os.path.expanduser(raw)))
+
+
+def _browse_tree_base() -> str:
+ """Containment root for home-jailed picker paths."""
+ return os.path.realpath(str(host_home_dir()))
def _is_denied_host_path(resolved: Path) -> bool:
if os.name != "posix":
return False
+ # Process home may coincide with a denylist prefix (uid 0 → ``/root``).
+ # The UI defaults ``root_dir`` to home, so home and its subdirs must stay
+ # selectable; ``/root`` remains denied for non-root process homes.
+ if is_within_host_home(resolved):
+ return False
text = resolved.as_posix()
# macOS resolves /etc → /private/etc (and similar). Strip that prefix so
# denied policy still matches the logical system locations.
@@ -89,19 +107,32 @@ def assert_safe_host_path(path: str, *, restrict_to_home: bool = False) -> Path:
subdirectories are allowed. The HTTP filesystem / expert APIs pass
``False`` so any authenticated user may pick outside home (denylist still
applies).
+
+ Normalization uses ``os.path.realpath`` and containment uses ``startswith``
+ against the browse-tree base — the pattern CodeQL recognizes for
+ ``py/path-injection`` (unlike ``Path.resolve`` alone).
"""
if not path or "\0" in path:
raise ValueError("invalid path")
try:
- resolved = normalize_host_path(path)
+ # normalize_host_path already realpath's; keep a str for startswith
+ # so CodeQL sees a containment sanitizer on the FS path we return.
+ resolved_s = os.fspath(normalize_host_path(path))
except OSError as exc:
raise ValueError("invalid path") from exc
- if not resolved.is_absolute():
+ if not os.path.isabs(resolved_s):
raise ValueError("path must be absolute")
+ resolved = Path(resolved_s)
if _is_denied_host_path(resolved):
raise ValueError(_NOT_ALLOWED_MSG)
- if restrict_to_home and not is_within_host_home(resolved):
- raise ValueError(_OUTSIDE_HOME_MSG)
+ # Home jail only. The host-root case (restrict_to_home=False) allows any
+ # absolute path: on POSIX everything sits under "/", and on Windows the
+ # denylist is empty while windows_neutralize_host_root rewrites "/" at
+ # runtime, so a drive-relative "/" must not be rejected there.
+ if restrict_to_home:
+ base = _browse_tree_base()
+ if not _path_within_base(resolved_s, base):
+ raise ValueError(_OUTSIDE_HOME_MSG)
return resolved
@@ -123,7 +154,7 @@ def list_host_subdirs(path: str, *, restrict_to_home: bool = False) -> list[dict
if not child.is_dir():
continue
try:
- resolved = child.resolve()
+ resolved = Path(os.path.realpath(str(child)))
if not resolved.is_dir():
continue
if _is_denied_host_path(resolved):
diff --git a/tests/integration/test_invites_api.py b/tests/integration/test_invites_api.py
index 086efdbd..fe54d7e6 100644
--- a/tests/integration/test_invites_api.py
+++ b/tests/integration/test_invites_api.py
@@ -16,7 +16,7 @@ async def test_invite_create_list_redeem_and_one_time(env) -> None:
assert invite["status"] == "pending"
assert invite["note"] == "for bob"
assert invite["code"]
- assert invite["invite_path"].startswith("/?invite=")
+ assert invite["invite_path"].startswith("/invite?code=")
assert invite["invite_url"].endswith(invite["invite_path"])
code = invite["code"]
diff --git a/tests/integration/test_published_experts.py b/tests/integration/test_published_experts.py
index e3f0b4b1..8b8347f2 100644
--- a/tests/integration/test_published_experts.py
+++ b/tests/integration/test_published_experts.py
@@ -2,10 +2,17 @@
from __future__ import annotations
+import os
from typing import Any
+import pytest
+
from tests.support.auth import create_user
+posix_only = pytest.mark.skipif(
+ os.name != "posix", reason="local_shell backend workspace file ops unsupported on Windows"
+)
+
async def _owner_and_peer(
env: tuple[Any, Any, dict[str, str]],
@@ -66,6 +73,7 @@ async def test_publish_list_install_and_unpublish_preserves_installed_fork(
assert fork.json()["name"] == "Peer installed expert"
+@posix_only
async def test_install_published_expert_accepts_create_options(
env: tuple[Any, Any, dict[str, str]],
) -> None:
diff --git a/tests/unit/i18n/test_mobile.py b/tests/unit/i18n/test_mobile.py
new file mode 100644
index 00000000..c3c9c25a
--- /dev/null
+++ b/tests/unit/i18n/test_mobile.py
@@ -0,0 +1,13 @@
+"""tests/unit/i18n/test_mobile.py"""
+
+from __future__ import annotations
+
+from octop.i18n.loader import all_keys_for_locale
+
+
+def test_mobile_keys_parity() -> None:
+ en_keys = {k for k in all_keys_for_locale("en") if k.startswith("mobile.")}
+ zh_keys = {k for k in all_keys_for_locale("zh") if k.startswith("mobile.")}
+ assert en_keys == zh_keys
+ assert "mobile.no_device" in en_keys
+ assert "mobile.handoff_message" in en_keys
diff --git a/tests/unit/infra/utils/test_host_dirs.py b/tests/unit/infra/utils/test_host_dirs.py
index b30eb128..be79dc9a 100644
--- a/tests/unit/infra/utils/test_host_dirs.py
+++ b/tests/unit/infra/utils/test_host_dirs.py
@@ -72,11 +72,60 @@ def test_assert_safe_host_path_rejects_private_etc_symlink() -> None:
@posix_only
-def test_assert_safe_host_path_rejects_root() -> None:
+def test_assert_safe_host_path_rejects_root(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # Deny /root only when it is not the process home (non-root Octop).
+ home = tmp_path / "os_home"
+ home.mkdir()
+ monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
with pytest.raises(ValueError, match="not allowed"):
assert_safe_host_path("/root")
+@posix_only
+def test_assert_safe_host_path_allows_root_when_home(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Octop running as uid 0 uses /root as home; default root_dir must probe OK."""
+ root_home = Path("/root")
+ if not root_home.is_dir():
+ pytest.skip("/root not available")
+ monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: root_home)
+ assert assert_safe_host_path("/root") == root_home.resolve()
+ nested = root_home / ".octop"
+ # Nested path under home is allowed even if the parent is denylisted for others.
+ assert assert_safe_host_path(str(nested)) == nested.resolve()
+
+
+@posix_only
+def test_list_host_subdirs_includes_root_home_when_denied_prefix(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Process home ``/root`` must appear when listing ``/`` (uid 0 default)."""
+ root_home = Path("/root")
+ if not root_home.is_dir() or not os.access(root_home, os.R_OK | os.X_OK):
+ pytest.skip("/root not readable")
+ monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: root_home)
+
+ entries = list_host_subdirs("/")
+ paths = {item["path"] for item in entries}
+ assert root_home.resolve().as_posix() in paths
+
+
+@posix_only
+def test_list_host_subdirs_hides_root_when_not_home(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ home = tmp_path / "os_home"
+ home.mkdir()
+ monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home)
+
+ entries = list_host_subdirs("/")
+ paths = {item["path"] for item in entries}
+ assert "/root" not in paths
+
+
@posix_only
def test_probe_host_root_dir_skips_write_for_slash() -> None:
result = probe_host_root_dir("/")
diff --git a/tests/unit/knowledge/test_text_documents.py b/tests/unit/knowledge/test_text_documents.py
new file mode 100644
index 00000000..a4dc90d9
--- /dev/null
+++ b/tests/unit/knowledge/test_text_documents.py
@@ -0,0 +1,73 @@
+"""KnowledgeService text create / update helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from octop.infra.db.migrate import run_migrations
+from octop.infra.db.pool import SqlitePool
+from octop.infra.db.repos.knowledge import KnowledgeRepo
+from octop.infra.db.repos.users import UserRepo
+from octop.infra.knowledge.files import document_path
+from octop.infra.knowledge.service import KnowledgeService
+from octop.infra.utils.paths import PathLayout
+
+
+@pytest.fixture
+def services(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
+ monkeypatch.setenv("OCTOP_HOME", str(tmp_path / ".octop"))
+ paths = PathLayout.from_env()
+ paths.ensure_root()
+ db = SqlitePool(paths.db)
+ run_migrations(db)
+ owner_id = UserRepo(db).create(username="owner", password_hash="h", role="user")
+ settings = MagicMock()
+ settings.get.side_effect = lambda key, default=None: {
+ "knowledge_feature_enabled": "1",
+ "knowledge_embedding_backend": "onnx",
+ "knowledge_embedding_model": "tiny",
+ }.get(key, default)
+ return SimpleNamespace(
+ knowledge_repo=KnowledgeRepo(db),
+ settings_repo=settings,
+ provider_repo=None,
+ owner_id=owner_id,
+ )
+
+
+def test_create_and_update_text_document(
+ services: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(
+ "octop.infra.knowledge.service.assert_knowledge_usable",
+ lambda *_a, **_k: None,
+ )
+ svc = KnowledgeService(services)
+ base = svc.create_base(owner_user_id=services.owner_id, name="Docs")
+ created = svc.create_text_document(
+ base.id,
+ actor_user_id=services.owner_id,
+ name="notes",
+ format="md",
+ content="# Hello\n",
+ )
+ assert created.filename == "notes.md"
+ assert created.content_type == "text/markdown"
+ assert created.status == "pending"
+ path = document_path(base.id, created.id, created.filename)
+ assert path.read_text(encoding="utf-8") == "# Hello\n"
+
+ updated = svc.update_text_document(
+ base.id,
+ created.id,
+ actor_user_id=services.owner_id,
+ content="# Updated\n",
+ )
+ assert updated.status == "pending"
+ assert path.read_text(encoding="utf-8") == "# Updated\n"
+ raw = svc.read_text_document(base.id, created.id, actor_user_id=services.owner_id)
+ assert raw["text"] == "# Updated\n"
diff --git a/tests/unit/mobile/test_agent_control.py b/tests/unit/mobile/test_agent_control.py
new file mode 100644
index 00000000..73f0ee2d
--- /dev/null
+++ b/tests/unit/mobile/test_agent_control.py
@@ -0,0 +1,40 @@
+"""Unit tests for mobile agent-control binding."""
+
+from __future__ import annotations
+
+from octop.infra.mobile.agent_control import (
+ clear_mobile_agent_control_if_device,
+ get_mobile_agent_control,
+ set_mobile_agent_control,
+)
+
+
+def setup_function() -> None:
+ set_mobile_agent_control(enabled=False, device=None)
+
+
+def test_enable_requires_device() -> None:
+ try:
+ set_mobile_agent_control(enabled=True, device=None)
+ raise AssertionError("expected ValueError")
+ except ValueError:
+ pass
+ assert get_mobile_agent_control().enabled is False
+
+
+def test_enable_and_disable() -> None:
+ st = set_mobile_agent_control(enabled=True, device="3b678f5c")
+ assert st.enabled is True
+ assert st.device == "3b678f5c"
+ st = set_mobile_agent_control(enabled=False, device="3b678f5c")
+ assert st.enabled is False
+ assert st.device is None
+
+
+def test_clear_when_bound_device_leaves() -> None:
+ set_mobile_agent_control(enabled=True, device="phone")
+ clear_mobile_agent_control_if_device("emulator-5554")
+ assert get_mobile_agent_control().enabled is True
+ clear_mobile_agent_control_if_device("phone")
+ assert get_mobile_agent_control().enabled is False
+ assert get_mobile_agent_control().device is None
diff --git a/tests/unit/mobile/test_config_probe.py b/tests/unit/mobile/test_config_probe.py
new file mode 100644
index 00000000..4d0989dd
--- /dev/null
+++ b/tests/unit/mobile/test_config_probe.py
@@ -0,0 +1,22 @@
+"""tests/unit/mobile/test_config_probe.py"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from octop.config import load_config
+from octop.infra.mobile.config_probe import persist_mobile_probe
+from octop.infra.mobile.probe import MobileProbeResult
+
+
+def test_persist_mobile_probe_merges(tmp_path: Path) -> None:
+ cfg_path = tmp_path / "config.json"
+ cfg_path.write_text(json.dumps({"port": 9000}), encoding="utf-8")
+ result = MobileProbeResult(True, "physical", "", "2026-01-01T00:00:00Z")
+ persist_mobile_probe(cfg_path, result)
+ data = json.loads(cfg_path.read_text(encoding="utf-8"))
+ assert data["port"] == 9000
+ assert data["capabilities"]["mobile"]["backend"] == "physical"
+ cfg = load_config(cfg_path)
+ assert cfg.capabilities.mobile.enabled is True
diff --git a/tests/unit/mobile/test_device_info.py b/tests/unit/mobile/test_device_info.py
new file mode 100644
index 00000000..d5745e7e
--- /dev/null
+++ b/tests/unit/mobile/test_device_info.py
@@ -0,0 +1,51 @@
+"""Unit tests for adb device-info parsing."""
+
+from __future__ import annotations
+
+from octop.infra.mobile.adb import parse_device_info_payload
+
+SAMPLE = """
+model=Pixel 6
+market=Pixel 6
+manufacturer=Google
+release=14
+sdk=34
+size=Physical size: 1080x2400
+density=Physical density: 420
+mem_kb=5730304
+cores=8
+df=114884608 52428800 58232832
+fps=fps=60.000004
+""".strip()
+
+
+def test_parse_device_info_payload() -> None:
+ info = parse_device_info_payload("emulator-5554", SAMPLE)
+ assert info["device"] == "emulator-5554"
+ assert info["model"] == "Pixel 6"
+ assert info["manufacturer"] == "Google"
+ assert info["android_version"] == "14"
+ assert info["sdk"] == 34
+ assert info["width"] == 1080
+ assert info["height"] == 2400
+ assert info["density_dpi"] == 420
+ assert info["refresh_hz"] == 60.0
+ assert info["mem_total_mb"] == 5596
+ assert info["cpu_cores"] == 8
+ assert info["storage_total_gb"] == round(114884608 / (1024 * 1024), 3)
+ assert info["storage_used_gb"] == round(52428800 / (1024 * 1024), 3)
+ assert info["storage_avail_gb"] == round(58232832 / (1024 * 1024), 3)
+
+
+def test_parse_prefers_market_name() -> None:
+ text = "model=M2102J20SG\nmarket=Redmi K40\nmanufacturer=Xiaomi\n"
+ info = parse_device_info_payload("3b678f5c", text)
+ assert info["model"] == "Redmi K40"
+
+
+def test_parse_handles_empty() -> None:
+ info = parse_device_info_payload("x", "")
+ assert info["device"] == "x"
+ assert info["model"] is None
+ assert info["width"] is None
+ assert info["cpu_cores"] is None
diff --git a/tests/unit/mobile/test_find_adb.py b/tests/unit/mobile/test_find_adb.py
new file mode 100644
index 00000000..a64e830f
--- /dev/null
+++ b/tests/unit/mobile/test_find_adb.py
@@ -0,0 +1,73 @@
+"""Unit tests for adb discovery (PATH + SDK env only)."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+from octop.infra.mobile.adb import find_adb
+
+
+def test_find_adb_prefers_path() -> None:
+ with (
+ patch("octop.infra.mobile.adb.shutil.which", return_value="/usr/bin/adb"),
+ patch.dict("os.environ", {"ANDROID_HOME": "/sdk"}, clear=False),
+ ):
+ assert find_adb() == "/usr/bin/adb"
+
+
+def test_find_adb_from_android_home(tmp_path: Path) -> None:
+ tools = tmp_path / "platform-tools"
+ tools.mkdir()
+ adb = tools / ("adb.exe" if os.name == "nt" else "adb")
+ adb.write_text("", encoding="utf-8")
+ with (
+ patch("octop.infra.mobile.adb.shutil.which", return_value=None),
+ patch.dict(
+ "os.environ",
+ {"ANDROID_HOME": str(tmp_path), "ANDROID_SDK_ROOT": ""},
+ clear=False,
+ ),
+ ):
+ assert find_adb() == str(adb)
+
+
+def test_find_adb_from_android_sdk_root(tmp_path: Path) -> None:
+ tools = tmp_path / "platform-tools"
+ tools.mkdir()
+ adb = tools / ("adb.exe" if os.name == "nt" else "adb")
+ adb.write_text("", encoding="utf-8")
+ with (
+ patch("octop.infra.mobile.adb.shutil.which", return_value=None),
+ patch.dict(
+ "os.environ",
+ {"ANDROID_HOME": "", "ANDROID_SDK_ROOT": str(tmp_path)},
+ clear=False,
+ ),
+ ):
+ assert find_adb() == str(adb)
+
+
+def test_find_adb_missing_returns_none() -> None:
+ with (
+ patch("octop.infra.mobile.adb.shutil.which", return_value=None),
+ patch.dict(
+ "os.environ",
+ {"ANDROID_HOME": "", "ANDROID_SDK_ROOT": ""},
+ clear=False,
+ ),
+ ):
+ assert find_adb() is None
+
+
+def test_find_adb_ignores_empty_env_roots() -> None:
+ with (
+ patch("octop.infra.mobile.adb.shutil.which", return_value=None),
+ patch.dict(
+ "os.environ",
+ {"ANDROID_HOME": " ", "ANDROID_SDK_ROOT": ""},
+ clear=False,
+ ),
+ ):
+ assert find_adb() is None
diff --git a/tests/unit/mobile/test_h264.py b/tests/unit/mobile/test_h264.py
new file mode 100644
index 00000000..908d6d68
--- /dev/null
+++ b/tests/unit/mobile/test_h264.py
@@ -0,0 +1,86 @@
+"""Unit tests for mobile capture / H.264 helpers."""
+
+from __future__ import annotations
+
+from octop.infra.mobile.adb import extract_png, extract_raw_rgba, screenrecord_h264_args
+from octop.infra.mobile.h264 import (
+ NAL_IDR,
+ NAL_PPS,
+ NAL_SPS,
+ AnnexBSplitter,
+ avc_codec_string,
+ avcc_from_sps_pps,
+ avcc_sample,
+ nal_type,
+)
+
+
+def test_extract_png_strips_screencap_warning() -> None:
+ png = b"\x89PNG\r\n\x1a\n" + b"rest"
+ blob = b"[Warning] Multiple displays were found\n" + png
+ assert extract_png(blob) == png
+ assert extract_png(png) == png
+ assert extract_png(b"[Warning] only") is None
+
+
+def test_extract_raw_rgba_skips_warning_prefix() -> None:
+ width, height = 32, 16
+ header = (
+ width.to_bytes(4, "little")
+ + height.to_bytes(4, "little")
+ + (1).to_bytes(4, "little")
+ + (0).to_bytes(4, "little")
+ )
+ body = bytes([i % 256 for i in range(width * height * 4)])
+ blob = b"[Warning] Multiple displays\n" + header + body
+ got = extract_raw_rgba(blob)
+ assert got == (width, height, body)
+
+
+def test_annexb_splitter_handles_prefix_and_mixed_start_codes() -> None:
+ sps = bytes([0x67, 0x42, 0xC0, 0x32, 0x0A])
+ pps = bytes([0x68, 0xCE, 0x01, 0xA8])
+ idr = bytes([0x65, 0x88, 0x80])
+ blob = (
+ b"[Warning] ignore me"
+ + b"\x00\x00\x00\x01"
+ + sps
+ + b"\x00\x00\x01"
+ + pps
+ + b"\x00\x00\x00\x01"
+ + idr
+ + b"\x00\x00\x00\x01"
+ )
+ splitter = AnnexBSplitter()
+ nals = splitter.feed(blob[:20]) + splitter.feed(blob[20:])
+ assert nals == [sps, pps, idr]
+ assert nal_type(sps) == NAL_SPS
+ assert nal_type(pps) == NAL_PPS
+ assert nal_type(idr) == NAL_IDR
+
+
+def test_avcc_and_codec_string() -> None:
+ sps = bytes([0x67, 0x42, 0xC0, 0x32, 0x0A])
+ pps = bytes([0x68, 0xCE, 0x01, 0xA8])
+ assert avc_codec_string(sps) == "avc1.42C032"
+ avcc = avcc_from_sps_pps(sps, pps)
+ assert avcc[0] == 1
+ assert avcc[1:4] == sps[1:4]
+ idr = bytes([0x65, 0x00])
+ sample = avcc_sample([idr])
+ assert sample[:4] == (2).to_bytes(4, "big")
+ assert sample[4:] == idr
+
+
+def test_screenrecord_args_include_display_id() -> None:
+ cmd = screenrecord_h264_args(
+ "emulator-5554", adb="/usr/bin/adb", display_id="42", bit_rate=1000
+ )
+ assert cmd[:4] == ["/usr/bin/adb", "-s", "emulator-5554", "exec-out"]
+ assert "--output-format=h264" in cmd
+ assert "--time-limit=0" in cmd
+ assert "--size" in cmd
+ assert "720x1600" in cmd
+ assert "--display-id" in cmd
+ assert "42" in cmd
+ assert cmd[-1] == "-"
diff --git a/tests/unit/mobile/test_mobile_setup.py b/tests/unit/mobile/test_mobile_setup.py
new file mode 100644
index 00000000..294ee29a
--- /dev/null
+++ b/tests/unit/mobile/test_mobile_setup.py
@@ -0,0 +1,68 @@
+"""tests/unit/mobile/test_mobile_setup.py"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from octop.config import CapabilitiesConfig, MobileCapabilities, OctopConfig
+from octop.infra.mobile.setup import mobile_status
+
+
+def _cfg(**mobile: object) -> OctopConfig:
+ return OctopConfig(
+ capabilities=CapabilitiesConfig(
+ mobile=MobileCapabilities(**mobile) # type: ignore[arg-type]
+ )
+ )
+
+
+def test_mobile_status_disabled() -> None:
+ status = mobile_status(_cfg(enabled=False, backend="none"))
+ assert status.mobile_supported is False
+ assert status.setup_state == "unsupported"
+
+
+def test_mobile_status_physical_ready() -> None:
+ with (
+ patch("octop.infra.mobile.setup.find_adb", return_value="/adb"),
+ patch("octop.infra.mobile.setup.list_devices", return_value=["emulator-5554"]),
+ ):
+ status = mobile_status(_cfg(enabled=True, backend="physical"))
+ assert status.setup_state == "ready"
+ assert status.selected_device == "emulator-5554"
+
+
+def test_mobile_status_physical_needs_device() -> None:
+ with (
+ patch("octop.infra.mobile.setup.find_adb", return_value="/adb"),
+ patch("octop.infra.mobile.setup.list_devices", return_value=[]),
+ ):
+ status = mobile_status(_cfg(enabled=True, backend="physical"))
+ assert status.setup_state == "needs_device"
+
+
+def test_mobile_status_container_reconnects_adb() -> None:
+ with (
+ patch("octop.infra.mobile.setup.find_adb", return_value="/adb"),
+ patch("octop.infra.mobile.setup._container_running", return_value=True),
+ patch(
+ "octop.infra.mobile.setup.list_devices",
+ side_effect=[[], ["127.0.0.1:5555"]],
+ ),
+ patch("octop.infra.mobile.setup.adb_connect", return_value=True) as connect,
+ ):
+ status = mobile_status(_cfg(enabled=True, backend="redroid"))
+ connect.assert_called_once_with("127.0.0.1:5555", adb="/adb")
+ assert status.setup_state == "ready"
+ assert status.selected_device == "127.0.0.1:5555"
+
+
+def test_mobile_status_container_missing_adb_binary() -> None:
+ with (
+ patch("octop.infra.mobile.setup.find_adb", return_value=None),
+ patch("octop.infra.mobile.setup._container_running", return_value=True),
+ patch("octop.infra.mobile.setup.list_devices", return_value=[]),
+ ):
+ status = mobile_status(_cfg(enabled=True, backend="redroid"), locale="en")
+ assert status.setup_state == "needs_device"
+ assert "adb was not found" in status.reason
diff --git a/tests/unit/mobile/test_mobile_tools.py b/tests/unit/mobile/test_mobile_tools.py
new file mode 100644
index 00000000..a827dade
--- /dev/null
+++ b/tests/unit/mobile/test_mobile_tools.py
@@ -0,0 +1,115 @@
+"""Unit tests for built-in mobile LangChain tools."""
+
+from __future__ import annotations
+
+import json
+from contextlib import contextmanager
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from langgraph.config import var_child_runnable_config
+
+from octop.config import CapabilitiesConfig, MobileCapabilities, OctopConfig
+from octop.infra.mobile.tools import build_mobile_tools
+
+
+@contextmanager
+def _configurable(**kwargs: object):
+ token = var_child_runnable_config.set({"configurable": kwargs})
+ try:
+ yield
+ finally:
+ var_child_runnable_config.reset(token)
+
+
+def _enabled_config() -> OctopConfig:
+ return OctopConfig(
+ capabilities=CapabilitiesConfig(
+ mobile=MobileCapabilities(
+ enabled=True,
+ backend="physical",
+ probed_at="2026-01-01T00:00:00Z",
+ )
+ )
+ )
+
+
+def _tool_by_name(tools: list, name: str):
+ for tool in tools:
+ if tool.name == name:
+ return tool
+ raise KeyError(name)
+
+
+def test_build_mobile_tools_empty_when_disabled() -> None:
+ cfg = OctopConfig()
+ tools = build_mobile_tools(cfg, user_repo=MagicMock())
+ assert tools == []
+
+
+def test_build_mobile_tools_registers_when_adb_present() -> None:
+ user_repo = MagicMock()
+ with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"):
+ tools = build_mobile_tools(_enabled_config(), user_repo=user_repo)
+ names = {t.name for t in tools}
+ assert "mobile_screenshot" in names
+ assert "mobile_tap" in names
+ assert "mobile_handoff_to_user" in names
+
+
+@pytest.mark.asyncio
+async def test_mobile_tap_requires_permission() -> None:
+ user_repo = MagicMock()
+ user_repo.get.return_value = SimpleNamespace(is_admin=False, permissions=["browser"])
+ with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"):
+ tools = build_mobile_tools(_enabled_config(), user_repo=user_repo)
+ tap_tool = _tool_by_name(tools, "mobile_tap")
+ with (
+ _configurable(user="1", user_is_admin=False, locale="en"),
+ patch("octop.infra.mobile.tools.mobile_status") as status,
+ ):
+ status.return_value = MagicMock(setup_state="ready", ok=True)
+ out = await tap_tool.ainvoke({"x": 10, "y": 20})
+ data = json.loads(out)
+ assert "error" in data
+ assert "permission" in data["error"]
+
+
+@pytest.mark.asyncio
+async def test_mobile_tap_success() -> None:
+ from octop.infra.mobile.agent_control import set_mobile_agent_control
+
+ set_mobile_agent_control(enabled=True, device="emulator-5554")
+ user_repo = MagicMock()
+ user_repo.get.return_value = SimpleNamespace(is_admin=False, permissions=["mobile"])
+ with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"):
+ tools = build_mobile_tools(_enabled_config(), user_repo=user_repo)
+ tap_tool = _tool_by_name(tools, "mobile_tap")
+ try:
+ with (
+ _configurable(user="1", user_is_admin=False, locale="en", agent_id="agent1"),
+ patch("octop.infra.mobile.tools.mobile_status") as status,
+ patch("octop.infra.mobile.tools.list_devices", return_value=["emulator-5554"]),
+ patch("octop.infra.mobile.tools.tap", return_value=True) as tap_fn,
+ ):
+ status.return_value = MagicMock(setup_state="ready", ok=True)
+ out = await tap_tool.ainvoke({"x": 100, "y": 200})
+ data = json.loads(out)
+ assert data["ok"] is True
+ tap_fn.assert_called_once()
+ finally:
+ set_mobile_agent_control(enabled=False, device=None)
+
+
+@pytest.mark.asyncio
+async def test_mobile_handoff_admin_bypass() -> None:
+ user_repo = MagicMock()
+ with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"):
+ tools = build_mobile_tools(_enabled_config(), user_repo=user_repo)
+ handoff = _tool_by_name(tools, "mobile_handoff_to_user")
+ with _configurable(user="1", user_is_admin=True, locale="en"):
+ out = await handoff.ainvoke({"reason": "login captcha"})
+ data = json.loads(out)
+ assert data["handoff"] is True
+ assert "login captcha" in data["message"]
diff --git a/tests/unit/mobile/test_probe.py b/tests/unit/mobile/test_probe.py
new file mode 100644
index 00000000..d0223d69
--- /dev/null
+++ b/tests/unit/mobile/test_probe.py
@@ -0,0 +1,37 @@
+"""tests/unit/mobile/test_probe.py"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from octop.infra.mobile.probe import probe_host_capability
+
+
+@pytest.mark.parametrize(
+ ("system", "binder", "kvm", "enabled", "backend"),
+ [
+ ("Darwin", False, False, True, "physical"),
+ ("Windows", False, False, True, "physical"),
+ ("Linux", True, False, True, "redroid"),
+ ("Linux", False, True, True, "emulator"),
+ ("Linux", False, False, False, "none"),
+ ],
+)
+def test_probe_host_matrix(
+ system: str,
+ binder: bool,
+ kvm: bool,
+ enabled: bool,
+ backend: str,
+) -> None:
+ with (
+ patch("octop.infra.mobile.probe.platform.system", return_value=system),
+ patch("octop.infra.mobile.probe.linux_binder_available", return_value=binder),
+ patch("octop.infra.mobile.probe.kvm_available", return_value=kvm),
+ ):
+ result = probe_host_capability(probed_at="2026-01-01T00:00:00Z")
+ assert result.enabled is enabled
+ assert result.backend == backend
+ assert result.probed_at == "2026-01-01T00:00:00Z"
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index cb70b158..2e7c5b79 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -318,3 +318,24 @@ def test_new_timezone_env_wins_over_legacy_env(tmp_path: Path, monkeypatch: pyte
monkeypatch.setenv("OCTOP_CRON_TIMEZONE", "Asia/Tokyo")
cfg = load_config(tmp_path / "config.json")
assert cfg.default_timezone == "UTC"
+
+
+def test_loads_mobile_capabilities(tmp_path: Path) -> None:
+ cfg_path = tmp_path / "config.json"
+ cfg_path.write_text(
+ json.dumps(
+ {
+ "capabilities": {
+ "mobile": {
+ "enabled": True,
+ "backend": "physical",
+ "probed_at": "2026-01-01T00:00:00Z",
+ "reason": "",
+ }
+ }
+ }
+ )
+ )
+ cfg = load_config(cfg_path)
+ assert cfg.capabilities.mobile.enabled is True
+ assert cfg.capabilities.mobile.backend == "physical"
diff --git a/tests/unit/test_user_manager.py b/tests/unit/test_user_manager.py
index 7b94cbf8..bf4f2b44 100644
--- a/tests/unit/test_user_manager.py
+++ b/tests/unit/test_user_manager.py
@@ -58,6 +58,59 @@ async def test_authenticate_success(manager: UserManager):
assert user.username == "a"
+async def test_authenticate_by_email(manager: UserManager):
+ await manager.create(
+ username="alice",
+ password="TestPass12",
+ role=Role.USER,
+ email="Alice@Example.com",
+ )
+ user = await manager.authenticate("alice@example.com", "TestPass12")
+ assert user is not None
+ assert user.username == "alice"
+
+
+async def test_authenticate_prefers_username_over_email(manager: UserManager):
+ await manager.create(
+ username="bob@x.com",
+ password="TestPass12",
+ role=Role.USER,
+ email="other@x.com",
+ )
+ await manager.create(
+ username="carol",
+ password="TestPass34",
+ role=Role.USER,
+ email="bob@x.com",
+ )
+ user = await manager.authenticate("bob@x.com", "TestPass12")
+ assert user is not None
+ assert user.username == "bob@x.com"
+
+
+async def test_create_duplicate_email_rejected(manager: UserManager):
+ await manager.create(
+ username="a", password="TestPass12", role=Role.USER, email="dup@example.com"
+ )
+ with pytest.raises(OctopError) as ei:
+ await manager.create(
+ username="b", password="TestPass34", role=Role.USER, email="DUP@example.com"
+ )
+ assert ei.value.code is ErrorCode.EMAIL_TAKEN
+
+
+async def test_set_email_and_clear(manager: UserManager):
+ user = await manager.create(username="a", password="TestPass12", role=Role.USER)
+ await manager.set_email("a", "a@example.com")
+ row = manager.get_row(user.id)
+ assert row is not None
+ assert row.email == "a@example.com"
+ await manager.set_email("a", None)
+ row = manager.get_row(user.id)
+ assert row is not None
+ assert row.email is None
+
+
async def test_authenticate_wrong_password(manager: UserManager):
await manager.create(username="a", password="TestPass12", role=Role.USER)
assert await manager.authenticate("a", "bad") is None
diff --git a/tests/unit/users/test_email.py b/tests/unit/users/test_email.py
new file mode 100644
index 00000000..d897d6de
--- /dev/null
+++ b/tests/unit/users/test_email.py
@@ -0,0 +1,31 @@
+"""Tests for email normalize / validate helpers."""
+
+from __future__ import annotations
+
+import pytest
+
+from octop.infra.errors import ErrorCode, OctopError
+from octop.infra.users.email import normalize_email, parse_optional_email, validate_email_format
+
+
+def test_normalize_email() -> None:
+ assert normalize_email(None) is None
+ assert normalize_email("") is None
+ assert normalize_email(" ") is None
+ assert normalize_email("Alice@Example.COM") == "alice@example.com"
+
+
+def test_validate_email_format() -> None:
+ validate_email_format("a@b.co")
+ with pytest.raises(OctopError) as exc:
+ validate_email_format("not-an-email")
+ assert exc.value.code is ErrorCode.EMAIL_INVALID
+
+
+def test_parse_optional_email() -> None:
+ assert parse_optional_email(None) is None
+ assert parse_optional_email(" ") is None
+ assert parse_optional_email("Bob@Host.IO") == "bob@host.io"
+ with pytest.raises(OctopError) as exc:
+ parse_optional_email("bad")
+ assert exc.value.code is ErrorCode.EMAIL_INVALID
diff --git a/tests/unit/users/test_invites.py b/tests/unit/users/test_invites.py
index 16b6e949..5f581784 100644
--- a/tests/unit/users/test_invites.py
+++ b/tests/unit/users/test_invites.py
@@ -30,7 +30,7 @@ def test_generate_invite_code_shape() -> None:
code = generate_invite_code()
assert len(code) == 11
assert code.isalnum()
- assert invite_path(code) == f"/?invite={code}"
+ assert invite_path(code) == f"/invite?code={code}"
def test_invite_repo_redeem_once(db: SqlitePool) -> None:
diff --git a/tests/unit/users/test_permissions.py b/tests/unit/users/test_permissions.py
index 347cc5d5..d44c12f4 100644
--- a/tests/unit/users/test_permissions.py
+++ b/tests/unit/users/test_permissions.py
@@ -65,6 +65,7 @@ def test_categories_match_nav_groups() -> None:
"terminal",
"browser",
"desktop",
+ "mobile",
}
assert PERMISSIONS["envs"].page == "advanced"
assert PERMISSIONS["knowledge_settings"].page == "advanced"
diff --git a/uv.lock b/uv.lock
index 902f0d96..818a15f8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2465,7 +2465,7 @@ wheels = [
[[package]]
name = "octop"
-version = "0.9.24"
+version = "0.9.26"
source = { editable = "." }
dependencies = [
{ name = "acme" },
@@ -2559,7 +2559,7 @@ requires-dist = [
{ name = "mcp", specifier = ">=1.9,<2" },
{ name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
- { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.23" },
+ { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.24" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "playwright", specifier = ">=1.40" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },
@@ -2731,7 +2731,7 @@ wheels = [
[[package]]
name = "orcakit-harness-agent"
-version = "0.9.23"
+version = "0.9.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deepagents" },
@@ -2749,9 +2749,9 @@ dependencies = [
{ name = "mcp" },
{ name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2c/3c/482ad9fe0cf33e8937105dee13c1d406d105fa4bfe5a7aa36b145d9ace7f/orcakit_harness_agent-0.9.23.tar.gz", hash = "sha256:f8cfbc45def2c032b8ec5360a002f238d92cf6f9f2d6d6512b83166c83a84823", size = 1182937, upload-time = "2026-08-21T07:58:36.019Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/3a/05421451c77788cb4b560bb15489fab67bbe4b51eb4430e765812e755d14/orcakit_harness_agent-0.9.24.tar.gz", hash = "sha256:4638f399678b2989656bf6dfaf38887b802b69f0c9179583725c939b4aafe367", size = 1182523, upload-time = "2026-08-22T12:56:24.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2b/41/fd6e358d007c4f47a16b7895f6158fa6329d569a8d00dbc9c5608c1b917e/orcakit_harness_agent-0.9.23-py3-none-any.whl", hash = "sha256:b774c76d9dfd38a9379db4b7a940f5deae0f55f109295ef15bb000a79591ceda", size = 1406361, upload-time = "2026-08-21T07:58:32.796Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0d/d23d52b4f5175d179f2713647c6df306eefa78de095a5f14a7a3e5ae0a82/orcakit_harness_agent-0.9.24-py3-none-any.whl", hash = "sha256:10d6ad02116dfcd2b2232b3b92bb7a9f5678fe0f98fa0265597cf3e26e1dbcbc", size = 1406596, upload-time = "2026-08-22T12:56:20.497Z" },
]
[package.optional-dependencies]