Add Linux account workspace runtime
This commit is contained in:
@@ -583,6 +583,22 @@ bash "$HOME/zk-data-agent/scripts/install-from-git.sh"
|
|||||||
- 安装前端依赖并构建。
|
- 安装前端依赖并构建。
|
||||||
- 安装并启动用户级 systemd 服务。
|
- 安装并启动用户级 systemd 服务。
|
||||||
|
|
||||||
|
如果要启用“平台账号 = Linux 用户”的托管工作区,需要使用 root/systemd system 服务部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$HOME/zk-data-agent"
|
||||||
|
sudo -E env PATH="$PATH" bash scripts/deploy-ubuntu.sh main --system-service --enable-linux-accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
启用后:
|
||||||
|
|
||||||
|
- 平台注册/登录账号时会同步创建同名 Linux 用户。
|
||||||
|
- 平台密码会同步设置为 Linux 用户密码。
|
||||||
|
- Linux 用户允许 SSH 登录。
|
||||||
|
- 本机托管工作区会使用 `/home/<account_id>/zk-agent/`。
|
||||||
|
- `python_exec`、`python_package`、`bash` 会在对应 Linux 用户身份下执行。
|
||||||
|
- Jupyter 远程工作区保持现有逻辑,不参与本机 Linux 用户隔离。
|
||||||
|
|
||||||
`.env.deploy` 会保存:
|
`.env.deploy` 会保存:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -595,6 +611,8 @@ CLAW_BACKEND_PORT
|
|||||||
CLAW_FRONTEND_HOST
|
CLAW_FRONTEND_HOST
|
||||||
CLAW_FRONTEND_PORT
|
CLAW_FRONTEND_PORT
|
||||||
CLAW_API_URL
|
CLAW_API_URL
|
||||||
|
CLAW_SERVICE_SCOPE
|
||||||
|
CLAW_ENABLE_LINUX_ACCOUNTS
|
||||||
CLAW_NPM_BIN
|
CLAW_NPM_BIN
|
||||||
CLAW_NPX_BIN
|
CLAW_NPX_BIN
|
||||||
CLAW_NODE_BIN
|
CLAW_NODE_BIN
|
||||||
@@ -618,6 +636,12 @@ bash scripts/update-server-fast.sh
|
|||||||
bash scripts/deploy-ubuntu.sh
|
bash scripts/deploy-ubuntu.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
root/system 服务更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -E env PATH="$PATH" bash scripts/deploy-ubuntu.sh main --system-service --enable-linux-accounts
|
||||||
|
```
|
||||||
|
|
||||||
部署指定分支:
|
部署指定分支:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -632,7 +656,7 @@ bash scripts/deploy-ubuntu.sh main --force
|
|||||||
|
|
||||||
### 服务管理
|
### 服务管理
|
||||||
|
|
||||||
部署脚本会安装两个用户级 systemd 服务:
|
部署脚本默认安装用户级 systemd 服务;以 root 执行或指定 `--system-service` 时安装 system 级服务。服务名默认是:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
zk-data-agent-backend
|
zk-data-agent-backend
|
||||||
@@ -646,6 +670,13 @@ systemctl --user status zk-data-agent-backend
|
|||||||
systemctl --user status zk-data-agent-frontend
|
systemctl --user status zk-data-agent-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
system 级服务使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status zk-data-agent-backend
|
||||||
|
systemctl status zk-data-agent-frontend
|
||||||
|
```
|
||||||
|
|
||||||
查看日志:
|
查看日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -653,12 +684,25 @@ journalctl --user -u zk-data-agent-backend -f
|
|||||||
journalctl --user -u zk-data-agent-frontend -f
|
journalctl --user -u zk-data-agent-frontend -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
system 级日志使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u zk-data-agent-backend -f
|
||||||
|
journalctl -u zk-data-agent-frontend -f
|
||||||
|
```
|
||||||
|
|
||||||
重启服务:
|
重启服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user restart zk-data-agent-backend zk-data-agent-frontend
|
systemctl --user restart zk-data-agent-backend zk-data-agent-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
system 级重启使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl restart zk-data-agent-backend zk-data-agent-frontend
|
||||||
|
```
|
||||||
|
|
||||||
停止服务:
|
停止服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -683,6 +727,7 @@ Ubuntu 部署建议:
|
|||||||
- Python `3.10.14`
|
- Python `3.10.14`
|
||||||
- Node.js `20` 或 `22`
|
- Node.js `20` 或 `22`
|
||||||
- `npm`
|
- `npm`
|
||||||
|
- 启用 Linux 账号工作区时,还需要 `passwd`、`python3`、`python3-venv`,并要求服务以 root/systemd system 方式运行。
|
||||||
|
|
||||||
首次安装如果缺 Python 编译依赖,可以执行:
|
首次安装如果缺 Python 编译依赖,可以执行:
|
||||||
|
|
||||||
@@ -690,7 +735,7 @@ Ubuntu 部署建议:
|
|||||||
bash scripts/deploy-ubuntu.sh --bootstrap-system
|
bash scripts/deploy-ubuntu.sh --bootstrap-system
|
||||||
```
|
```
|
||||||
|
|
||||||
`--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。
|
`--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;普通模式下应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。启用 `--enable-linux-accounts` 后,账号工作区位于 `/home/<account_id>/zk-agent/`。
|
||||||
|
|
||||||
## 开发验证
|
## 开发验证
|
||||||
|
|
||||||
|
|||||||
+157
-2
@@ -11,6 +11,7 @@ import csv
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -67,6 +68,9 @@ from src.session_store import (
|
|||||||
)
|
)
|
||||||
from src.token_budget import calculate_token_budget
|
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'
|
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
||||||
API_TOOL_CONTENT_MAX_CHARS = 20000
|
API_TOOL_CONTENT_MAX_CHARS = 20000
|
||||||
@@ -626,6 +630,8 @@ class AgentState:
|
|||||||
if not account_id:
|
if not account_id:
|
||||||
return self.session_directory.parent
|
return self.session_directory.parent
|
||||||
safe_id = _safe_account_id(account_id)
|
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()
|
return (self.session_directory.parent / 'accounts' / safe_id).resolve()
|
||||||
|
|
||||||
def account_paths(self, account_id: str | None) -> dict[str, Path]:
|
def account_paths(self, account_id: str | None) -> dict[str, Path]:
|
||||||
@@ -825,7 +831,15 @@ class AgentState:
|
|||||||
for directory in paths.values():
|
for directory in paths.values():
|
||||||
if directory.name != '.venv':
|
if directory.name != '.venv':
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
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(
|
permissions = AgentPermissions(
|
||||||
allow_file_write=config.allow_write,
|
allow_file_write=config.allow_write,
|
||||||
allow_shell_commands=config.allow_shell,
|
allow_shell_commands=config.allow_shell,
|
||||||
@@ -837,6 +851,7 @@ class AgentState:
|
|||||||
session_directory=paths['sessions'],
|
session_directory=paths['sessions'],
|
||||||
scratchpad_root=paths['scratchpad'],
|
scratchpad_root=paths['scratchpad'],
|
||||||
python_env_dir=paths['python_env'],
|
python_env_dir=paths['python_env'],
|
||||||
|
runtime_user=runtime_user,
|
||||||
enabled_skill_names=self.enabled_skill_names(account_id),
|
enabled_skill_names=self.enabled_skill_names(account_id),
|
||||||
)
|
)
|
||||||
model_config = ModelConfig(
|
model_config = ModelConfig(
|
||||||
@@ -936,12 +951,37 @@ class AgentState:
|
|||||||
def lock(self) -> Lock:
|
def lock(self) -> Lock:
|
||||||
return 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'
|
python_bin = env_dir / 'bin' / 'python'
|
||||||
pip_bin = env_dir / 'bin' / 'pip'
|
pip_bin = env_dir / 'bin' / 'pip'
|
||||||
if python_bin.exists() and pip_bin.exists():
|
if python_bin.exists() and pip_bin.exists():
|
||||||
return
|
return
|
||||||
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
venv.EnvBuilder(with_pip=True, symlinks=False).create(env_dir)
|
||||||
|
|
||||||
|
|
||||||
@@ -3785,6 +3825,107 @@ def _safe_account_id(account_id: str | None) -> str:
|
|||||||
return normalized[:80] or 'default'
|
return normalized[:80] or 'default'
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _admin_token() -> str:
|
||||||
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
|
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
|
||||||
|
|
||||||
@@ -3933,6 +4074,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
|||||||
safe_id = _safe_account_id(account_id)
|
safe_id = _safe_account_id(account_id)
|
||||||
if safe_id == 'default':
|
if safe_id == 'default':
|
||||||
raise HTTPException(status_code=400, detail='账号名不合法')
|
raise HTTPException(status_code=400, detail='账号名不合法')
|
||||||
|
if _linux_accounts_enabled():
|
||||||
|
_validate_linux_username(safe_id)
|
||||||
users = _load_users_file(state)
|
users = _load_users_file(state)
|
||||||
user_rows = users.setdefault('users', [])
|
user_rows = users.setdefault('users', [])
|
||||||
if any(
|
if any(
|
||||||
@@ -3941,6 +4084,8 @@ def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
|||||||
for item in user_rows
|
for item in user_rows
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail='账号已存在')
|
raise HTTPException(status_code=400, detail='账号已存在')
|
||||||
|
if _linux_accounts_enabled():
|
||||||
|
_ensure_linux_account(safe_id, '123456')
|
||||||
user_rows.append(
|
user_rows.append(
|
||||||
{
|
{
|
||||||
'id': safe_id,
|
'id': safe_id,
|
||||||
@@ -3984,8 +4129,18 @@ def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
|||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
)
|
)
|
||||||
base = _accounts_root(state) / safe_id
|
base = _accounts_root(state) / safe_id
|
||||||
|
if _linux_accounts_enabled():
|
||||||
|
base = _linux_account_workspace(safe_id)
|
||||||
if base.exists():
|
if base.exists():
|
||||||
shutil.rmtree(base)
|
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)
|
state._clear_agents_for_account(safe_id)
|
||||||
return {'account_id': safe_id, 'deleted': True}
|
return {'account_id': safe_id, 'deleted': True}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,663 @@
|
|||||||
|
# Workspace Runtime 设计稿
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前项目已经有了平台账号、会话目录、Jupyter 远程工作区、Skill/Tools 和文件产物管理,但这些能力还没有被一个统一的“执行环境”概念串起来。
|
||||||
|
|
||||||
|
现在的问题不是单纯缺少登录账号,而是需要回答:
|
||||||
|
|
||||||
|
```text
|
||||||
|
谁在使用 Agent
|
||||||
|
-> 当前会话绑定到哪个工作区
|
||||||
|
-> 工具以什么身份、在什么目录、用什么权限执行
|
||||||
|
-> 产物在哪里保存、展示和下载
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,账户体系升级不应该只看账号密码,而应该引入 `Workspace Runtime` 作为平台账号和工具执行之间的核心抽象。
|
||||||
|
|
||||||
|
## 核心结论
|
||||||
|
|
||||||
|
账户体系分两层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台账号 Account
|
||||||
|
负责登录、角色、会话、Skill 配置、模型配置、记忆和集成状态。
|
||||||
|
|
||||||
|
工作区运行时 Workspace Runtime
|
||||||
|
负责执行身份、工作目录、文件读写、Python 环境、远程连接和进程管理。
|
||||||
|
```
|
||||||
|
|
||||||
|
平台账号不直接等价于 Linux 账号,也不直接等价于 Jupyter 账号。平台账号可以绑定不同类型的 runtime。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 统一本机工作区、Linux 子账户工作区、Jupyter 工作区和未来 SSH 工作区。
|
||||||
|
2. 让 Tool handler 不关心执行位置,只面向统一 runtime 执行。
|
||||||
|
3. 明确权限来源,避免把远程工作区误认为平台托管沙盒。
|
||||||
|
4. 让每个 session 的输入、输出、scratchpad、Python 环境和文件下载有稳定归属。
|
||||||
|
5. 为后续多用户、资源限制、审计、团队空间和远程执行打基础。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
1. 不在第一阶段实现完整企业 SSO。
|
||||||
|
2. 不把所有账号体系直接迁移到 Linux PAM。
|
||||||
|
3. 不强制所有远程工作区都变成平台托管沙盒。
|
||||||
|
4. 不要求 Skill 感知 runtime 的具体实现细节。
|
||||||
|
|
||||||
|
## 对象模型
|
||||||
|
|
||||||
|
### Account
|
||||||
|
|
||||||
|
平台账号是 Web 产品层的身份。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Account
|
||||||
|
id
|
||||||
|
username
|
||||||
|
display_name
|
||||||
|
role
|
||||||
|
status
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 登录和会话 token。
|
||||||
|
- 模型选择。
|
||||||
|
- Skill 启用状态。
|
||||||
|
- 用户记忆。
|
||||||
|
- 第三方集成状态。
|
||||||
|
- 默认 workspace runtime 策略。
|
||||||
|
|
||||||
|
### Session
|
||||||
|
|
||||||
|
Session 是一次 Agent 对话任务。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Session
|
||||||
|
id
|
||||||
|
account_id
|
||||||
|
runtime_id
|
||||||
|
title
|
||||||
|
status
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 保存对话历史。
|
||||||
|
- 绑定一个 runtime。
|
||||||
|
- 保存工具调用、活动步骤和最终结果。
|
||||||
|
- 关联输入文件和输出 artifact。
|
||||||
|
|
||||||
|
Session 一旦绑定远程 runtime,刷新页面后也应该恢复到同一个 runtime。
|
||||||
|
|
||||||
|
### Workspace Runtime
|
||||||
|
|
||||||
|
Workspace Runtime 是工具执行的真实环境。
|
||||||
|
|
||||||
|
```text
|
||||||
|
WorkspaceRuntime
|
||||||
|
id
|
||||||
|
account_id
|
||||||
|
session_id
|
||||||
|
type
|
||||||
|
root
|
||||||
|
permissions_source
|
||||||
|
status
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`type` 可以是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local_process
|
||||||
|
local_linux_user
|
||||||
|
remote_jupyter
|
||||||
|
remote_ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 决定 bash/python/file 工具在哪里执行。
|
||||||
|
- 决定输入输出文件在哪里。
|
||||||
|
- 决定 Python 环境在哪里。
|
||||||
|
- 决定进程如何启动、停止和清理。
|
||||||
|
- 决定文件如何展示、下载和转在线文档。
|
||||||
|
|
||||||
|
### Artifact
|
||||||
|
|
||||||
|
Artifact 是输入和输出文件的统一抽象。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Artifact
|
||||||
|
id
|
||||||
|
account_id
|
||||||
|
session_id
|
||||||
|
runtime_id
|
||||||
|
kind: input | output | scratchpad
|
||||||
|
uri
|
||||||
|
name
|
||||||
|
size
|
||||||
|
mime
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`uri` 可以是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
file:///home/<account_id>/zk-agent/sessions/<session_id>/output/a.jsonl
|
||||||
|
jupyter://<session_id>/root/zk_agent_workspaces/<session_id>/output/a.jsonl
|
||||||
|
ssh://<runtime_id>/home/user/zk_agent_workspaces/<session_id>/output/a.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
文件列表只需要展示 metadata。下载或转在线文档时,再通过 runtime 拉取内容。
|
||||||
|
|
||||||
|
### Executor
|
||||||
|
|
||||||
|
Executor 是 Tool handler 和 Runtime 之间的执行适配层。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Executor
|
||||||
|
run_bash(command, cwd, timeout)
|
||||||
|
run_python(code_or_file, cwd, timeout)
|
||||||
|
read_file(path)
|
||||||
|
write_file(path, content)
|
||||||
|
list_files(path)
|
||||||
|
open_file_stream(path)
|
||||||
|
cancel(run_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool handler 不应该自己判断是在本地、Jupyter 还是 SSH。它只调用当前 session 的 executor。
|
||||||
|
|
||||||
|
## Runtime 类型
|
||||||
|
|
||||||
|
### local_process
|
||||||
|
|
||||||
|
当前已有的默认模式。工具在服务进程所在机器上执行,目录由平台约定。
|
||||||
|
|
||||||
|
```text
|
||||||
|
.port_sessions/accounts/<account_id>/sessions/<session_id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 本地开发。
|
||||||
|
- 单用户调试。
|
||||||
|
- 早期兼容。
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- 多用户隔离主要靠代码路径约束。
|
||||||
|
- 工具进程和平台服务权限一致,风险较高。
|
||||||
|
|
||||||
|
### local_linux_user
|
||||||
|
|
||||||
|
平台托管的标准多用户工作区。
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台账号: banisherwy
|
||||||
|
Linux runtime user: banisherwy
|
||||||
|
workspace root: /home/banisherwy/zk-agent/sessions/<session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
服务进程可以是 root,工具进程切换到普通 Linux 用户执行。
|
||||||
|
|
||||||
|
```text
|
||||||
|
root backend
|
||||||
|
-> runuser -u banisherwy -- <runner command>
|
||||||
|
```
|
||||||
|
|
||||||
|
职责分工:
|
||||||
|
|
||||||
|
```text
|
||||||
|
root 服务
|
||||||
|
创建 runtime 用户
|
||||||
|
初始化 workspace
|
||||||
|
设置 owner 和权限
|
||||||
|
启停进程
|
||||||
|
管理平台账号和 session
|
||||||
|
|
||||||
|
普通 Linux 用户
|
||||||
|
执行 bash/python
|
||||||
|
拥有自己的 workspace
|
||||||
|
拥有自己的 Python 虚拟环境
|
||||||
|
只能写自己的目录
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/<account_id>/zk-agent/
|
||||||
|
sessions/
|
||||||
|
<session_id>/
|
||||||
|
input/
|
||||||
|
output/
|
||||||
|
scratchpad/
|
||||||
|
session.json
|
||||||
|
python/
|
||||||
|
.venv/
|
||||||
|
memory/
|
||||||
|
integrations/
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐约定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台用户名 = Linux 用户名
|
||||||
|
平台密码 = Linux 用户密码
|
||||||
|
Linux 用户允许 SSH 登录
|
||||||
|
```
|
||||||
|
|
||||||
|
这样用户体验更直接:
|
||||||
|
|
||||||
|
- 在平台创建账号时,同步创建同名 Linux 用户。
|
||||||
|
- 用户可以使用同一套账号密码登录 Web 平台和 SSH。
|
||||||
|
- Agent 工具执行时也使用同一个 Linux 用户身份。
|
||||||
|
- 文件 owner、进程 owner、SSH 登录用户和平台用户名一致,便于排查和审计。
|
||||||
|
|
||||||
|
但两者在架构语义上仍然保留分层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台账号体系
|
||||||
|
登录、角色、session、Skill、模型配置。
|
||||||
|
|
||||||
|
Linux 用户体系
|
||||||
|
执行隔离、文件权限、进程权限、资源限制。
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,账号名和密码保持一致,但平台仍然保留自己的登录态、session、角色和配置管理。Linux 账号负责机器级登录和执行权限。
|
||||||
|
|
||||||
|
需要注意:
|
||||||
|
|
||||||
|
- 用户名必须同时满足平台账号规范和 Linux 用户名规范。
|
||||||
|
- 修改平台密码时必须同步修改 Linux 密码。
|
||||||
|
- 禁用平台账号时,也应该禁用 Linux 登录或锁定 Linux 用户。
|
||||||
|
- 删除平台账号时,需要明确是否保留 `/home/<account_id>/zk-agent/` 数据。
|
||||||
|
- root 服务创建用户和改密码时必须走受控 helper,不能把用户输入拼成 shell 命令。
|
||||||
|
|
||||||
|
### remote_jupyter
|
||||||
|
|
||||||
|
用户授权的远程工作区。
|
||||||
|
|
||||||
|
语义是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户把自己已有权限的 Jupyter 环境接入平台。
|
||||||
|
平台代替用户在这个环境里执行。
|
||||||
|
```
|
||||||
|
|
||||||
|
这不是平台托管沙盒。权限边界来自用户提供的 Jupyter 凭证。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Account: banisherwy
|
||||||
|
Session: xxx
|
||||||
|
Runtime: remote_jupyter
|
||||||
|
Root: /root/zk_agent_workspaces/<session_id>
|
||||||
|
Permissions source: Jupyter password/token 对应的远程用户权限
|
||||||
|
```
|
||||||
|
|
||||||
|
平台需要保证:
|
||||||
|
|
||||||
|
- Jupyter 凭证只绑定当前 account/session。
|
||||||
|
- 刷新后 runtime 状态可恢复。
|
||||||
|
- 文件列表 metadata-only。
|
||||||
|
- 下载时通过 Jupyter API 流式拉取。
|
||||||
|
- 转在线文档时按需拉取,不默认同步大文件。
|
||||||
|
- 用户明确知道 Agent 在远程环境里的权限等同于该 Jupyter 用户。
|
||||||
|
|
||||||
|
平台不能保证:
|
||||||
|
|
||||||
|
- 远程机器上的文件权限隔离。
|
||||||
|
- 远程 Jupyter 用户不是 root。
|
||||||
|
- 远程挂载目录的访问范围。
|
||||||
|
|
||||||
|
短期建议:`remote_jupyter` 先保持当前逻辑,不作为账户体系升级的主战场。
|
||||||
|
|
||||||
|
当前已经具备:
|
||||||
|
|
||||||
|
- session 级 Jupyter 绑定。
|
||||||
|
- 刷新后恢复远程工作区状态。
|
||||||
|
- 输出文件 metadata-only 展示。
|
||||||
|
- 下载时通过 Jupyter API 流式读取。
|
||||||
|
- 转在线文档时按需拉取。
|
||||||
|
|
||||||
|
因此下一步账户体系升级优先处理本机托管 runtime 和平台账号,不主动重构 Jupyter 执行链路。后续只需要让 Jupyter 工作区在概念上挂到 `WorkspaceRuntime` 模型下。
|
||||||
|
|
||||||
|
### remote_ssh
|
||||||
|
|
||||||
|
未来可扩展的用户授权远程工作区。
|
||||||
|
|
||||||
|
语义和 remote_jupyter 类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户提供 SSH 连接能力。
|
||||||
|
平台代替用户在远程机器上执行。
|
||||||
|
权限边界来自 SSH 凭证对应的远程用户。
|
||||||
|
```
|
||||||
|
|
||||||
|
remote_ssh 更适合:
|
||||||
|
|
||||||
|
- 远程机器没有 Jupyter。
|
||||||
|
- 需要更完整 shell 能力。
|
||||||
|
- 需要使用远程开发机的挂载盘、GPU、模型目录。
|
||||||
|
|
||||||
|
但它也更复杂:
|
||||||
|
|
||||||
|
- SSH 凭证管理。
|
||||||
|
- 长连接和心跳。
|
||||||
|
- relay / OTP / 扫码登录。
|
||||||
|
- 文件传输和断线恢复。
|
||||||
|
- 进程树管理。
|
||||||
|
|
||||||
|
因此优先级应低于 `local_linux_user` 和已有 `remote_jupyter`。
|
||||||
|
|
||||||
|
## 权限边界
|
||||||
|
|
||||||
|
需要在 UI 和文档中明确区分两类工作区:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台托管工作区
|
||||||
|
平台负责权限隔离。
|
||||||
|
典型类型: local_linux_user。
|
||||||
|
|
||||||
|
用户授权工作区
|
||||||
|
用户提供凭证。
|
||||||
|
平台不创建权限边界,只复用用户已有权限。
|
||||||
|
典型类型: remote_jupyter, remote_ssh。
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 可以显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前工作区:Jupyter 远程工作区
|
||||||
|
权限来源:用户提供的 Jupyter 凭证
|
||||||
|
Agent 权限:等同于该远程环境当前登录用户
|
||||||
|
```
|
||||||
|
|
||||||
|
或者:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前工作区:平台托管工作区
|
||||||
|
执行身份:banisherwy
|
||||||
|
Agent 权限:普通 Linux 用户权限
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool 调用关系
|
||||||
|
|
||||||
|
目标关系:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent Loop
|
||||||
|
-> Tool handler
|
||||||
|
-> RuntimeResolver(session_id)
|
||||||
|
-> Executor
|
||||||
|
-> local process / linux user / jupyter / ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
工具不应该散落处理路径和远程协议。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python_exec
|
||||||
|
-> executor.run_python(...)
|
||||||
|
|
||||||
|
write_file
|
||||||
|
-> executor.write_file(...)
|
||||||
|
|
||||||
|
download_artifact
|
||||||
|
-> executor.open_file_stream(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
这样后续新增 runtime 时,尽量只新增 executor,不重写每个工具。
|
||||||
|
|
||||||
|
## 文件策略
|
||||||
|
|
||||||
|
### 输入文件
|
||||||
|
|
||||||
|
输入文件应该同步到当前 runtime 的 `input/`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
local_linux_user
|
||||||
|
上传文件 -> /home/<account_id>/zk-agent/sessions/<session_id>/input/
|
||||||
|
|
||||||
|
remote_jupyter
|
||||||
|
上传文件 -> 通过 Jupyter API 写入 /root/zk_agent_workspaces/<session_id>/input/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出文件
|
||||||
|
|
||||||
|
输出文件默认放到当前 runtime 的 `output/`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
output/
|
||||||
|
records.jsonl
|
||||||
|
report.md
|
||||||
|
samples.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
对远程 runtime,平台只保存 metadata。
|
||||||
|
|
||||||
|
```text
|
||||||
|
name
|
||||||
|
size
|
||||||
|
mtime
|
||||||
|
uri
|
||||||
|
runtime_id
|
||||||
|
```
|
||||||
|
|
||||||
|
点击下载时再流式读取。点击转在线文档时再按需拉取,并设置大小限制。
|
||||||
|
|
||||||
|
## Python 环境策略
|
||||||
|
|
||||||
|
每个 runtime 应有自己的 Python 环境。
|
||||||
|
|
||||||
|
```text
|
||||||
|
local_linux_user
|
||||||
|
/home/<account_id>/zk-agent/python/.venv
|
||||||
|
|
||||||
|
remote_jupyter
|
||||||
|
/root/zk_agent_workspaces/.zk-agent-python/.venv
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化时只做最小准备:
|
||||||
|
|
||||||
|
- 创建 venv。
|
||||||
|
- 配置 pip 源。
|
||||||
|
- 不预装大量包。
|
||||||
|
|
||||||
|
缺包时由 Agent 根据任务安装,安装也发生在当前 runtime 内。
|
||||||
|
|
||||||
|
## 进程管理
|
||||||
|
|
||||||
|
每个工具执行必须有 run id 和 process group。
|
||||||
|
|
||||||
|
```text
|
||||||
|
run_id
|
||||||
|
account_id
|
||||||
|
session_id
|
||||||
|
runtime_id
|
||||||
|
executor_pid 或 remote_execution_id
|
||||||
|
status
|
||||||
|
started_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
停止任务时:
|
||||||
|
|
||||||
|
- local_process:杀本地进程组。
|
||||||
|
- local_linux_user:杀对应 runtime 用户下该 run 的进程组。
|
||||||
|
- remote_jupyter:中断 kernel 或关闭对应执行任务。
|
||||||
|
- remote_ssh:杀远程进程组。
|
||||||
|
|
||||||
|
不能只停止 Web 请求,否则会出现“前端以为停了,后台 Python 还在跑”的问题。
|
||||||
|
|
||||||
|
## 持久化建议
|
||||||
|
|
||||||
|
建议把当前 JSON 账号体系逐步迁到 SQLite。
|
||||||
|
|
||||||
|
第一阶段可新增这些表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
accounts
|
||||||
|
id
|
||||||
|
username
|
||||||
|
password_hash
|
||||||
|
role
|
||||||
|
status
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
|
||||||
|
account_sessions
|
||||||
|
token_hash
|
||||||
|
account_id
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
expires_at
|
||||||
|
|
||||||
|
workspace_runtimes
|
||||||
|
id
|
||||||
|
account_id
|
||||||
|
session_id
|
||||||
|
type
|
||||||
|
root
|
||||||
|
status
|
||||||
|
config_json
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
|
||||||
|
artifacts
|
||||||
|
id
|
||||||
|
account_id
|
||||||
|
session_id
|
||||||
|
runtime_id
|
||||||
|
kind
|
||||||
|
uri
|
||||||
|
name
|
||||||
|
size
|
||||||
|
mime
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
敏感信息不要直接明文落库。Jupyter 密码、SSH key、token 至少需要加密或放入受控 secret store。
|
||||||
|
|
||||||
|
## 与现有实现的关系
|
||||||
|
|
||||||
|
当前已有能力可以映射到新模型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/app/lib/claw-auth.ts
|
||||||
|
Account 登录态原型。
|
||||||
|
|
||||||
|
.port_sessions/accounts/<account_id>
|
||||||
|
local_process 模式下的 account workspace。
|
||||||
|
|
||||||
|
backend/api/server.py::account_paths
|
||||||
|
Runtime path resolver 的雏形。
|
||||||
|
|
||||||
|
src/jupyter_runtime.py
|
||||||
|
remote_jupyter executor 的雏形。
|
||||||
|
|
||||||
|
RunManager / RunStateStore
|
||||||
|
run id、活动状态、停止任务的雏形。
|
||||||
|
|
||||||
|
frontend 文件面板
|
||||||
|
Artifact list/download 的雏形。
|
||||||
|
```
|
||||||
|
|
||||||
|
所以这不是推翻重来,而是把已有能力抽象成更稳定的边界。
|
||||||
|
|
||||||
|
## 演进路线
|
||||||
|
|
||||||
|
### Phase 0:明确概念,不改执行路径
|
||||||
|
|
||||||
|
- 在代码和文档中引入 Workspace Runtime 术语。
|
||||||
|
- 把现有 `.port_sessions/accounts/<account_id>` 视为 `local_process` runtime。
|
||||||
|
- UI 显示当前工作区类型。
|
||||||
|
- 对 Jupyter 工作区补充权限提示。
|
||||||
|
|
||||||
|
### Phase 1:抽象 RuntimeResolver 和 Executor
|
||||||
|
|
||||||
|
- 新增 `RuntimeResolver`,根据 account/session 找当前 runtime。
|
||||||
|
- 新增统一 `Executor` 接口。
|
||||||
|
- 先把 `python_exec`、`bash`、文件工具迁到 executor。
|
||||||
|
- 保持现有 local 和 Jupyter 行为不变。
|
||||||
|
|
||||||
|
### Phase 2:账号存储升级
|
||||||
|
|
||||||
|
- 把 `users.json` 和 `auth_sessions.json` 迁到 SQLite。
|
||||||
|
- 增加 `account_id`、`role`、`status`、`expires_at`。
|
||||||
|
- 增加 session token 清理。
|
||||||
|
- 管理后台去掉 `admin/admin` 和默认 `123456`。
|
||||||
|
|
||||||
|
### Phase 3:local_linux_user runtime
|
||||||
|
|
||||||
|
- root 服务创建与平台账号同名的 Linux 用户。
|
||||||
|
- 平台密码同步设置为 Linux 用户密码。
|
||||||
|
- Linux 用户允许 SSH 登录。
|
||||||
|
- 初始化 `/home/<account_id>/zk-agent/`。
|
||||||
|
- 工具执行切到普通 Linux 用户。
|
||||||
|
- Python venv、session、output 全部进入用户 home。
|
||||||
|
- 停止任务时按 process group 清理。
|
||||||
|
|
||||||
|
### Phase 4:资源限制和审计
|
||||||
|
|
||||||
|
- ulimit / cgroup。
|
||||||
|
- 每账号磁盘 quota。
|
||||||
|
- 工具执行审计。
|
||||||
|
- 大文件下载限流。
|
||||||
|
- session/output 清理策略。
|
||||||
|
|
||||||
|
### Phase 5:remote_ssh runtime
|
||||||
|
|
||||||
|
- 在 remote_jupyter 稳定后再考虑。
|
||||||
|
- 重点解决认证、relay、长连接、文件传输和远程进程清理。
|
||||||
|
|
||||||
|
## 关键待决问题
|
||||||
|
|
||||||
|
1. 平台账号是否允许用户自注册,还是只允许管理员创建?
|
||||||
|
2. 用户自注册时,是否允许自动创建同名 Linux 用户?
|
||||||
|
3. 删除账号时,是否删除 Linux 用户,是否保留 home 目录?
|
||||||
|
4. 本机平台托管 workspace 是否统一迁到 `/home/<account_id>/zk-agent/`?
|
||||||
|
5. Jupyter 凭证如何加密保存?
|
||||||
|
6. 远程 workspace 产物保留多久?
|
||||||
|
7. 大文件下载、在线文档转换和文件预览的大小限制是多少?
|
||||||
|
8. 是否需要团队空间:一个 workspace 被多个账号共享?
|
||||||
|
|
||||||
|
## 推荐决策
|
||||||
|
|
||||||
|
短期建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
保留平台账号体系。
|
||||||
|
引入 Workspace Runtime 抽象。
|
||||||
|
继续稳定 remote_jupyter。
|
||||||
|
账号存储从 JSON 迁到 SQLite。
|
||||||
|
开始设计 local_linux_user,但不要立即替换所有执行路径。
|
||||||
|
```
|
||||||
|
|
||||||
|
中期建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
服务可以 root 运行。
|
||||||
|
平台账号创建时同步创建同名普通 Linux 用户。
|
||||||
|
平台密码和 Linux 密码保持一致。
|
||||||
|
Linux 用户允许 SSH 登录。
|
||||||
|
工具执行统一通过 runtime executor。
|
||||||
|
本机默认工作区逐步迁到 /home/<account_id>/zk-agent。
|
||||||
|
```
|
||||||
|
|
||||||
|
长期建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台账号负责产品身份。
|
||||||
|
Workspace Runtime 负责执行环境。
|
||||||
|
Artifact 负责跨 runtime 文件抽象。
|
||||||
|
Executor 负责工具执行适配。
|
||||||
|
```
|
||||||
|
|
||||||
|
这样账户体系、Linux 子账户、Jupyter/SSH 远程工作区、文件下载、Python 环境和工具执行可以合到一个统一设计里,而不是继续各自生长。
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
accountSessionOutputRoot,
|
accountSessionOutputRoot,
|
||||||
accountSessionRoot,
|
accountSessionRoot,
|
||||||
accountSessionScratchpadRoot,
|
accountSessionScratchpadRoot,
|
||||||
|
chownAccountPath,
|
||||||
getCurrentAccount,
|
getCurrentAccount,
|
||||||
} from "@/lib/claw-auth";
|
} from "@/lib/claw-auth";
|
||||||
|
|
||||||
@@ -334,14 +335,16 @@ async function getLastUserText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSessionDirectories(accountId: string, sessionId: string) {
|
async function ensureSessionDirectories(accountId: string, sessionId: string) {
|
||||||
|
const sessionRoot = accountSessionRoot(accountId, sessionId);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mkdir(accountSessionRoot(accountId, sessionId), { recursive: true }),
|
mkdir(sessionRoot, { recursive: true }),
|
||||||
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
|
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
|
||||||
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
|
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
|
||||||
mkdir(accountSessionScratchpadRoot(accountId, sessionId), {
|
mkdir(accountSessionScratchpadRoot(accountId, sessionId), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
await chownAccountPath(accountId, sessionRoot, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
|
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
|
||||||
@@ -394,6 +397,7 @@ async function saveFilePart(
|
|||||||
await mkdir(uploadDir, { recursive: true });
|
await mkdir(uploadDir, { recursive: true });
|
||||||
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
|
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
|
||||||
await writeFile(filePath, bytes);
|
await writeFile(filePath, bytes);
|
||||||
|
await chownAccountPath(accountId, filePath);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`[文件已上传] ${filename}`,
|
`[文件已上传] ${filename}`,
|
||||||
|
|||||||
+117
-15
@@ -1,17 +1,19 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const AUTH_ROOT = path.join(
|
const AUTH_STATE_ROOT = path.join(
|
||||||
/* turbopackIgnore: true */ process.cwd(),
|
/* turbopackIgnore: true */ process.cwd(),
|
||||||
"../../.port_sessions/accounts",
|
"../../.port_sessions/accounts",
|
||||||
);
|
);
|
||||||
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
|
const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json");
|
||||||
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
|
const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json");
|
||||||
const COOKIE_NAME = "claw_account_session";
|
const COOKIE_NAME = "claw_account_session";
|
||||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
|
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
|
||||||
|
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
|
||||||
|
|
||||||
type UserRecord = {
|
type UserRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +53,7 @@ export async function registerAccount(username: string, password: string) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
usersFile.users.push(account);
|
usersFile.users.push(account);
|
||||||
|
await ensureLinuxAccount(account.id, password);
|
||||||
await writeUsers(usersFile);
|
await writeUsers(usersFile);
|
||||||
await createAccountDirectories(account.id);
|
await createAccountDirectories(account.id);
|
||||||
return createSession(account);
|
return createSession(account);
|
||||||
@@ -65,6 +68,7 @@ export async function loginAccount(username: string, password: string) {
|
|||||||
if (!account || !verifyPassword(password, account.passwordHash)) {
|
if (!account || !verifyPassword(password, account.passwordHash)) {
|
||||||
throw new Error("账号或密码错误");
|
throw new Error("账号或密码错误");
|
||||||
}
|
}
|
||||||
|
await ensureLinuxAccount(account.id, password);
|
||||||
await createAccountDirectories(account.id);
|
await createAccountDirectories(account.id);
|
||||||
return createSession(account);
|
return createSession(account);
|
||||||
}
|
}
|
||||||
@@ -101,15 +105,18 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function accountUploadRoot(accountId: string) {
|
export function accountUploadRoot(accountId: string) {
|
||||||
return path.join(AUTH_ROOT, accountId, "sessions");
|
return path.join(accountBaseRoot(accountId), "sessions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function accountOutputRoot(accountId: string) {
|
export function accountOutputRoot(accountId: string) {
|
||||||
return path.join(AUTH_ROOT, accountId, "sessions");
|
return path.join(accountBaseRoot(accountId), "sessions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function accountBaseRoot(accountId: string) {
|
export function accountBaseRoot(accountId: string) {
|
||||||
return path.join(AUTH_ROOT, accountId);
|
if (linuxAccountsEnabled()) {
|
||||||
|
return path.join("/home", accountId, LINUX_ACCOUNT_WORKSPACE_NAME);
|
||||||
|
}
|
||||||
|
return path.join(AUTH_STATE_ROOT, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function accountSessionInputRoot(accountId: string, sessionId: string) {
|
export function accountSessionInputRoot(accountId: string, sessionId: string) {
|
||||||
@@ -161,15 +168,18 @@ async function setSessionCookie(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createAccountDirectories(accountId: string) {
|
async function createAccountDirectories(accountId: string) {
|
||||||
await Promise.all(
|
const base = accountBaseRoot(accountId);
|
||||||
["sessions"].map((name) =>
|
await Promise.all([
|
||||||
mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }),
|
mkdir(path.join(base, "sessions"), { recursive: true }),
|
||||||
),
|
mkdir(path.join(base, "python"), { recursive: true }),
|
||||||
);
|
mkdir(path.join(base, "memory"), { recursive: true }),
|
||||||
|
mkdir(path.join(base, "integrations"), { recursive: true }),
|
||||||
|
]);
|
||||||
|
await chownAccountPath(accountId, base, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readUsers(): Promise<UsersFile> {
|
async function readUsers(): Promise<UsersFile> {
|
||||||
await mkdir(AUTH_ROOT, { recursive: true });
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
|
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -178,12 +188,12 @@ async function readUsers(): Promise<UsersFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeUsers(payload: UsersFile) {
|
async function writeUsers(payload: UsersFile) {
|
||||||
await mkdir(AUTH_ROOT, { recursive: true });
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||||
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSessions(): Promise<SessionsFile> {
|
async function readSessions(): Promise<SessionsFile> {
|
||||||
await mkdir(AUTH_ROOT, { recursive: true });
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
|
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -192,12 +202,20 @@ async function readSessions(): Promise<SessionsFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeSessions(payload: SessionsFile) {
|
async function writeSessions(payload: SessionsFile) {
|
||||||
await mkdir(AUTH_ROOT, { recursive: true });
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
||||||
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUsername(username: string) {
|
function normalizeUsername(username: string) {
|
||||||
const cleanUsername = username.trim().toLowerCase();
|
const cleanUsername = username.trim().toLowerCase();
|
||||||
|
if (linuxAccountsEnabled()) {
|
||||||
|
if (!/^[a-z_][a-z0-9_-]{1,31}$/.test(cleanUsername)) {
|
||||||
|
throw new Error(
|
||||||
|
"启用 Linux 账号时,账号只能包含小写字母、数字、下划线或中划线,且必须以小写字母或下划线开头,长度 2-32",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleanUsername;
|
||||||
|
}
|
||||||
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
|
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
|
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
|
||||||
@@ -230,3 +248,87 @@ function shouldRefreshSession(value?: string) {
|
|||||||
if (Number.isNaN(timestamp)) return true;
|
if (Number.isNaN(timestamp)) return true;
|
||||||
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
|
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function linuxAccountsEnabled() {
|
||||||
|
return ["1", "true", "yes", "on"].includes(
|
||||||
|
(process.env.CLAW_ENABLE_LINUX_ACCOUNTS ?? "").trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLinuxAccount(accountId: string, password: string) {
|
||||||
|
if (!linuxAccountsEnabled()) return;
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
throw new Error("CLAW_ENABLE_LINUX_ACCOUNTS=1 只支持 Linux 部署环境");
|
||||||
|
}
|
||||||
|
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
"CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 Web 服务以 root 身份运行",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await runCommand("id", ["-u", accountId], { allowFailure: true }).then(
|
||||||
|
async (result) => {
|
||||||
|
if (result.code === 0) return;
|
||||||
|
await runCommand("useradd", [
|
||||||
|
"-m",
|
||||||
|
"-d",
|
||||||
|
`/home/${accountId}`,
|
||||||
|
"-s",
|
||||||
|
"/bin/bash",
|
||||||
|
accountId,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await runCommand("chpasswd", [], {
|
||||||
|
input: `${accountId}:${password}\n`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chownAccountPath(
|
||||||
|
accountId: string,
|
||||||
|
targetPath: string,
|
||||||
|
recursive = false,
|
||||||
|
) {
|
||||||
|
if (!linuxAccountsEnabled()) return;
|
||||||
|
await runCommand("chown", [
|
||||||
|
...(recursive ? ["-R"] : []),
|
||||||
|
accountId,
|
||||||
|
targetPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { input?: string; allowFailure?: boolean } = {},
|
||||||
|
) {
|
||||||
|
return new Promise<{ code: number; stdout: string; stderr: string }>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
const exitCode = code ?? 1;
|
||||||
|
if (exitCode !== 0 && !options.allowFailure) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${command} ${args.join(" ")} 失败:${stderr || stdout || exitCode}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ code: exitCode, stdout, stderr });
|
||||||
|
});
|
||||||
|
if (options.input) child.stdin.write(options.input);
|
||||||
|
child.stdin.end();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+105
-11
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Ubuntu 一键部署/更新脚本。
|
# Ubuntu 一键部署/更新脚本。
|
||||||
# - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。
|
# - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。
|
||||||
# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启用户级 systemd 服务。
|
# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启 systemd 服务。
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
ENV_FILE="${ROOT_DIR}/.env.deploy"
|
ENV_FILE="${ROOT_DIR}/.env.deploy"
|
||||||
@@ -12,14 +12,17 @@ DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
|
|||||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||||
USER_SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
USER_SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||||
|
SYSTEM_SYSTEMD_DIR="/etc/systemd/system"
|
||||||
BRANCH=""
|
BRANCH=""
|
||||||
FORCE=0
|
FORCE=0
|
||||||
SKIP_GIT=0
|
SKIP_GIT=0
|
||||||
BOOTSTRAP_SYSTEM=0
|
BOOTSTRAP_SYSTEM=0
|
||||||
|
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
|
||||||
|
ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: bash scripts/deploy-ubuntu.sh [branch] [--force] [--skip-git] [--bootstrap-system]
|
Usage: bash scripts/deploy-ubuntu.sh [branch] [--force] [--skip-git] [--bootstrap-system] [--system-service] [--user-service] [--enable-linux-accounts]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
branch 要部署的 git 分支;不传则使用当前分支
|
branch 要部署的 git 分支;不传则使用当前分支
|
||||||
@@ -27,6 +30,12 @@ Options:
|
|||||||
--skip-git 跳过 git 拉取,只部署当前工作区
|
--skip-git 跳过 git 拉取,只部署当前工作区
|
||||||
--bootstrap-system
|
--bootstrap-system
|
||||||
强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行
|
强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行
|
||||||
|
--system-service
|
||||||
|
安装 root/system 级 systemd 服务;以 root 执行时默认启用
|
||||||
|
--user-service
|
||||||
|
安装当前用户级 systemd 服务
|
||||||
|
--enable-linux-accounts
|
||||||
|
启用平台账号同名 Linux 用户工作区;要求 system 服务和 root 权限
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +53,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BOOTSTRAP_SYSTEM=1
|
BOOTSTRAP_SYSTEM=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--system-service)
|
||||||
|
SERVICE_SCOPE="system"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--user-service)
|
||||||
|
SERVICE_SCOPE="user"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--enable-linux-accounts)
|
||||||
|
ENABLE_LINUX_ACCOUNTS="1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -60,6 +81,9 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
REQUESTED_SERVICE_SCOPE="${SERVICE_SCOPE}"
|
||||||
|
REQUESTED_ENABLE_LINUX_ACCOUNTS="${ENABLE_LINUX_ACCOUNTS}"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf "\n\033[1;34m==>\033[0m %s\n" "$1"
|
printf "\n\033[1;34m==>\033[0m %s\n" "$1"
|
||||||
}
|
}
|
||||||
@@ -77,6 +101,16 @@ configure_instance_names() {
|
|||||||
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
|
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
|
||||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||||
|
if [[ -z "${SERVICE_SCOPE}" ]]; then
|
||||||
|
if [[ "${EUID}" == "0" ]]; then
|
||||||
|
SERVICE_SCOPE="system"
|
||||||
|
else
|
||||||
|
SERVICE_SCOPE="user"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${ENABLE_LINUX_ACCOUNTS}" ]]; then
|
||||||
|
ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-0}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
require_command() {
|
require_command() {
|
||||||
@@ -120,6 +154,9 @@ bootstrap_system_packages() {
|
|||||||
libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \
|
libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \
|
||||||
libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \
|
libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \
|
||||||
systemd
|
systemd
|
||||||
|
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" ]]; then
|
||||||
|
sudo apt-get install -y passwd python3 python3-venv
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_nginx_upload_limit() {
|
configure_nginx_upload_limit() {
|
||||||
@@ -180,15 +217,42 @@ export CLAW_BACKEND_PORT=$(printf "%q" "${backend_port}")
|
|||||||
export CLAW_FRONTEND_HOST=$(printf "%q" "${frontend_host}")
|
export CLAW_FRONTEND_HOST=$(printf "%q" "${frontend_host}")
|
||||||
export CLAW_FRONTEND_PORT=$(printf "%q" "${frontend_port}")
|
export CLAW_FRONTEND_PORT=$(printf "%q" "${frontend_port}")
|
||||||
export CLAW_API_URL=$(printf "%q" "${claw_api_url}")
|
export CLAW_API_URL=$(printf "%q" "${claw_api_url}")
|
||||||
|
export CLAW_SERVICE_SCOPE=$(printf "%q" "${SERVICE_SCOPE}")
|
||||||
|
export CLAW_ENABLE_LINUX_ACCOUNTS=$(printf "%q" "${ENABLE_LINUX_ACCOUNTS:-0}")
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "${ENV_FILE}"
|
chmod 600 "${ENV_FILE}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upsert_env_export() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp_file
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
grep -v -E "^export ${key}=" "${ENV_FILE}" >"${tmp_file}" || true
|
||||||
|
printf 'export %s=%s\n' "${key}" "$(printf "%q" "${value}")" >>"${tmp_file}"
|
||||||
|
install -m 0600 "${tmp_file}" "${ENV_FILE}"
|
||||||
|
rm -f "${tmp_file}"
|
||||||
|
}
|
||||||
|
|
||||||
ensure_env_file() {
|
ensure_env_file() {
|
||||||
if [[ -f "${ENV_FILE}" ]]; then
|
if [[ -f "${ENV_FILE}" ]]; then
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "${ENV_FILE}"
|
source "${ENV_FILE}"
|
||||||
|
if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then
|
||||||
|
SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}"
|
||||||
|
export CLAW_SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then
|
||||||
|
ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}"
|
||||||
|
export CLAW_ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}"
|
||||||
|
fi
|
||||||
configure_instance_names
|
configure_instance_names
|
||||||
|
if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then
|
||||||
|
upsert_env_export CLAW_SERVICE_SCOPE "${SERVICE_SCOPE}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then
|
||||||
|
upsert_env_export CLAW_ENABLE_LINUX_ACCOUNTS "${ENABLE_LINUX_ACCOUNTS}"
|
||||||
|
fi
|
||||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||||
fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。"
|
fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。"
|
||||||
fi
|
fi
|
||||||
@@ -197,6 +261,8 @@ ensure_env_file() {
|
|||||||
echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}"
|
echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}"
|
||||||
echo "OPENAI_MODEL=${OPENAI_MODEL:-}"
|
echo "OPENAI_MODEL=${OPENAI_MODEL:-}"
|
||||||
echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}"
|
echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}"
|
||||||
|
echo "CLAW_SERVICE_SCOPE=${SERVICE_SCOPE}"
|
||||||
|
echo "CLAW_ENABLE_LINUX_ACCOUNTS=${CLAW_ENABLE_LINUX_ACCOUNTS:-0}"
|
||||||
echo "OPENAI_API_KEY=已配置"
|
echo "OPENAI_API_KEY=已配置"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -214,6 +280,9 @@ ensure_env_file() {
|
|||||||
prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1"
|
prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1"
|
||||||
prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro"
|
prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro"
|
||||||
configure_instance_names
|
configure_instance_names
|
||||||
|
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then
|
||||||
|
fail "--enable-linux-accounts 需要 --system-service,并建议使用 sudo/root 执行部署。"
|
||||||
|
fi
|
||||||
prompt_value backend_host "请输入后端监听地址" "127.0.0.1"
|
prompt_value backend_host "请输入后端监听地址" "127.0.0.1"
|
||||||
prompt_value backend_port "请输入后端端口" "8765"
|
prompt_value backend_port "请输入后端端口" "8765"
|
||||||
prompt_value frontend_host "请输入前端监听地址" "0.0.0.0"
|
prompt_value frontend_host "请输入前端监听地址" "0.0.0.0"
|
||||||
@@ -304,14 +373,21 @@ EOF
|
|||||||
install_systemd_service() {
|
install_systemd_service() {
|
||||||
local service_name="$1"
|
local service_name="$1"
|
||||||
local template_path="$2"
|
local template_path="$2"
|
||||||
local target_path="${USER_SYSTEMD_DIR}/${service_name}.service"
|
local target_dir="${USER_SYSTEMD_DIR}"
|
||||||
|
local wanted_by="default.target"
|
||||||
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
|
target_dir="${SYSTEM_SYSTEMD_DIR}"
|
||||||
|
wanted_by="multi-user.target"
|
||||||
|
fi
|
||||||
|
local target_path="${target_dir}/${service_name}.service"
|
||||||
local tmp_path
|
local tmp_path
|
||||||
tmp_path="$(mktemp)"
|
tmp_path="$(mktemp)"
|
||||||
mkdir -p "${USER_SYSTEMD_DIR}"
|
mkdir -p "${target_dir}"
|
||||||
sed \
|
sed \
|
||||||
-e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \
|
-e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \
|
||||||
-e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \
|
-e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \
|
||||||
-e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \
|
-e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \
|
||||||
|
-e "s#WantedBy=default.target#WantedBy=${wanted_by}#g" \
|
||||||
"${template_path}" >"${tmp_path}"
|
"${template_path}" >"${tmp_path}"
|
||||||
install -m 0644 "${tmp_path}" "${target_path}"
|
install -m 0644 "${tmp_path}" "${target_path}"
|
||||||
rm -f "${tmp_path}"
|
rm -f "${tmp_path}"
|
||||||
@@ -319,17 +395,30 @@ install_systemd_service() {
|
|||||||
|
|
||||||
install_services() {
|
install_services() {
|
||||||
require_command systemctl "当前系统不支持 systemd,无法安装服务。"
|
require_command systemctl "当前系统不支持 systemd,无法安装服务。"
|
||||||
log "安装/更新用户级 systemd 服务"
|
if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then
|
||||||
|
fail "CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 system 服务。请用 sudo bash scripts/deploy-ubuntu.sh --system-service --enable-linux-accounts。"
|
||||||
|
fi
|
||||||
|
log "安装/更新 ${SERVICE_SCOPE} systemd 服务"
|
||||||
install_systemd_service "${BACKEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-backend.service.template"
|
install_systemd_service "${BACKEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-backend.service.template"
|
||||||
install_systemd_service "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template"
|
install_systemd_service "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template"
|
||||||
systemctl --user daemon-reload
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
else
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
restart_services() {
|
restart_services() {
|
||||||
log "重启服务"
|
log "重启服务"
|
||||||
systemctl --user restart "${BACKEND_SERVICE}"
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
systemctl --user restart "${FRONTEND_SERVICE}"
|
systemctl restart "${BACKEND_SERVICE}"
|
||||||
|
systemctl restart "${FRONTEND_SERVICE}"
|
||||||
|
else
|
||||||
|
systemctl --user restart "${BACKEND_SERVICE}"
|
||||||
|
systemctl --user restart "${FRONTEND_SERVICE}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
health_check() {
|
health_check() {
|
||||||
@@ -348,8 +437,13 @@ health_check() {
|
|||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
warn "健康检查失败,输出最近日志。"
|
warn "健康检查失败,输出最近日志。"
|
||||||
journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
|
journalctl -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
|
||||||
|
journalctl -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
|
||||||
|
else
|
||||||
|
journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true
|
||||||
|
journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ ENV_FILE="${ROOT_DIR}/.env.deploy"
|
|||||||
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
|
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}"
|
||||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||||
|
SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}"
|
||||||
BRANCH="${1:-}"
|
BRANCH="${1:-}"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
@@ -26,6 +27,13 @@ configure_instance_names() {
|
|||||||
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
|
DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}"
|
||||||
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}"
|
||||||
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}"
|
||||||
|
if [[ -z "${SERVICE_SCOPE}" ]]; then
|
||||||
|
if [[ "${EUID}" == "0" ]]; then
|
||||||
|
SERVICE_SCOPE="system"
|
||||||
|
else
|
||||||
|
SERVICE_SCOPE="user"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_npm_bin() {
|
resolve_npm_bin() {
|
||||||
@@ -99,12 +107,20 @@ npm_bin="$(resolve_npm_bin)"
|
|||||||
export PATH="$(dirname "${npm_bin}"):${PATH}"
|
export PATH="$(dirname "${npm_bin}"):${PATH}"
|
||||||
"${npm_bin}" run build
|
"${npm_bin}" run build
|
||||||
|
|
||||||
log "重启用户服务"
|
log "重启 ${SERVICE_SCOPE} 服务"
|
||||||
systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
|
systemctl restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
else
|
||||||
|
systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
fi
|
||||||
|
|
||||||
log "健康检查"
|
log "健康检查"
|
||||||
sleep 1
|
sleep 1
|
||||||
systemctl --user --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
if [[ "${SERVICE_SCOPE}" == "system" ]]; then
|
||||||
|
systemctl --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
else
|
||||||
|
systemctl --user --no-pager --lines=0 status "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "快速更新完成。"
|
echo "快速更新完成。"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class ToolExecutionContext:
|
|||||||
permissions: AgentPermissions
|
permissions: AgentPermissions
|
||||||
scratchpad_directory: Path | None = None
|
scratchpad_directory: Path | None = None
|
||||||
python_env_dir: Path | None = None
|
python_env_dir: Path | None = None
|
||||||
|
runtime_user: str | None = None
|
||||||
extra_env: dict[str, str] = field(default_factory=dict)
|
extra_env: dict[str, str] = field(default_factory=dict)
|
||||||
cancel_event: Any | None = None
|
cancel_event: Any | None = None
|
||||||
process_registry: Any | None = None
|
process_registry: Any | None = None
|
||||||
@@ -162,6 +163,7 @@ def build_tool_context(
|
|||||||
if config.python_env_dir
|
if config.python_env_dir
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
runtime_user=config.runtime_user,
|
||||||
extra_env=dict(extra_env or {}),
|
extra_env=dict(extra_env or {}),
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
process_registry=process_registry,
|
process_registry=process_registry,
|
||||||
|
|||||||
+56
-3
@@ -6,6 +6,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import re
|
import re
|
||||||
import selectors
|
import selectors
|
||||||
import shutil
|
import shutil
|
||||||
@@ -1644,6 +1645,8 @@ def _resolve_path(
|
|||||||
else context.root / expanded
|
else context.root / expanded
|
||||||
)
|
)
|
||||||
resolved = candidate.resolve(strict=not allow_missing)
|
resolved = candidate.resolve(strict=not allow_missing)
|
||||||
|
if _is_session_workspace_path(resolved, context):
|
||||||
|
return resolved
|
||||||
try:
|
try:
|
||||||
resolved.relative_to(context.root)
|
resolved.relative_to(context.root)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -1674,6 +1677,17 @@ def _session_logical_path(path: Path, context: ToolExecutionContext) -> Path | N
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_session_workspace_path(path: Path, context: ToolExecutionContext) -> bool:
|
||||||
|
if context.scratchpad_directory is None:
|
||||||
|
return False
|
||||||
|
session_root = context.scratchpad_directory.parent.resolve(strict=False)
|
||||||
|
try:
|
||||||
|
path.resolve(strict=False).relative_to(session_root)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _display_path(path: Path, context: ToolExecutionContext) -> str:
|
def _display_path(path: Path, context: ToolExecutionContext) -> str:
|
||||||
resolved = path.resolve()
|
resolved = path.resolve()
|
||||||
try:
|
try:
|
||||||
@@ -1687,10 +1701,25 @@ def _execution_cwd(context: ToolExecutionContext) -> Path:
|
|||||||
return Path(context.jupyter_runtime.binding.workspace_cwd)
|
return Path(context.jupyter_runtime.binding.workspace_cwd)
|
||||||
if context.scratchpad_directory is not None:
|
if context.scratchpad_directory is not None:
|
||||||
context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
|
context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
_chown_to_runtime_user(context.scratchpad_directory, context)
|
||||||
return context.scratchpad_directory
|
return context.scratchpad_directory
|
||||||
return context.root
|
return context.root
|
||||||
|
|
||||||
|
|
||||||
|
def _chown_to_runtime_user(path: Path, context: ToolExecutionContext) -> None:
|
||||||
|
runtime_user = (context.runtime_user or '').strip()
|
||||||
|
if not runtime_user or os.name != 'posix':
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user_info = pwd.getpwnam(runtime_user)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.chown(path, user_info.pw_uid, user_info.pw_gid)
|
||||||
|
except (FileNotFoundError, PermissionError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _is_platform_app_root(root: Path) -> bool:
|
def _is_platform_app_root(root: Path) -> bool:
|
||||||
return all((root / marker).exists() for marker in _PLATFORM_ROOT_MARKERS)
|
return all((root / marker).exists() for marker in _PLATFORM_ROOT_MARKERS)
|
||||||
|
|
||||||
@@ -1886,8 +1915,10 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
|
|||||||
elif target.exists():
|
elif target.exists():
|
||||||
raise ToolExecutionError(f'Path exists and is not a file: {target}')
|
raise ToolExecutionError(f'Path exists and is not a file: {target}')
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_chown_to_runtime_user(target.parent, context)
|
||||||
final_text = (previous_text or '') + content if append else content
|
final_text = (previous_text or '') + content if append else content
|
||||||
target.write_text(final_text, encoding='utf-8')
|
target.write_text(final_text, encoding='utf-8')
|
||||||
|
_chown_to_runtime_user(target, context)
|
||||||
rel = _display_path(target, context)
|
rel = _display_path(target, context)
|
||||||
new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest()
|
new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest()
|
||||||
action = 'append_file' if append else 'write_file'
|
action = 'append_file' if append else 'write_file'
|
||||||
@@ -2007,6 +2038,7 @@ def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
|||||||
before_sha256 = hashlib.sha256(current.encode('utf-8')).hexdigest()
|
before_sha256 = hashlib.sha256(current.encode('utf-8')).hexdigest()
|
||||||
updated = current.replace(old_text, new_text) if replace_all else current.replace(old_text, new_text, 1)
|
updated = current.replace(old_text, new_text) if replace_all else current.replace(old_text, new_text, 1)
|
||||||
target.write_text(updated, encoding='utf-8')
|
target.write_text(updated, encoding='utf-8')
|
||||||
|
_chown_to_runtime_user(target, context)
|
||||||
rel = _display_path(target, context)
|
rel = _display_path(target, context)
|
||||||
replaced = occurrences if replace_all else 1
|
replaced = occurrences if replace_all else 1
|
||||||
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
||||||
@@ -2091,6 +2123,7 @@ def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) ->
|
|||||||
cell.setdefault('execution_count', None)
|
cell.setdefault('execution_count', None)
|
||||||
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
|
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
|
||||||
target.write_text(updated, encoding='utf-8')
|
target.write_text(updated, encoding='utf-8')
|
||||||
|
_chown_to_runtime_user(target, context)
|
||||||
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
||||||
rel = _display_path(target, context)
|
rel = _display_path(target, context)
|
||||||
return (
|
return (
|
||||||
@@ -2360,7 +2393,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env=_build_subprocess_env(context),
|
env=_build_subprocess_env(context),
|
||||||
**_subprocess_isolation_kwargs(),
|
**_subprocess_execution_kwargs(context),
|
||||||
)
|
)
|
||||||
_register_child_process(context, process)
|
_register_child_process(context, process)
|
||||||
if stdin and process.stdin is not None:
|
if stdin and process.stdin is not None:
|
||||||
@@ -2482,6 +2515,24 @@ def _subprocess_isolation_kwargs() -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_execution_kwargs(context: ToolExecutionContext) -> dict[str, Any]:
|
||||||
|
runtime_user = (context.runtime_user or '').strip()
|
||||||
|
if not runtime_user or os.name != 'posix':
|
||||||
|
return _subprocess_isolation_kwargs()
|
||||||
|
try:
|
||||||
|
user_info = pwd.getpwnam(runtime_user)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Linux runtime user not found: {runtime_user}') from exc
|
||||||
|
|
||||||
|
def demote() -> None:
|
||||||
|
os.setsid()
|
||||||
|
os.initgroups(runtime_user, user_info.pw_gid)
|
||||||
|
os.setgid(user_info.pw_gid)
|
||||||
|
os.setuid(user_info.pw_uid)
|
||||||
|
|
||||||
|
return {'preexec_fn': demote}
|
||||||
|
|
||||||
|
|
||||||
def _terminate_process(process: subprocess.Popen[Any]) -> None:
|
def _terminate_process(process: subprocess.Popen[Any]) -> None:
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
return
|
return
|
||||||
@@ -2616,6 +2667,7 @@ def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
env=_build_subprocess_env(context),
|
env=_build_subprocess_env(context),
|
||||||
|
**_subprocess_execution_kwargs(context),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
stdout = exc.stdout if isinstance(exc.stdout, str) else ''
|
stdout = exc.stdout if isinstance(exc.stdout, str) else ''
|
||||||
@@ -2729,6 +2781,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=context.command_timeout_seconds,
|
timeout=context.command_timeout_seconds,
|
||||||
env=_build_subprocess_env(context),
|
env=_build_subprocess_env(context),
|
||||||
|
**_subprocess_execution_kwargs(context),
|
||||||
)
|
)
|
||||||
stdout = completed.stdout or ''
|
stdout = completed.stdout or ''
|
||||||
stderr = completed.stderr or ''
|
stderr = completed.stderr or ''
|
||||||
@@ -4063,13 +4116,13 @@ def _stream_bash(
|
|||||||
command,
|
command,
|
||||||
shell=True,
|
shell=True,
|
||||||
executable='/bin/bash',
|
executable='/bin/bash',
|
||||||
cwd=context.root,
|
cwd=_execution_cwd(context),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=_build_subprocess_env(context),
|
env=_build_subprocess_env(context),
|
||||||
**_subprocess_isolation_kwargs(),
|
**_subprocess_execution_kwargs(context),
|
||||||
)
|
)
|
||||||
_register_child_process(context, process)
|
_register_child_process(context, process)
|
||||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ class AgentRuntimeConfig:
|
|||||||
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve())
|
||||||
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve())
|
||||||
python_env_dir: Path | None = None
|
python_env_dir: Path | None = None
|
||||||
|
runtime_user: str | None = None
|
||||||
enabled_skill_names: tuple[str, ...] | None = None
|
enabled_skill_names: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict:
|
|||||||
if runtime_config.python_env_dir is not None
|
if runtime_config.python_env_dir is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
'runtime_user': runtime_config.runtime_user,
|
||||||
'enabled_skill_names': (
|
'enabled_skill_names': (
|
||||||
list(runtime_config.enabled_skill_names)
|
list(runtime_config.enabled_skill_names)
|
||||||
if runtime_config.enabled_skill_names is not None
|
if runtime_config.enabled_skill_names is not None
|
||||||
@@ -256,6 +257,11 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
|
|||||||
if payload.get('python_env_dir') is not None
|
if payload.get('python_env_dir') is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
runtime_user=(
|
||||||
|
str(payload['runtime_user'])
|
||||||
|
if payload.get('runtime_user') is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
enabled_skill_names=enabled_skill_names,
|
enabled_skill_names=enabled_skill_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user