Merge remote-tracking branch 'origin/main' into wsh_dev
# Conflicts: # backend/api/server.py
This commit is contained in:
+157
-2
@@ -13,6 +13,7 @@ import hashlib
|
||||
import shlex
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
@@ -76,6 +77,9 @@ from src.session_store import (
|
||||
)
|
||||
from src.token_budget import calculate_token_budget
|
||||
|
||||
LINUX_ACCOUNT_WORKSPACE_NAME = 'zk-agent'
|
||||
LINUX_ACCOUNT_ENV = 'CLAW_ENABLE_LINUX_ACCOUNTS'
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
||||
API_TOOL_CONTENT_MAX_CHARS = 20000
|
||||
@@ -638,6 +642,8 @@ class AgentState:
|
||||
if not account_id:
|
||||
return self.session_directory.parent
|
||||
safe_id = _safe_account_id(account_id)
|
||||
if _linux_accounts_enabled():
|
||||
return _linux_account_workspace(safe_id).resolve()
|
||||
return (self.session_directory.parent / 'accounts' / safe_id).resolve()
|
||||
|
||||
def account_paths(self, account_id: str | None) -> dict[str, Path]:
|
||||
@@ -837,7 +843,15 @@ class AgentState:
|
||||
for directory in paths.values():
|
||||
if directory.name != '.venv':
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_python_env(paths['python_env'])
|
||||
runtime_user = (
|
||||
_safe_account_id(account_id)
|
||||
if account_id and _linux_accounts_enabled()
|
||||
else None
|
||||
)
|
||||
if runtime_user:
|
||||
_ensure_linux_user_exists(runtime_user)
|
||||
_chown_path_for_linux_user(paths['base'], runtime_user, recursive=True)
|
||||
self._ensure_python_env(paths['python_env'], account_id)
|
||||
permissions = AgentPermissions(
|
||||
allow_file_write=config.allow_write,
|
||||
allow_shell_commands=config.allow_shell,
|
||||
@@ -849,6 +863,7 @@ class AgentState:
|
||||
session_directory=paths['sessions'],
|
||||
scratchpad_root=paths['scratchpad'],
|
||||
python_env_dir=paths['python_env'],
|
||||
runtime_user=runtime_user,
|
||||
enabled_skill_names=self.enabled_skill_names(account_id),
|
||||
auto_compact_threshold_tokens=180_000,
|
||||
)
|
||||
@@ -949,12 +964,37 @@ class AgentState:
|
||||
def lock(self) -> Lock:
|
||||
return self._lock
|
||||
|
||||
def _ensure_python_env(self, env_dir: Path) -> None:
|
||||
def _ensure_python_env(self, env_dir: Path, account_id: str | None = None) -> None:
|
||||
python_bin = env_dir / 'bin' / 'python'
|
||||
pip_bin = env_dir / 'bin' / 'pip'
|
||||
if python_bin.exists() and pip_bin.exists():
|
||||
return
|
||||
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
runtime_user = _safe_account_id(account_id) if account_id and _linux_accounts_enabled() else None
|
||||
if runtime_user:
|
||||
_ensure_linux_user_exists(runtime_user)
|
||||
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
_chown_path_for_linux_user(env_dir.parent, runtime_user, recursive=True)
|
||||
runtime_python = os.environ.get('CLAW_RUNTIME_PYTHON_BIN') or shutil.which('python3')
|
||||
if not runtime_python:
|
||||
raise RuntimeError('Linux runtime requires python3 or CLAW_RUNTIME_PYTHON_BIN')
|
||||
subprocess.run(
|
||||
[
|
||||
'runuser',
|
||||
'-u',
|
||||
runtime_user,
|
||||
'--',
|
||||
runtime_python,
|
||||
'-m',
|
||||
'venv',
|
||||
str(env_dir),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return
|
||||
venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir)
|
||||
|
||||
|
||||
@@ -4538,6 +4578,107 @@ def _account_id_from_email(email: str | None) -> str | None:
|
||||
return prefix
|
||||
|
||||
|
||||
def _linux_accounts_enabled() -> bool:
|
||||
return os.environ.get(LINUX_ACCOUNT_ENV, '').strip().lower() in {
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on',
|
||||
}
|
||||
|
||||
|
||||
def _linux_account_workspace(account_id: str) -> Path:
|
||||
return Path('/home') / account_id / LINUX_ACCOUNT_WORKSPACE_NAME
|
||||
|
||||
|
||||
def _validate_linux_username(account_id: str) -> None:
|
||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]{1,31}', account_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='启用 Linux 账号时,账号名必须以小写字母或下划线开头,只包含小写字母、数字、下划线或中划线,长度 2-32',
|
||||
)
|
||||
|
||||
|
||||
def _ensure_linux_account(account_id: str, password: str) -> None:
|
||||
_validate_linux_username(account_id)
|
||||
if os.geteuid() != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail='CLAW_ENABLE_LINUX_ACCOUNTS=1 需要后端以 root 身份运行',
|
||||
)
|
||||
try:
|
||||
pwd.getpwnam(account_id)
|
||||
except KeyError:
|
||||
subprocess.run(
|
||||
[
|
||||
'useradd',
|
||||
'-m',
|
||||
'-d',
|
||||
f'/home/{account_id}',
|
||||
'-s',
|
||||
'/bin/bash',
|
||||
account_id,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
chpasswd = subprocess.run(
|
||||
['chpasswd'],
|
||||
input=f'{account_id}:{password}\n',
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if chpasswd.returncode != 0:
|
||||
detail = (chpasswd.stderr or chpasswd.stdout or '').strip()
|
||||
raise HTTPException(status_code=500, detail=f'同步 Linux 用户密码失败:{detail}')
|
||||
workspace = _linux_account_workspace(account_id)
|
||||
for child in (
|
||||
workspace / 'sessions',
|
||||
workspace / 'python',
|
||||
workspace / 'memory',
|
||||
workspace / 'integrations',
|
||||
):
|
||||
child.mkdir(parents=True, exist_ok=True)
|
||||
_chown_path_for_linux_user(workspace, account_id, recursive=True)
|
||||
|
||||
|
||||
def _ensure_linux_user_exists(account_id: str) -> None:
|
||||
try:
|
||||
pwd.getpwnam(account_id)
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f'Linux runtime user does not exist: {account_id}. '
|
||||
'Create the account through the platform first, or disable CLAW_ENABLE_LINUX_ACCOUNTS.'
|
||||
) from exc
|
||||
|
||||
|
||||
def _chown_path_for_linux_user(path: Path, account_id: str, *, recursive: bool = False) -> None:
|
||||
if os.name != 'posix':
|
||||
return
|
||||
try:
|
||||
user_info = pwd.getpwnam(account_id)
|
||||
except KeyError:
|
||||
return
|
||||
target = path.resolve(strict=False)
|
||||
try:
|
||||
os.chown(target, user_info.pw_uid, user_info.pw_gid)
|
||||
except PermissionError:
|
||||
return
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not recursive or not target.is_dir():
|
||||
return
|
||||
for child in target.rglob('*'):
|
||||
try:
|
||||
os.chown(child, user_info.pw_uid, user_info.pw_gid)
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
|
||||
def _admin_token() -> str:
|
||||
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
|
||||
|
||||
@@ -4686,6 +4827,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
safe_id = _safe_account_id(account_id)
|
||||
if safe_id == 'default':
|
||||
raise HTTPException(status_code=400, detail='账号名不合法')
|
||||
if _linux_accounts_enabled():
|
||||
_validate_linux_username(safe_id)
|
||||
users = _load_users_file(state)
|
||||
user_rows = users.setdefault('users', [])
|
||||
if any(
|
||||
@@ -4694,6 +4837,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
for item in user_rows
|
||||
):
|
||||
raise HTTPException(status_code=400, detail='账号已存在')
|
||||
if _linux_accounts_enabled():
|
||||
_ensure_linux_account(safe_id, '123456')
|
||||
user_rows.append(
|
||||
{
|
||||
'id': safe_id,
|
||||
@@ -4737,8 +4882,18 @@ def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
encoding='utf-8',
|
||||
)
|
||||
base = _accounts_root(state) / safe_id
|
||||
if _linux_accounts_enabled():
|
||||
base = _linux_account_workspace(safe_id)
|
||||
if base.exists():
|
||||
shutil.rmtree(base)
|
||||
if _linux_accounts_enabled():
|
||||
subprocess.run(
|
||||
['passwd', '-l', safe_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
state._clear_agents_for_account(safe_id)
|
||||
return {'account_id': safe_id, 'deleted': True}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user