From ee596a33e02b4a7c56d1671457b023b6755c9073 Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Wed, 20 May 2026 12:29:11 +0800 Subject: [PATCH] Add Linux account workspace runtime --- README.md | 49 +- backend/api/server.py | 159 ++++- .../11-workspace-runtime.md | 663 ++++++++++++++++++ frontend/app/app/api/chat/route.ts | 6 +- frontend/app/lib/claw-auth.ts | 132 +++- scripts/deploy-ubuntu.sh | 116 ++- scripts/update-server-fast.sh | 22 +- src/agent_tool_core.py | 2 + src/agent_tools.py | 59 +- src/agent_types.py | 1 + src/session_store.py | 6 + 11 files changed, 1178 insertions(+), 37 deletions(-) create mode 100644 docs/technical-architecture/11-workspace-runtime.md diff --git a/README.md b/README.md index 79b5eac..c9d72d0 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,22 @@ bash "$HOME/zk-data-agent/scripts/install-from-git.sh" - 安装前端依赖并构建。 - 安装并启动用户级 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//zk-agent/`。 +- `python_exec`、`python_package`、`bash` 会在对应 Linux 用户身份下执行。 +- Jupyter 远程工作区保持现有逻辑,不参与本机 Linux 用户隔离。 + `.env.deploy` 会保存: ```text @@ -595,6 +611,8 @@ CLAW_BACKEND_PORT CLAW_FRONTEND_HOST CLAW_FRONTEND_PORT CLAW_API_URL +CLAW_SERVICE_SCOPE +CLAW_ENABLE_LINUX_ACCOUNTS CLAW_NPM_BIN CLAW_NPX_BIN CLAW_NODE_BIN @@ -618,6 +636,12 @@ bash scripts/update-server-fast.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 @@ -632,7 +656,7 @@ bash scripts/deploy-ubuntu.sh main --force ### 服务管理 -部署脚本会安装两个用户级 systemd 服务: +部署脚本默认安装用户级 systemd 服务;以 root 执行或指定 `--system-service` 时安装 system 级服务。服务名默认是: ```text zk-data-agent-backend @@ -646,6 +670,13 @@ systemctl --user status zk-data-agent-backend systemctl --user status zk-data-agent-frontend ``` +system 级服务使用: + +```bash +systemctl status zk-data-agent-backend +systemctl status zk-data-agent-frontend +``` + 查看日志: ```bash @@ -653,12 +684,25 @@ journalctl --user -u zk-data-agent-backend -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 systemctl --user restart zk-data-agent-backend zk-data-agent-frontend ``` +system 级重启使用: + +```bash +systemctl restart zk-data-agent-backend zk-data-agent-frontend +``` + 停止服务: ```bash @@ -683,6 +727,7 @@ Ubuntu 部署建议: - Python `3.10.14` - Node.js `20` 或 `22` - `npm` +- 启用 Linux 账号工作区时,还需要 `passwd`、`python3`、`python3-venv`,并要求服务以 root/systemd system 方式运行。 首次安装如果缺 Python 编译依赖,可以执行: @@ -690,7 +735,7 @@ Ubuntu 部署建议: bash scripts/deploy-ubuntu.sh --bootstrap-system ``` -`--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。 +`--bootstrap-system` 会使用 `sudo apt-get` 安装系统依赖;普通模式下应用代码、虚拟环境、前端依赖、运行数据和 systemd 用户服务仍然位于当前用户目录。启用 `--enable-linux-accounts` 后,账号工作区位于 `/home//zk-agent/`。 ## 开发验证 diff --git a/backend/api/server.py b/backend/api/server.py index 206e082..6fdd383 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -11,6 +11,7 @@ import csv import hashlib import json import os +import pwd import queue import re import shutil @@ -67,6 +68,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 @@ -626,6 +630,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]: @@ -825,7 +831,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, @@ -837,6 +851,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), ) model_config = ModelConfig( @@ -936,12 +951,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) @@ -3785,6 +3825,107 @@ def _safe_account_id(account_id: str | None) -> str: 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: 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) 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( @@ -3941,6 +4084,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, @@ -3984,8 +4129,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} diff --git a/docs/technical-architecture/11-workspace-runtime.md b/docs/technical-architecture/11-workspace-runtime.md new file mode 100644 index 0000000..9e599a2 --- /dev/null +++ b/docs/technical-architecture/11-workspace-runtime.md @@ -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//zk-agent/sessions//output/a.jsonl +jupyter:///root/zk_agent_workspaces//output/a.jsonl +ssh:///home/user/zk_agent_workspaces//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//sessions// +``` + +适合: + +- 本地开发。 +- 单用户调试。 +- 早期兼容。 + +问题: + +- 多用户隔离主要靠代码路径约束。 +- 工具进程和平台服务权限一致,风险较高。 + +### local_linux_user + +平台托管的标准多用户工作区。 + +```text +平台账号: banisherwy +Linux runtime user: banisherwy +workspace root: /home/banisherwy/zk-agent/sessions/ +``` + +服务进程可以是 root,工具进程切换到普通 Linux 用户执行。 + +```text +root backend + -> runuser -u banisherwy -- +``` + +职责分工: + +```text +root 服务 + 创建 runtime 用户 + 初始化 workspace + 设置 owner 和权限 + 启停进程 + 管理平台账号和 session + +普通 Linux 用户 + 执行 bash/python + 拥有自己的 workspace + 拥有自己的 Python 虚拟环境 + 只能写自己的目录 +``` + +推荐目录: + +```text +/home//zk-agent/ + sessions/ + / + 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//zk-agent/` 数据。 +- root 服务创建用户和改密码时必须走受控 helper,不能把用户输入拼成 shell 命令。 + +### remote_jupyter + +用户授权的远程工作区。 + +语义是: + +```text +用户把自己已有权限的 Jupyter 环境接入平台。 +平台代替用户在这个环境里执行。 +``` + +这不是平台托管沙盒。权限边界来自用户提供的 Jupyter 凭证。 + +```text +Account: banisherwy +Session: xxx +Runtime: remote_jupyter +Root: /root/zk_agent_workspaces/ +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//zk-agent/sessions//input/ + +remote_jupyter + 上传文件 -> 通过 Jupyter API 写入 /root/zk_agent_workspaces//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//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/ + 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/` 视为 `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//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//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//zk-agent。 +``` + +长期建议: + +```text +平台账号负责产品身份。 +Workspace Runtime 负责执行环境。 +Artifact 负责跨 runtime 文件抽象。 +Executor 负责工具执行适配。 +``` + +这样账户体系、Linux 子账户、Jupyter/SSH 远程工作区、文件下载、Python 环境和工具执行可以合到一个统一设计里,而不是继续各自生长。 diff --git a/frontend/app/app/api/chat/route.ts b/frontend/app/app/api/chat/route.ts index 927743b..fca7a2e 100644 --- a/frontend/app/app/api/chat/route.ts +++ b/frontend/app/app/api/chat/route.ts @@ -11,6 +11,7 @@ import { accountSessionOutputRoot, accountSessionRoot, accountSessionScratchpadRoot, + chownAccountPath, getCurrentAccount, } from "@/lib/claw-auth"; @@ -334,14 +335,16 @@ async function getLastUserText( } async function ensureSessionDirectories(accountId: string, sessionId: string) { + const sessionRoot = accountSessionRoot(accountId, sessionId); await Promise.all([ - mkdir(accountSessionRoot(accountId, sessionId), { recursive: true }), + mkdir(sessionRoot, { recursive: true }), mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }), mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }), mkdir(accountSessionScratchpadRoot(accountId, sessionId), { recursive: true, }), ]); + await chownAccountPath(accountId, sessionRoot, true); } function renderSessionRuntimeContext(accountId: string, sessionId: string) { @@ -394,6 +397,7 @@ async function saveFilePart( await mkdir(uploadDir, { recursive: true }); const filePath = path.join(uploadDir, `${Date.now()}-${filename}`); await writeFile(filePath, bytes); + await chownAccountPath(accountId, filePath); return [ `[文件已上传] ${filename}`, diff --git a/frontend/app/lib/claw-auth.ts b/frontend/app/lib/claw-auth.ts index e5346d7..493c69e 100644 --- a/frontend/app/lib/claw-auth.ts +++ b/frontend/app/lib/claw-auth.ts @@ -1,17 +1,19 @@ +import { spawn } from "node:child_process"; import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { cookies } from "next/headers"; -const AUTH_ROOT = path.join( +const AUTH_STATE_ROOT = path.join( /* turbopackIgnore: true */ process.cwd(), "../../.port_sessions/accounts", ); -const USERS_PATH = path.join(AUTH_ROOT, "users.json"); -const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json"); +const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json"); +const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json"); const COOKIE_NAME = "claw_account_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000; +const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent"; type UserRecord = { id: string; @@ -51,6 +53,7 @@ export async function registerAccount(username: string, password: string) { createdAt: new Date().toISOString(), }; usersFile.users.push(account); + await ensureLinuxAccount(account.id, password); await writeUsers(usersFile); await createAccountDirectories(account.id); return createSession(account); @@ -65,6 +68,7 @@ export async function loginAccount(username: string, password: string) { if (!account || !verifyPassword(password, account.passwordHash)) { throw new Error("账号或密码错误"); } + await ensureLinuxAccount(account.id, password); await createAccountDirectories(account.id); return createSession(account); } @@ -101,15 +105,18 @@ export async function getCurrentAccount(): Promise { } export function accountUploadRoot(accountId: string) { - return path.join(AUTH_ROOT, accountId, "sessions"); + return path.join(accountBaseRoot(accountId), "sessions"); } export function accountOutputRoot(accountId: string) { - return path.join(AUTH_ROOT, accountId, "sessions"); + return path.join(accountBaseRoot(accountId), "sessions"); } 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) { @@ -161,15 +168,18 @@ async function setSessionCookie(token: string) { } async function createAccountDirectories(accountId: string) { - await Promise.all( - ["sessions"].map((name) => - mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }), - ), - ); + const base = accountBaseRoot(accountId); + await Promise.all([ + 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 { - await mkdir(AUTH_ROOT, { recursive: true }); + await mkdir(AUTH_STATE_ROOT, { recursive: true }); try { return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile; } catch { @@ -178,12 +188,12 @@ async function readUsers(): Promise { } 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"); } async function readSessions(): Promise { - await mkdir(AUTH_ROOT, { recursive: true }); + await mkdir(AUTH_STATE_ROOT, { recursive: true }); try { return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile; } catch { @@ -192,12 +202,20 @@ async function readSessions(): Promise { } 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"); } function normalizeUsername(username: string) { 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)) { throw new Error( "账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40", @@ -230,3 +248,87 @@ function shouldRefreshSession(value?: string) { if (Number.isNaN(timestamp)) return true; 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(); + }, + ); +} diff --git a/scripts/deploy-ubuntu.sh b/scripts/deploy-ubuntu.sh index 284ba4f..52532c1 100755 --- a/scripts/deploy-ubuntu.sh +++ b/scripts/deploy-ubuntu.sh @@ -3,7 +3,7 @@ set -euo pipefail # Ubuntu 一键部署/更新脚本。 # - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。 -# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启用户级 systemd 服务。 +# - 后续执行会拉取代码、更新依赖、构建前端、安装/重启 systemd 服务。 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" +SYSTEM_SYSTEMD_DIR="/etc/systemd/system" BRANCH="" FORCE=0 SKIP_GIT=0 BOOTSTRAP_SYSTEM=0 +SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}" +ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-}" usage() { cat <\033[0m %s\n" "$1" } @@ -77,6 +101,16 @@ configure_instance_names() { DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" 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() { @@ -120,6 +154,9 @@ bootstrap_system_packages() { libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \ systemd + if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" ]]; then + sudo apt-get install -y passwd python3 python3-venv + fi } 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_PORT=$(printf "%q" "${frontend_port}") 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 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() { if [[ -f "${ENV_FILE}" ]]; then # shellcheck disable=SC1090 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 + 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 fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。" fi @@ -197,6 +261,8 @@ ensure_env_file() { echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}" echo "OPENAI_MODEL=${OPENAI_MODEL:-}" 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=已配置" return fi @@ -214,6 +280,9 @@ ensure_env_file() { prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1" prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro" 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_port "请输入后端端口" "8765" prompt_value frontend_host "请输入前端监听地址" "0.0.0.0" @@ -304,14 +373,21 @@ EOF install_systemd_service() { local service_name="$1" 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 tmp_path="$(mktemp)" - mkdir -p "${USER_SYSTEMD_DIR}" + mkdir -p "${target_dir}" sed \ -e "s#__PROJECT_ROOT__#${ROOT_DIR}#g" \ -e "s#__BACKEND_SERVICE__#${BACKEND_SERVICE}#g" \ -e "s#__FRONTEND_SERVICE__#${FRONTEND_SERVICE}#g" \ + -e "s#WantedBy=default.target#WantedBy=${wanted_by}#g" \ "${template_path}" >"${tmp_path}" install -m 0644 "${tmp_path}" "${target_path}" rm -f "${tmp_path}" @@ -319,17 +395,30 @@ install_systemd_service() { install_services() { 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 "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template" - systemctl --user daemon-reload - systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" + if [[ "${SERVICE_SCOPE}" == "system" ]]; then + systemctl daemon-reload + systemctl enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" + else + systemctl --user daemon-reload + systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" + fi } restart_services() { log "重启服务" - systemctl --user restart "${BACKEND_SERVICE}" - systemctl --user restart "${FRONTEND_SERVICE}" + if [[ "${SERVICE_SCOPE}" == "system" ]]; then + systemctl restart "${BACKEND_SERVICE}" + systemctl restart "${FRONTEND_SERVICE}" + else + systemctl --user restart "${BACKEND_SERVICE}" + systemctl --user restart "${FRONTEND_SERVICE}" + fi } health_check() { @@ -348,8 +437,13 @@ health_check() { sleep 0.5 done warn "健康检查失败,输出最近日志。" - journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true - journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true + if [[ "${SERVICE_SCOPE}" == "system" ]]; then + 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 } diff --git a/scripts/update-server-fast.sh b/scripts/update-server-fast.sh index 0e4c29d..8ac9ae8 100755 --- a/scripts/update-server-fast.sh +++ b/scripts/update-server-fast.sh @@ -11,6 +11,7 @@ ENV_FILE="${ROOT_DIR}/.env.deploy" DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" +SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}" BRANCH="${1:-}" log() { @@ -26,6 +27,13 @@ configure_instance_names() { DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" 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() { @@ -99,12 +107,20 @@ npm_bin="$(resolve_npm_bin)" export PATH="$(dirname "${npm_bin}"):${PATH}" "${npm_bin}" run build -log "重启用户服务" -systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" +log "重启 ${SERVICE_SCOPE} 服务" +if [[ "${SERVICE_SCOPE}" == "system" ]]; then + systemctl restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" +else + systemctl --user restart "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" +fi log "健康检查" 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 "快速更新完成。" diff --git a/src/agent_tool_core.py b/src/agent_tool_core.py index 0ec8e0f..183e10d 100644 --- a/src/agent_tool_core.py +++ b/src/agent_tool_core.py @@ -40,6 +40,7 @@ class ToolExecutionContext: permissions: AgentPermissions scratchpad_directory: Path | None = None python_env_dir: Path | None = None + runtime_user: str | None = None extra_env: dict[str, str] = field(default_factory=dict) cancel_event: Any | None = None process_registry: Any | None = None @@ -162,6 +163,7 @@ def build_tool_context( if config.python_env_dir else None ), + runtime_user=config.runtime_user, extra_env=dict(extra_env or {}), cancel_event=cancel_event, process_registry=process_registry, diff --git a/src/agent_tools.py b/src/agent_tools.py index 550548e..8327039 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -6,6 +6,7 @@ import hashlib import io import json import os +import pwd import re import selectors import shutil @@ -1644,6 +1645,8 @@ def _resolve_path( else context.root / expanded ) resolved = candidate.resolve(strict=not allow_missing) + if _is_session_workspace_path(resolved, context): + return resolved try: resolved.relative_to(context.root) except ValueError as exc: @@ -1674,6 +1677,17 @@ def _session_logical_path(path: Path, context: ToolExecutionContext) -> Path | N 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: resolved = path.resolve() try: @@ -1687,10 +1701,25 @@ def _execution_cwd(context: ToolExecutionContext) -> Path: return Path(context.jupyter_runtime.binding.workspace_cwd) if context.scratchpad_directory is not None: context.scratchpad_directory.mkdir(parents=True, exist_ok=True) + _chown_to_runtime_user(context.scratchpad_directory, context) return context.scratchpad_directory 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: 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(): raise ToolExecutionError(f'Path exists and is not a file: {target}') 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 target.write_text(final_text, encoding='utf-8') + _chown_to_runtime_user(target, context) rel = _display_path(target, context) new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest() 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() 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') + _chown_to_runtime_user(target, context) rel = _display_path(target, context) replaced = occurrences if replace_all else 1 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) updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n' target.write_text(updated, encoding='utf-8') + _chown_to_runtime_user(target, context) after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest() rel = _display_path(target, context) return ( @@ -2360,7 +2393,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) - stderr=subprocess.PIPE, text=True, env=_build_subprocess_env(context), - **_subprocess_isolation_kwargs(), + **_subprocess_execution_kwargs(context), ) _register_child_process(context, process) if stdin and process.stdin is not None: @@ -2482,6 +2515,24 @@ def _subprocess_isolation_kwargs() -> dict[str, Any]: 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: if process.poll() is not None: return @@ -2616,6 +2667,7 @@ def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext text=True, timeout=timeout_seconds, env=_build_subprocess_env(context), + **_subprocess_execution_kwargs(context), ) except subprocess.TimeoutExpired as exc: 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, timeout=context.command_timeout_seconds, env=_build_subprocess_env(context), + **_subprocess_execution_kwargs(context), ) stdout = completed.stdout or '' stderr = completed.stderr or '' @@ -4063,13 +4116,13 @@ def _stream_bash( command, shell=True, executable='/bin/bash', - cwd=context.root, + cwd=_execution_cwd(context), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, env=_build_subprocess_env(context), - **_subprocess_isolation_kwargs(), + **_subprocess_execution_kwargs(context), ) _register_child_process(context, process) except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc: diff --git a/src/agent_types.py b/src/agent_types.py index 2678956..895d173 100644 --- a/src/agent_types.py +++ b/src/agent_types.py @@ -171,6 +171,7 @@ class AgentRuntimeConfig: session_directory: Path = field(default_factory=lambda: (Path('.port_sessions') / 'agent').resolve()) scratchpad_root: Path = field(default_factory=lambda: (Path('.port_sessions') / 'scratchpad').resolve()) python_env_dir: Path | None = None + runtime_user: str | None = None enabled_skill_names: tuple[str, ...] | None = None diff --git a/src/session_store.py b/src/session_store.py index abf67e3..25ec176 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -192,6 +192,7 @@ def serialize_runtime_config(runtime_config: AgentRuntimeConfig) -> JSONDict: if runtime_config.python_env_dir is not None else None ), + 'runtime_user': runtime_config.runtime_user, 'enabled_skill_names': ( list(runtime_config.enabled_skill_names) 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 else None ), + runtime_user=( + str(payload['runtime_user']) + if payload.get('runtime_user') is not None + else None + ), enabled_skill_names=enabled_skill_names, )