diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index a0f9ec0..fdeb84f 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -34,7 +34,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Run tests run: python3 -m unittest discover -s tests -v - name: Compile Python sources @@ -57,18 +59,20 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Configure Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Build online packages run: | cp install.sh pages/install.sh cp scripts/install.ps1 pages/install.ps1 python3 scripts/build-online.py - name: Upload site artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: pages - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27be60f..7a73b70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,13 +8,15 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Validate tag and version shell: bash run: | @@ -27,14 +29,16 @@ jobs: online: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Build online packages run: | cp install.sh pages/install.sh cp scripts/install.ps1 pages/install.ps1 python3 scripts/build-online.py cp install.sh scripts/install.ps1 VERSION pages/ - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: online path: | @@ -46,7 +50,9 @@ jobs: offline-linux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Build verified Codex offline package run: AGENTS=codex PLATFORMS=linux-x64 TAG="v$(cat VERSION)" sh scripts/build-offline.sh - name: Smoke install, execute, and uninstall @@ -63,7 +69,7 @@ jobs: test ! -e "$root/home/.agentboot/agents/codex" mv "dist/AgentBoot-offline-${tag}-linux-x64.tar.gz" "dist/AgentBoot-offline-${tag}-linux-x64-codex.tar.gz" mv "dist/AgentBoot-offline-${tag}-linux-x64-sfx.sh" "dist/AgentBoot-offline-${tag}-linux-x64-codex-sfx.sh" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: offline-linux path: dist/AgentBoot-offline-*-linux-x64-codex* @@ -71,7 +77,9 @@ jobs: offline-windows: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Build verified Codex offline package shell: powershell run: .\scripts\build-offline.ps1 -Tag ('v' + (Get-Content VERSION -Raw).Trim()) -Platforms win-x64 -Agents codex @@ -80,7 +88,7 @@ jobs: run: | $root = Join-Path $env:RUNNER_TEMP 'agentboot-smoke' $extract = Join-Path $root 'extract' - $smokeHome = Join-Path $root 'home' + $smokeHome = Join-Path $root '测试-home' New-Item -ItemType Directory -Path $extract, $smokeHome -Force | Out-Null $tag = 'v' + (Get-Content VERSION -Raw).Trim() tar -xf "dist\AgentBoot-offline-$tag-win-x64.zip" -C $extract @@ -93,17 +101,42 @@ jobs: python "$env:LOCALAPPDATA\AgentBoot\app\core\menu.py" uninstall codex if (Test-Path "$smokeHome\.agentboot\agents\codex") { throw 'Codex payload not removed' } Move-Item "dist\AgentBoot-offline-$tag-win-x64.zip" "dist\AgentBoot-offline-$tag-win-x64-codex.zip" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: offline-windows path: dist/AgentBoot-offline-*-win-x64-codex.zip + macos-smoke: + runs-on: macos-15-intel + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Build and smoke native macOS Codex package + shell: bash + run: | + AGENTS=codex PLATFORMS=darwin-x64 TAG="v$(cat VERSION)" sh scripts/build-offline.sh + root="$(mktemp -d)" + mkdir -p "$root/extract" "$root/home" + tag="v$(cat VERSION)" + tar -xzf "dist/AgentBoot-offline-${tag}-darwin-x64.tar.gz" -C "$root/extract" + HOME="$root/home" sh "$root/extract/AgentBoot/install-offline.sh" codex + HOME="$root/home" AGENTBOOT_HOME="$root/home/.agentboot" "$root/home/.agentboot/bin/codex" --version + HOME="$root/home" AGENTBOOT_HOME="$root/home/.agentboot" python3 "$root/home/.agentboot/app/core/menu.py" uninstall codex + test ! -e "$root/home/.agentboot/agents/codex" + publish: - needs: [validate, online, offline-linux, offline-windows] + needs: [validate, online, offline-linux, offline-windows, macos-smoke] if: github.ref_type == 'tag' runs-on: ubuntu-latest + environment: release-production + permissions: + contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: release merge-multiple: true @@ -113,12 +146,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" release/* \ - --repo "$GITHUB_REPOSITORY" \ - --title "AgentBoot $GITHUB_REF_NAME" \ - --prerelease \ - --generate-notes \ - --verify-tag + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" release/* --repo "$GITHUB_REPOSITORY" --clobber + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --prerelease --latest=false + else + gh release create "$GITHUB_REF_NAME" release/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "AgentBoot $GITHUB_REF_NAME" \ + --prerelease \ + --generate-notes \ + --verify-tag + fi - name: Wait for Pages deployment env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 913432d..a179219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ - 修复 Windows CMD shim 百分号格式、npm 真实入口解析和便携 Node 路径,Linux/Windows 均通过原生安装→启动→卸载矩阵。 - Worker 支持 Range/If-Range 与真实资产健康探测;发布采用 prerelease→Pages/Worker live verify→Latest 的协调状态机。 - 修正 Node engine 范围、离线资产名、相对链接、vLLM 文案、移动端表格溢出、触摸目标和旧性能固定数字。 +- 安全模式改为可信系统路径的固定 argv 执行,禁止 shell grammar、路径伪装与未知确认策略放行。 +- 截断/畸形 SSE 不再产生可执行工具调用;模型密钥仅发送到 HTTPS,网页工具默认拒绝内网/元数据地址。 +- 配置与会话使用 0700/0600 原子持久化,命令超时终止整个进程树,安装状态更新加入跨进程锁。 +- CoCo 卸载拒绝 symlink 根目录,私有 Node 在最终目录部署;Hermes uv、Node 与 Python 载荷完整校验。 +- Release/Pages Actions 固定 commit、最小权限、显式源码清单;离线 Agent 更新与应用/launcher 事务支持回滚。 +- Aider 改为 AgentBoot 私有 venv 安装;OpenCode 暂停离线声明,npm 离线入口按 JS/native/CMD 类型执行。 +- Agent配置、代理、会话和安装清单改为私密原子持久化;并发安装状态加锁,命令超时终止整棵进程树。 +- 在线/离线应用、launcher与Agent payload提交边界支持回滚;Windows中文路径、PowerShell 3哈希和批处理错误码修复。 +- 发布矩阵新增Intel macOS原生Codex smoke;同源安装包增加成员/大小检查,离线构建只复制显式文件清单。 ## v1.0.0 (2026-08-29) diff --git a/README.en.md b/README.en.md index ee50399..2bf69b2 100644 --- a/README.en.md +++ b/README.en.md @@ -51,7 +51,7 @@ Two commands after install: | 📦 **Choose what to install** | 14 mainstream agents, multi-select in the menu | | 🛟 **Built-in fallback agent** | `ab`: zero third-party dependencies, Agnes by default, offline Linux knowledge base, session persistence | | 🧠 **Model provider manager** | Named custom providers, Ollama/LM Studio presets, failover order, connectivity test | -| 🇨🇳 **China network adaptive** | npmmirror / Node mirrors / Tsinghua PyPI, four-source downloads, proxy support | +| 🇨🇳 **China network adaptive** | npmmirror / Node mirrors / Tsinghua PyPI, Worker/Pages/Release fallback, proxy support | | 📴 **Verified offline packages** | Releases provide Codex slim packs tested through install/run/uninstall; menu `[7]` builds other Agents on their target platform | | ➕ **Custom agents** | Add anything beyond the registry (npm / pip / script), stored in your home dir | | 🧹 **Safe uninstall** | Menu `[9]` or `agentboot uninstall `; removes owned program files and preserves user data by default | @@ -63,7 +63,7 @@ Two commands after install: | # | Agent | Command | Vendor | Offline | |---|---|---|---|---| | 1 | CoCo Agent | `coco` | BitCook | Linux/macOS | -| 2 | OpenCode | `opencode` | opencode.ai | ✓ | +| 2 | OpenCode | `opencode` | opencode.ai | online only (postinstall not yet offline-verified) | | 3 | Hermes Agent | `hermes` | Hermes | ✓ (needs Git) | | 4 | Cline CLI | `cline` | Cline | ✓ | | 5 | CodeBuddy CLI | `codebuddy` | Tencent | ✓ | diff --git a/README.md b/README.md index 477dd97..f04f1c3 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.Web | 📦 **菜单自选安装** | 14 个主流 Agent 按需勾选(见下表),支持命令行指定 | | 🛟 **内置保底 Agent** | `ab` 零第三方依赖 Python 核心:Agnes 开箱即用、离线 Linux 知识库、工具调用、会话持久化 | | 🧠 **提供商管理器** | Agnes 预设 + 自定义提供商命名管理 + Ollama/LM Studio 本地模型 + 故障切换顺序 | -| 🇨🇳 **中国网络自适应** | npmmirror / Node 镜像 / 清华 PyPI 自动切换;四源下载容错;代理一键配置 | +| 🇨🇳 **中国网络自适应** | npmmirror / Node 镜像 / 清华 PyPI 自动切换;Worker / Pages / Release 三源容错;代理一键配置 | | 📴 **可验证离线包** | Release 提供经安装/启动/卸载冒烟的 Codex 精简包;菜单 `[7]` 可按目标平台自建其他 Agent 包 | | ➕ **自定义 Agent** | 菜单向导或 `add-agent` 添加注册表之外的任意 Agent(npm / pip / 脚本),用户目录保存、升级不丢 | | 🧹 **安全卸载** | 菜单 `[9]` 或 `agentboot uninstall `;精确清理程序,默认保留配置、认证与会话 | @@ -63,7 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.Web | # | Agent | 命令 | 厂商 | 离线 | 备注 | |---|---|---|---|---|---| | 1 | CoCo Agent | `coco` | BitCook | Linux/macOS | 官方脚本安装 | -| 2 | OpenCode | `opencode` | opencode.ai | ✓ | | +| 2 | OpenCode | `opencode` | opencode.ai | 仅在线 | postinstall 尚未纳入离线验证 | | 3 | Hermes Agent | `hermes` | Hermes | ✓ | 需 Git;国内自动镜像 | | 4 | Cline CLI | `cline` | Cline | ✓ | | | 5 | CodeBuddy CLI | `codebuddy` | Tencent | ✓ | | @@ -146,7 +146,7 @@ AgentBoot/ ## 文档与链接 - [安装指南](安装指南.md) —— 一键安装 / 离线部署 / 自定义构建 / 模型配置 / 故障排查 -- [Releases](https://github.com/bit-cook/AgentBoot/releases) —— 在线包 / 三平台离线包 / 源码包 +- [Releases](https://github.com/bit-cook/AgentBoot/releases) —— 在线包 / 已验证 Linux、Windows Codex 离线包 / 源码包 - 分发入口:[boot.ide.pub](https://boot.ide.pub)(Cloudflare)· [GitHub Pages](https://bit-cook.github.io/AgentBoot/) ## 安全说明 diff --git a/agents/registry.json b/agents/registry.json index 94b2598..e30244f 100644 --- a/agents/registry.json +++ b/agents/registry.json @@ -24,7 +24,7 @@ "method": "npm", "npm": "opencode-ai", "node": ">=18", - "offline": true, + "offline": false, "npm_install_flags": ["--ignore-scripts"], "script": "https://opencode.ai/install", "notes": ["已预置 Agnes 免费模型(~/.config/opencode/opencode.json),安装即用", "也可用官方脚本安装:curl -fsSL https://opencode.ai/install | bash"] @@ -169,11 +169,11 @@ "vendor": "Aider AI", "desc": "经典 AI 结对编程 CLI(Python/pip 生态,暂不支持离线)", "bin": "aider", - "method": "pip", - "pip": "aider-install", + "method": "venv", + "pip": "aider-chat", "node": null, "offline": false, - "notes": ["通过 aider-install 安装,需要本机 Python 3.8+", "pip 包的离线分发需要按平台打包 wheel,暂未支持"] + "notes": ["安装到 AgentBoot 私有 venv,不污染系统 Python", "需要 Python 3.10+;暂不支持离线"] } ] } diff --git a/cloudflare/worker.js b/cloudflare/worker.js index 092ef4d..a66de95 100644 --- a/cloudflare/worker.js +++ b/cloudflare/worker.js @@ -76,7 +76,7 @@ async function proxy(target, contentType, cacheTtl, incoming = null) { cf: { cacheEverything: !requestHeaders.has("Range"), cacheTtl }, headers: requestHeaders, }); - if (!resp.ok) { + if (!resp.ok && resp.status !== 304) { return text(`上游不可用(${resp.status}):${target}\n请稍后重试或使用 GitHub 直链。\n`, 502); } const headers = new Headers(resp.headers); @@ -162,7 +162,7 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14 -
+

AgentBoot

@@ -181,7 +181,7 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14

Windows(PowerShell)

powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))"
-
备用入口:GitHub Pages(bit-cook.github.io/AgentBoot)/ GitHub Releases / 国内加速镜像 —— 安装脚本内自动按序多源重试。
+
安装器只使用项目控制的 Worker / GitHub Pages / GitHub Release 三源,并对包与同源 SHA-256 一起校验。

🚀 三步上手

    @@ -195,17 +195,17 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14
    📦 菜单自选安装(不是全家桶)

    14 个主流 Agent 按需勾选:Claude Code、Codex、Qwen Code、OpenCode、CodeBuddy、MiMo、Cline、Pi、CoCo…

    🛟 内置保底 Agent(ab)

    其他都装不上时它一定能用:零第三方依赖 Python 核心、Agnes 免费模型、离线 Linux 知识库、会话持久化。

    🧠 模型提供商管理器

    Agnes 零配置开箱;自定义提供商命名管理;Ollama / LM Studio 本地模型;故障切换顺序。

    -
    🇨🇳 中国网络自适应

    自动探测并切换 npmmirror / Node 镜像 / 清华 PyPI;四源下载容错;代理一键配置。

    +
    🇨🇳 中国网络自适应

    自动切换 npm / Node / PyPI 镜像;Worker / Pages / Release 三源容错;代理一键配置。

    📴 已验证离线 & 按需构建

    Release 精简包通过真实安装、启动与卸载冒烟;菜单 [7] 可在目标平台自选 Agent 构建。

    🧹 可追溯安全卸载

    菜单 [9] 或 uninstall 命令批量卸载;只清理由 AgentBoot 管理的程序,默认保留配置、认证与会话。

    ⚡ 可测性能

    TLS 连接复用、知识库预建索引、上下文自动瘦身与流式中断保护;/bench 按当前网络与模型现场测量。

    -
+

🤖 支持的 Agent(14 个)

- + @@ -241,7 +241,7 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14
@@ -328,7 +328,7 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14 -
+

English | 中文

@@ -347,7 +347,7 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14

Windows (PowerShell)

powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))"
-
Fallback entries: GitHub Pages (this page) / GitHub Releases / China mirrors — the installer retries sources in order automatically. The CLI UI is Chinese by default; language can be switched to English (agentboot lang en).
+
The installer uses project-controlled Worker / GitHub Pages / GitHub Release origins and verifies each archive against its same-origin SHA-256.

🚀 Three steps

    @@ -361,17 +361,17 @@ pre{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14
    📦 Choose what to install (no bundle bloat)

    14 mainstream agents to pick from: Claude Code, Codex, Qwen Code, OpenCode, CodeBuddy, MiMo, Cline, Pi, CoCo…

    🛟 Built-in fallback agent (ab)

    A zero-third-party-dependency Python core with Agnes, an offline Linux knowledge base, and session persistence.

    🧠 Model provider manager

    Agnes out of the box; named custom providers; Ollama / LM Studio local models; failover order.

    -
    🇨🇳 China network adaptive

    Auto-detects and switches to npmmirror / Node mirrors / Tsinghua PyPI; four-source download retry; one-click proxy.

    +
    🇨🇳 China network adaptive

    npm / Node / PyPI mirrors, Worker/Pages/Release fallback, and one-click proxy configuration.

    📴 Verified offline & custom builds

    Release packs pass real install/run/uninstall smoke tests; menu [7] builds selected Agents on their target platform.

    🧹 Ownership-aware uninstall

    Menu [9] or the uninstall command removes AgentBoot-managed programs in batches while preserving config, credentials, and sessions by default.

    ⚡ Measurable performance

    TLS reuse, pre-indexed KB, context trimming, and stream protection; /bench measures the current network and provider.

    -
+

🤖 Supported agents (14)

#Agent命令厂商离线
1CoCo AgentcocoBitCookLinux/macOS
2OpenCodeopencodeopencode.ai
2OpenCodeopencodeopencode.ai仅在线
3Hermes AgenthermesHermes✓(需 Git)
4Cline CLIclineCline
5CodeBuddy CLIcodebuddyTencent
- + diff --git a/core/agent.py b/core/agent.py index 2db8226..bc68f99 100644 --- a/core/agent.py +++ b/core/agent.py @@ -11,10 +11,13 @@ * 工具:run_cmd / read_file / write_file / edit_file / list_dir / linux_help / http_get """ import json +import ipaddress import os import re import shlex +import socket import sys +import tempfile import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -48,8 +51,11 @@ def _utf8_console(): def ensure_home(): - if not os.path.isdir(AB_HOME): - os.makedirs(AB_HOME, exist_ok=True) + os.makedirs(AB_HOME, mode=0o700, exist_ok=True) + try: + os.chmod(AB_HOME, 0o700) + except OSError: + pass # Agnes 为官方预设的永久免费模型,开箱即用;其余为常见本地模型示例。 @@ -97,6 +103,21 @@ def load_config(): cfg = json.load(f) except Exception: cfg = default_config() + if not isinstance(cfg, dict): + cfg = default_config() + if not isinstance(cfg.get("providers"), dict): + cfg["providers"] = {} + confirm = str(cfg.get("confirm", "smart")).strip().lower() + cfg["confirm"] = confirm if confirm in ("safe", "smart", "always") else "smart" + raw_steps = cfg.get("max_steps", 12) + try: + steps = int(raw_steps) + except (TypeError, ValueError): + steps = 12 + cfg["max_steps"] = 12 if steps < 1 else min(steps, 50) + cfg["lang"] = "en" if str(cfg.get("lang", "zh")).lower().startswith("en") else "zh" + if not isinstance(cfg.get("fallback", []), list): + cfg["fallback"] = [] for k, v in default_config().items(): cfg.setdefault(k, v) return cfg @@ -104,10 +125,33 @@ def load_config(): def save_config(cfg): ensure_home() - tmp = CONFIG_PATH + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(cfg, f, ensure_ascii=False, indent=2) - os.replace(tmp, CONFIG_PATH) + _atomic_private_json(CONFIG_PATH, cfg) + + +def _atomic_private_json(path, data): + directory = os.path.dirname(path) or "." + os.makedirs(directory, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".", suffix=".tmp", dir=directory) + try: + try: + os.fchmod(fd, 0o600) + except (AttributeError, OSError): + pass + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(data, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + finally: + try: + os.remove(tmp) + except OSError: + pass def get_provider(cfg, name=None): @@ -143,10 +187,31 @@ def _split_base(base_url): if not u.endswith("/v1"): u += "/v1" s = urlsplit(u) + if s.scheme not in ("http", "https"): + raise ApiError("模型接口仅支持 https;本地无密钥模型可使用回环 http。") + if not s.hostname: + raise ApiError("模型接口地址缺少主机名。") port = s.port or (443 if s.scheme == "https" else 80) return s.scheme, s.hostname, port, (s.path or "") + "/chat/completions" +def _is_literal_loopback(host): + if str(host or "").lower() == "localhost": + return True + try: + return ipaddress.ip_address(str(host).strip("[]")).is_loopback + except ValueError: + return False + + +def _validate_model_transport(scheme, host, api_key): + if scheme == "https": + return + if scheme == "http" and _is_literal_loopback(host) and not api_key: + return + raise ApiError("拒绝不安全的模型接口:仅允许 HTTPS,或无 API Key 的 localhost/回环 HTTP。") + + # 连接池:复用 TLS 连接,砍掉每轮对话的握手开销(极限性能核心) _POOL = {} @@ -154,7 +219,19 @@ def _split_base(base_url): def _connect(scheme, host, port, timeout=180): import http.client import ssl - key = (scheme, host, port) + from urllib.parse import urlsplit + proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") + if not proxy_url: + try: + env_path = os.path.join(AB_HOME, "env.json") + with open(env_path, "r", encoding="utf-8") as source: + proxy_url = (json.load(source) or {}).get("proxy") + except Exception: + proxy_url = None + proxy = urlsplit(proxy_url) if proxy_url else None + if proxy and (proxy.scheme not in ("http", "https") or not proxy.hostname): + raise ApiError("代理地址无效,仅支持 http/https。") + key = (scheme, host, port, proxy_url or "") conn = _POOL.get(key) if conn is not None: return conn @@ -162,16 +239,31 @@ def _connect(scheme, host, port, timeout=180): ctx = ssl.create_default_context() if os.environ.get("AGENTBOOT_INSECURE") == "1": ctx = ssl._create_unverified_context() - conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx) - else: + if proxy: + conn = http.client.HTTPSConnection(proxy.hostname, proxy.port or (443 if proxy.scheme == "https" else 80), + timeout=timeout, context=ctx) + tunnel_headers = {} + if proxy.username: + import base64 + raw = "%s:%s" % (proxy.username, proxy.password or "") + tunnel_headers["Proxy-Authorization"] = "Basic " + base64.b64encode(raw.encode()).decode() + conn.set_tunnel(host, port, headers=tunnel_headers) + else: + conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx) + elif scheme == "http": conn = http.client.HTTPConnection(host, port, timeout=timeout) + else: + raise ApiError("不支持的模型接口协议:%s" % scheme) _POOL[key] = conn return conn def _drop_pool(scheme, host, port): - conn = _POOL.pop((scheme, host, port), None) - if conn is not None: + keys = [key for key in _POOL if key[:3] == (scheme, host, port)] + for key in keys: + conn = _POOL.pop(key, None) + if conn is None: + continue try: conn.close() except Exception: @@ -188,6 +280,7 @@ def chat(cfg, messages, stream_cb=None, tools=None, max_tokens=None, temperature scheme, host, port, path = _split_base(p.get("base_url", "")) if not host: raise ApiError("模型接口地址为空,请先运行 `ab model` 或在菜单里配置模型。") + _validate_model_transport(scheme, host, p.get("api_key")) body = {"model": p.get("model"), "messages": messages, "temperature": temperature} if tools: @@ -230,6 +323,13 @@ def chat(cfg, messages, stream_cb=None, tools=None, max_tokens=None, temperature msg = choice.get("message") or {} return msg.get("content") or "", msg.get("tool_calls") or [] return _read_stream(resp, stream_cb, conn, scheme, host, port) + except StreamInterrupted as e: + last_err = e + _drop_pool(scheme, host, port) + if attempt < 2: + time.sleep(1 + attempt) + continue + raise ApiError("模型流在完成前中断,请重试。") except ApiError: raise except Exception as e: # 网络类错误:连接可能已坏,弃用后重建重试 @@ -243,6 +343,8 @@ def chat(cfg, messages, stream_cb=None, tools=None, max_tokens=None, temperature def _read_stream(resp, stream_cb, conn, scheme, host, port): content_parts = [] tool_calls = {} + terminal = False + malformed = False def flush_tc(): out = [] @@ -265,12 +367,16 @@ def flush_tc(): continue data = line[5:].strip() if data == "[DONE]": + terminal = True break try: obj = json.loads(data) except Exception: - continue + malformed = True + break for choice in obj.get("choices") or []: + if choice.get("finish_reason") is not None: + terminal = True delta = choice.get("delta") or {} piece = delta.get("content") if piece: @@ -294,10 +400,25 @@ def flush_tc(): partial = "".join(content_parts) _drop_pool(scheme, host, port) if partial: - return partial, flush_tc() # 尽力而为:返回已收到的部分内容 + raise ApiError("模型流中断(已收到部分文本,未执行任何工具)。") + raise StreamInterrupted("") + if malformed: + _drop_pool(scheme, host, port) + raise ApiError("模型流包含无效 JSON,已拒绝处理。") + if not terminal: + _drop_pool(scheme, host, port) + if content_parts: + raise ApiError("模型流在完成前中断(已收到部分文本,未执行任何工具)。") raise StreamInterrupted("") content = "".join(content_parts) tcs = flush_tc() + for tc in tcs: + try: + parsed = json.loads(tc["function"]["arguments"] or "{}") + except (TypeError, ValueError): + raise ApiError("模型返回了不完整的工具参数 JSON,已拒绝执行。") + if not tc["function"]["name"] or not isinstance(parsed, dict): + raise ApiError("模型返回了无效的工具参数,已拒绝执行。") if not content and not tcs: raise ApiError("模型返回为空(流式)。") return content, tcs @@ -382,10 +503,9 @@ def _shrink(msgs, budget=60000, keep_recent=8): def save_session(history): try: - os.makedirs(AB_HOME, exist_ok=True) - with open(SESSION_FILE, "w", encoding="utf-8") as f: - json.dump({"saved": time.strftime("%Y-%m-%d %H:%M"), - "history": history[-12:]}, f, ensure_ascii=False) + ensure_home() + _atomic_private_json(SESSION_FILE, {"saved": time.strftime("%Y-%m-%d %H:%M"), + "history": history[-12:]}) except OSError: pass @@ -420,7 +540,10 @@ def bench(cfg): def test_provider(cfg, name=None): """连通性测试:让模型回一个字。""" try: - content, _ = chat(cfg, [{"role": "user", "content": "请只回复两个字:正常"}], + target = dict(cfg) + if name: + target["active"] = name + content, _ = chat(target, [{"role": "user", "content": "请只回复两个字:正常"}], stream_cb=None, max_tokens=16, temperature=0) return True, (content or "").strip()[:40] or "(空响应)" except Exception as e: @@ -510,7 +633,7 @@ def linux_help(query): "journalctl", "dmesg", "man", "apropos", "top", "vmstat", "iostat", "sar", "netstat", "getenforce", "sestatus", # Windows 常见只读命令 - "dir", "type", "ipconfig", "systeminfo", "tasklist", "ver", "whoami", + "ipconfig", "systeminfo", "tasklist", "whoami", "netstat", "where", "driverquery", "hostname", } @@ -555,11 +678,13 @@ def _simple_command_level(segment): words.pop(0) if not words: return "normal" - first = os.path.basename(words[0]).lower() + if os.path.basename(words[0]) != words[0] or "/" in words[0] or "\\" in words[0]: + return "normal" + first = words[0].lower() args = [str(word).lower() for word in words[1:]] if first in ("sh", "bash", "zsh", "dash", "cmd", "powershell", "pwsh", "env"): return "normal" - if first == "find" and any(arg in ("-delete", "-exec", "-execdir", "-ok", "-okdir") for arg in args): + if first == "find" and any(arg == "-delete" or arg.startswith(("-exec", "-ok", "-fprint", "-fls")) for arg in args): return "normal" if first in MUTATING_FLAGS: for arg in words[1:]: @@ -583,29 +708,108 @@ def classify_cmd(cmd): for pat in DANGER_RE: if re.search(pat, c, re.IGNORECASE): return "danger" - if re.search(r"`|\$\(|\$\(\(|[<>]\(|\$\{|(^|[^<])>{1,2}|\btee\b", c): + if re.search(r"[;&|<>\r\n]|`|\$\(|\$\(\(|\$\{|\btee\b", c): return "normal" - segments = [part.strip() for part in re.split(r"(?:&&|\|\||[;|\n])", c) if part.strip()] - levels = [_simple_command_level(segment) for segment in segments] - return "safe" if levels and all(level == "safe" for level in levels) else "normal" + return _simple_command_level(c) + + +def _trusted_executable(name): + """Resolve a bare allowlisted command from OS-owned directories only.""" + import shutil + if not name or os.path.basename(name) != name or "/" in name or "\\" in name: + return None + if os.name == "nt": + root = os.environ.get("SystemRoot", r"C:\Windows") + search = [os.path.join(root, "System32"), root] + else: + search = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] + path = shutil.which(name, path=os.pathsep.join(search)) + return os.path.realpath(path) if path else None + + +def safe_command_argv(cmd): + if classify_cmd(cmd) != "safe": + return None + try: + words = shlex.split(cmd, posix=os.name != "nt") + except ValueError: + return None + if not words: + return None + executable = _trusted_executable(words[0]) + return [executable] + words[1:] if executable else None + + +def run_safe_cmd(cmd, timeout=60): + import subprocess + argv = safe_command_argv(cmd) + if not argv: + return "exit=126\n拒绝执行无法从可信系统目录解析的只读命令。" + timeout = min(max(int(timeout or 60), 5), 300) + try: + result = subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=timeout) + output = ((result.stdout or "") + (("\n[stderr] " + result.stderr) if result.stderr.strip() else "")).strip() + return "exit=%d\n%s" % (result.returncode, output[:8000] or "(无输出)") + except subprocess.TimeoutExpired: + return "exit=124\n(只读命令超时 %ss,已终止)" % timeout + except OSError as error: + return "exit=126\n启动只读命令失败:%s" % error def run_cmd(cmd, timeout=60): import subprocess # 惰性导入:保持启动极速 timeout = min(max(int(timeout or 60), 5), 300) shell = ["cmd", "/c", cmd] if os.name == "nt" else ["/bin/sh", "-c", cmd] + out_file = tempfile.TemporaryFile(mode="w+b") + err_file = tempfile.TemporaryFile(mode="w+b") try: - r = subprocess.run(shell, capture_output=True, text=True, - errors="replace", timeout=timeout) - out = ((r.stdout or "") + (("\n[stderr] " + r.stderr) if r.stderr.strip() else "")).strip() - code = r.returncode + kwargs = {"stdout": out_file, "stderr": err_file} + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + process = subprocess.Popen(shell, **kwargs) + try: + process.wait(timeout=timeout) + code = process.returncode + except subprocess.TimeoutExpired: + code = 124 + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + import signal + try: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=2) + except Exception: + try: + os.killpg(process.pid, signal.SIGKILL) + except OSError: + pass + process.wait() + def read_bounded(stream): + stream.flush() + size = stream.tell() + stream.seek(0) + if size <= 8000: + return stream.read().decode("utf-8", "replace") + first = stream.read(4000).decode("utf-8", "replace") + stream.seek(max(0, size - 4000)) + last = stream.read(4000).decode("utf-8", "replace") + return first + "\n…(输出过长,已截断中间部分)…\n" + last + stdout = read_bounded(out_file) + stderr = read_bounded(err_file) + out = (stdout + (("\n[stderr] " + stderr) if stderr.strip() else "")).strip() + if code == 124: + out = "(命令超时 %ss,已终止整个进程树)\n%s" % (timeout, out) except subprocess.TimeoutExpired: out, code = "(命令超时 %ss,已终止)" % timeout, 124 except FileNotFoundError as e: out, code = "启动 shell 失败:%s" % e, 127 - if len(out) > 8000: - half = 4000 - out = out[:half] + "\n…(输出过长,已截断中间部分)…\n" + out[-half:] + finally: + out_file.close() + err_file.close() return "exit=%d\n%s" % (code, out or "(无输出)") @@ -718,20 +922,44 @@ def search_files(pattern, path=".", regex=False, max_results=40): return "\n".join(out) if out else "(无匹配)" +def _validate_public_http_url(url): + from urllib.parse import urlsplit + parsed = urlsplit(url or "") + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("仅支持不含凭据的 http/https 公网地址") + try: + addresses = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), + type=socket.SOCK_STREAM) + except OSError as error: + raise ValueError("域名解析失败:%s" % error) + if not addresses: + raise ValueError("域名没有可用地址") + for item in addresses: + address = ipaddress.ip_address(item[4][0]) + if not address.is_global: + raise ValueError("拒绝访问非公网地址:%s" % address) + return parsed + + def http_get(url): - if not re.match(r"^https?://", url or ""): - return "错误:仅支持 http/https 地址" try: - from urllib.request import Request, urlopen + from urllib.request import Request, build_opener, HTTPRedirectHandler + _validate_public_http_url(url) + + class SafeRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + _validate_public_http_url(newurl) + return HTTPRedirectHandler.redirect_request(self, req, fp, code, msg, headers, newurl) + req = Request(url, headers={"User-Agent": "AgentBoot/1.0"}) - with urlopen(req, timeout=15) as r: + with build_opener(SafeRedirect()).open(req, timeout=15) as r: data = r.read(300000) text = data.decode("utf-8", "replace") if len(data) >= 299000: text += "\n…(已截断)" return text if text.strip() else "(空响应)" except Exception as e: - return "抓取失败:%s" % e + return "抓取失败或已拒绝:%s" % e # ---------------------------------------------------------------- 工具 schema(OpenAI 格式) @@ -761,7 +989,9 @@ def execute_tool(cfg, name, args, session_allow): if name == "run_cmd": cmd = args.get("command", "") level = classify_cmd(cmd) - policy = cfg.get("confirm", "smart") + policy = str(cfg.get("confirm", "smart")).strip().lower() + if policy not in ("safe", "smart", "always"): + policy = "smart" if policy == "safe" and level != "safe": return "safe 模式只允许只读命令,已拒绝:%s" % cmd, level == "danger" if level == "danger" and policy != "always" and cmd not in session_allow: @@ -776,11 +1006,15 @@ def execute_tool(cfg, name, args, session_allow): return "用户拒绝了该命令。", False else: return "非交互模式无法确认写操作,已拒绝;如需自动执行请设置 confirm=always。", False + if level == "safe": + return run_safe_cmd(cmd, args.get("timeout")), False return run_cmd(cmd, args.get("timeout")), level == "danger" if name == "read_file": return read_file(args.get("path", "")), False if name == "write_file": - policy = cfg.get("confirm", "smart") + policy = str(cfg.get("confirm", "smart")).strip().lower() + if policy not in ("safe", "smart", "always"): + policy = "smart" key = "write_file:%s" % args.get("path", "") if policy == "safe": return "safe 模式只允许只读工具,已拒绝写入文件。", False @@ -795,7 +1029,9 @@ def execute_tool(cfg, name, args, session_allow): return "用户拒绝了文件写入。", False return write_file(args.get("path", ""), args.get("content", "")), False if name == "edit_file": - policy = cfg.get("confirm", "smart") + policy = str(cfg.get("confirm", "smart")).strip().lower() + if policy not in ("safe", "smart", "always"): + policy = "smart" key = "edit_file:%s" % args.get("path", "") if policy == "safe": return "safe 模式只允许只读工具,已拒绝修改文件。", False @@ -832,6 +1068,13 @@ def _is_interactive(): def system_prompt(): plat = "%s / %s" % (platform_info(), sys.platform) + if i18n.get_lang() == "en": + return ( + "You are AgentBoot's built-in terminal assistant running locally on %s.\n" + "Use tools for commands, files, the offline Linux knowledge base, and public web pages.\n" + "Rules: answer concisely in English; inspect with read-only tools before changing state; " + "explain destructive consequences first; put commands in code blocks; summarize verified results." % plat + ) return ( "你是 AgentBoot 内置的终端智能助手(ab),直接运行在用户本机,当前系统:%s。\n" "你可以调用工具:执行命令、读写文件、查询离线 Linux 知识库、抓取网页。\n" @@ -1146,8 +1389,8 @@ def main(): if not args: print("用法: ab run \"你的任务\"") return - final, _ = agent_loop(cfg, " ".join(args), stream=(not _is_interactive())) - if final and not _is_interactive(): + final, _ = agent_loop(cfg, " ".join(args), stream=False) + if final: print(final) return if cmd == "bench": diff --git a/core/i18n.py b/core/i18n.py index b18f396..817b5ef 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -115,14 +115,8 @@ "menu.custom_saved": "已保存到 %s", "menu.pick_platforms": "选择目标平台(可多选,空格/逗号分隔;回车 = 常用三平台):", "menu.pick_offline_agents": "选择要打入离线包的 Agent(可多选;回车 = 全选):", - "menu.build_aider_hint": " (aider 为 pip 生态暂不支持离线,已自动排除)", "menu.build_plan": "即将构建:平台 %s · Agent %s", "menu.build_yn": "确认开始? [Y/n] ", - "menu.build_start": "开始构建:平台=%s · Agent=%s", - "menu.build_wait": "(首次构建会自动下载便携 Node/Python 与各 Agent 依赖,耗时取决于网速,请耐心等待)", - "menu.build_done": "构建完成,产物在 %s:", - "menu.build_fail": "构建脚本退出码 %s", - "menu.build_empty": "未选择任何 Agent", "agent.banner_model": "模型: %s @ %s(Agnes 官方免费预设,可用 /model 切换)", "agent.banner_resume": "↩ 已恢复上次会话(%d 条对话记忆,/继续 可随时恢复)", "agent.prompt": "\n你 › ", @@ -275,14 +269,8 @@ "menu.custom_saved": "Saved to %s", "menu.pick_platforms": "Pick target platforms (multi-select; Enter = common three):", "menu.pick_offline_agents": "Pick agents to include (multi-select; Enter = all):", - "menu.build_aider_hint": " (aider is pip-based, offline not supported — excluded automatically)", "menu.build_plan": "About to build: platforms %s · agents %s", "menu.build_yn": "Start? [Y/n] ", - "menu.build_start": "Building: platforms=%s · agents=%s", - "menu.build_wait": "(First build downloads portable Node/Python and agent deps; may take a while)", - "menu.build_done": "Build finished, artifacts in %s:", - "menu.build_fail": "Build script exit code %s", - "menu.build_empty": "No agent selected", "agent.banner_model": "Model: %s @ %s (Agnes free preset, /model to switch)", "agent.banner_resume": "↩ Restored last session (%d messages, /继续 to restore again)", "agent.prompt": "\nyou › ", @@ -343,8 +331,5 @@ def t(key, *args): if s is None: s = ZH.get(key, key) if args: - try: - return s % args - except Exception: - return s + return s % args return s diff --git a/core/menu.py b/core/menu.py index 05c2517..f267d82 100644 --- a/core/menu.py +++ b/core/menu.py @@ -18,6 +18,7 @@ """ import json import hashlib +from contextlib import contextmanager import os import re import shutil @@ -74,7 +75,9 @@ def plat_id(): elif m in ("arm64", "aarch64"): arch = "arm64" else: - arch = "x64" + raise RuntimeError("不支持的 CPU 架构:%s" % m) + if s not in ("linux", "darwin", "win"): + raise RuntimeError("不支持的操作系统:%s" % s) return "%s-%s" % (s, arch) @@ -117,15 +120,18 @@ def cn_mode(): def load_env_json(): try: with open(ENV_JSON, "r", encoding="utf-8") as f: - return json.load(f) + data = json.load(f) + proxy = data.get("proxy") if isinstance(data, dict) else None + if proxy: + os.environ["HTTP_PROXY"] = proxy + os.environ["HTTPS_PROXY"] = proxy + return data if isinstance(data, dict) else {} except Exception: return {} def save_env_json(data): - os.makedirs(AB_HOME, exist_ok=True) - with open(ENV_JSON, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + agent._atomic_private_json(ENV_JSON, data) def load_registry(): @@ -151,9 +157,7 @@ def load_custom_agents(): def save_custom_agents(items): - os.makedirs(AB_HOME, exist_ok=True) - with open(CUSTOM_AGENTS, "w", encoding="utf-8") as f: - json.dump(items, f, ensure_ascii=False, indent=2) + agent._atomic_private_json(CUSTOM_AGENTS, items) def load_install_state(): @@ -186,28 +190,51 @@ def save_install_state(data): pass +@contextmanager +def _install_state_lock(): + os.makedirs(AB_HOME, mode=0o700, exist_ok=True) + lock_path = INSTALL_STATE + ".lock" + with open(lock_path, "a+b") as lock: + if os.path.getsize(lock_path) == 0: + lock.write(b"\0") + lock.flush() + lock.seek(0) + if os.name == "nt": + import msvcrt + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + lock.seek(0) + if os.name == "nt": + msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + def record_install(a, source, executable=None, install_prefix=None): """记录由 AgentBoot 完成的安装,作为安全卸载的归属依据。""" - state = load_install_state() - state["version"] = 1 - state["agents"][a["id"]] = { - "name": a.get("name") or a["id"], - "bin": a.get("bin"), - "source": source, - "method": a.get("method", "npm"), - "package": a.get("npm") or a.get("pip") or a.get("script"), - "executable": executable, - "prefix": install_prefix, - "custom": bool(a.get("custom")), - "installed_at": int(time.time()), - } - save_install_state(state) + with _install_state_lock(): + state = load_install_state() + state["version"] = 1 + state["agents"][a["id"]] = { + "name": a.get("name") or a["id"], "bin": a.get("bin"), "source": source, + "method": a.get("method", "npm"), + "package": a.get("npm") or a.get("pip") or a.get("script"), + "executable": executable, "prefix": install_prefix, + "custom": bool(a.get("custom")), "installed_at": int(time.time()), + } + save_install_state(state) def forget_install(aid): - state = load_install_state() - if state["agents"].pop(aid, None) is not None: - save_install_state(state) + with _install_state_lock(): + state = load_install_state() + if state["agents"].pop(aid, None) is not None: + save_install_state(state) def custom_add_entry(entry): @@ -215,6 +242,14 @@ def custom_add_entry(entry): if not _valid_agent_id(entry.get("id")) or not _valid_bin_name(entry.get("bin")): raise ValueError("Agent id/命令名只能包含字母、数字、点、下划线与连字符") items = load_custom_agents() + registry_path = os.path.join(APP_DIR, "agents", "registry.json") + try: + with open(registry_path, "r", encoding="utf-8") as source: + builtins = {item.get("id") for item in json.load(source).get("agents", [])} + except Exception: + builtins = set() + if entry["id"] in builtins or any(item.get("id") == entry["id"] for item in items): + raise ValueError("Agent id 已存在:%s" % entry["id"]) entry["offline"] = False entry.setdefault("vendor", "自定义") items.append(entry) @@ -271,7 +306,11 @@ def custom_add_wizard(): entry["pip"] = pkg else: entry["script"] = pkg - custom_add_entry(entry) + try: + custom_add_entry(entry) + except ValueError as error: + log_err(str(error)) + return None log_ok("已保存到 %s" % CUSTOM_AGENTS) return aid @@ -524,6 +563,31 @@ def _remove_path(path): os.remove(path) +def _safe_extract_tar(archive, destination): + """Extract after rejecting traversal, device nodes, and escaping links.""" + import tarfile + root = os.path.realpath(destination) + os.makedirs(root, exist_ok=True) + for member in archive.getmembers(): + target = os.path.realpath(os.path.join(root, member.name)) + if not _inside(target, root): + raise ValueError("tar 成员越界:%s" % member.name) + if member.isdev() or member.isfifo(): + raise ValueError("tar 包含设备或管道:%s" % member.name) + if member.issym(): + link_target = os.path.realpath(os.path.join(os.path.dirname(target), member.linkname)) + if not _inside(link_target, root): + raise ValueError("tar 符号链接越界:%s" % member.name) + elif member.islnk(): + link_target = os.path.realpath(os.path.join(root, member.linkname)) + if not _inside(link_target, root): + raise ValueError("tar 硬链接越界:%s" % member.name) + if hasattr(tarfile, "data_filter"): + archive.extractall(root, filter="data") + else: + archive.extractall(root) + + def _remove_owned_shims(a, entry): candidates = [entry.get("executable")] suffix = "" if POSIX else ".cmd" @@ -550,9 +614,13 @@ def _remove_coco(purge=False): root = os.path.expanduser("~/.coco") if not os.path.exists(root): return + if os.path.islink(root) or not _inside(os.path.realpath(root), os.path.realpath(os.path.expanduser("~"))): + raise OSError("拒绝删除符号链接或用户目录之外的 CoCo 路径:%s" % root) if purge: shutil.rmtree(root) return + if not any(os.path.exists(os.path.join(root, name)) for name in ("bin", "runtime", "resources")): + raise OSError("CoCo 程序目录缺少预期结构,拒绝自动删除:%s" % root) # CoCo 的 agent/ 内含会话、认证与用户设置;默认只移除程序文件。 for name in os.listdir(root): if name == "agent": @@ -560,6 +628,33 @@ def _remove_coco(purge=False): _remove_path(os.path.join(root, name)) +def _remove_coco_external_launchers(entry): + executable = entry.get("executable") + if not executable: + return + directory = os.path.dirname(os.path.abspath(executable)) + allowed_dirs = {os.path.realpath(os.path.expanduser("~/.local/bin")), "/usr/local/bin"} + if os.path.realpath(directory) not in allowed_dirs: + return + coco_root = os.path.realpath(os.path.expanduser("~/.coco")) + suffix = ".cmd" if not POSIX else "" + for name in ("coco", "web", "coweb"): + path = os.path.join(directory, name + suffix) + if not os.path.lexists(path): + continue + owned = False + if os.path.islink(path): + owned = _inside(os.path.realpath(path), coco_root) + elif os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as source: + owned = ".coco" in source.read(1024) + except OSError: + pass + if owned: + os.remove(path) + + def uninstall_one(a, purge=False): """安全卸载一个 Agent;返回 (成功, 面向用户的说明)。""" if not _valid_agent_id(a.get("id")) or not _valid_bin_name(a.get("bin")): @@ -576,6 +671,7 @@ def uninstall_one(a, purge=False): if a["id"] == "coco": try: _remove_coco(purge) + _remove_coco_external_launchers(entry) except OSError as e: return False, str(e) elif source == "offline": @@ -587,7 +683,7 @@ def uninstall_one(a, purge=False): return False, str(e) elif method == "npm": npm = npm_cmd() - package = _npm_package_name(a.get("npm") or entry.get("package")) + package = _npm_package_name(entry.get("package") or a.get("npm")) if not npm or not package: return False, t("menu.uninstall_no_tool") % "npm" cmd = [npm, "uninstall", "-g", package] @@ -599,13 +695,15 @@ def uninstall_one(a, purge=False): return False, t("menu.uninstall_command_failed") % package elif method == "pip": py = find_python() - package = a.get("pip") or entry.get("package") + package = entry.get("package") or a.get("pip") if not py or not package: return False, t("menu.uninstall_no_tool") % "Python/pip" cmd = [py, "-m", "pip", "uninstall", "-y", package] log_info("$ %s" % " ".join(cmd)) if subprocess.run(cmd, env=child_env()).returncode != 0: return False, t("menu.uninstall_command_failed") % package + elif method == "venv": + pass else: return False, t("menu.uninstall_manual_script") @@ -715,8 +813,10 @@ def install_online(ids): ok = install_via_script(a) elif method == "pip": ok = install_via_pip(a) + elif method == "venv": + ok = install_aider_venv(a) if ok and a.get("bin"): - found = find_bin(a["bin"]) + found = aider_venv_executable() if method == "venv" else find_bin(a["bin"]) if found: env_extra, args_prefix = wire_agnes(a) executable = found @@ -740,7 +840,8 @@ def install_online(ids): fail_list.append(aid) for note in a.get("notes", []) or []: print(" ℹ %s" % note) - print("\n" + t("menu.install_summary") % (ok_list or "-", fail_list or "-")) + print("\n" + t("menu.install_summary") % + (", ".join(ok_list) or "-", ", ".join(fail_list) or "-")) if not POSIX: print(t("menu.install_hint_win")) ensure_path_registered() @@ -822,6 +923,47 @@ def install_via_pip(a): return subprocess.run(cmd).returncode == 0 +def aider_venv_executable(): + relative = os.path.join("Scripts", "aider.exe") if not POSIX else os.path.join("bin", "aider") + return os.path.join(AGENTS_DIR, "aider", "venv", relative) + + +def install_aider_venv(a): + py = find_python() + if not py: + log_err("Aider 需要 Python 3.10+") + return False + check = subprocess.run([py, "-c", "import sys; print('%d.%d'%sys.version_info[:2])"], + capture_output=True, text=True, timeout=20) + try: + version = tuple(int(x) for x in check.stdout.strip().split(".")[:2]) + except ValueError: + return False + if check.returncode or version < (3, 10): + log_err("Aider 需要 Python 3.10+,当前 %s" % check.stdout.strip()) + return False + root = os.path.join(AGENTS_DIR, "aider") + candidate, backup = root + ".new.%s" % os.getpid(), root + ".old.%s" % os.getpid() + shutil.rmtree(candidate, ignore_errors=True) + shutil.rmtree(backup, ignore_errors=True) + if subprocess.run([py, "-m", "venv", os.path.join(candidate, "venv")]).returncode: + return False + pip = os.path.join(candidate, "venv", "Scripts", "pip.exe") if not POSIX else os.path.join(candidate, "venv", "bin", "pip") + cmd = [pip, "install", "--disable-pip-version-check", a["pip"]] + if cn_mode(): cmd += ["-i", PIP_MIRROR] + if subprocess.run(cmd, env=child_env()).returncode: + shutil.rmtree(candidate, ignore_errors=True) + return False + if os.path.isdir(root): os.replace(root, backup) + try: + os.replace(candidate, root) + shutil.rmtree(backup, ignore_errors=True) + except Exception: + if os.path.isdir(backup) and not os.path.exists(root): os.replace(backup, root) + raise + return os.path.isfile(aider_venv_executable()) + + # ---------------------------------------------------------------- hermes-agent 国内专用安装 def _npm_global_root(env, npm=None): @@ -984,7 +1126,10 @@ def install_hermes_special(a): "UV_PYTHON_INSTALL_MIRROR": "https://ghfast.top/https://github.com/astral-sh/python-build-standalone/releases/download", "UV_HTTP_TIMEOUT": "180", }) - node = shutil.which("node") or node_exe() + node = node_exe() if _inside(npm, runtime_node_dir()) else shutil.which("node") + if not node or not node_ok(node, a.get("node")): + log_err("Hermes postinstall 未找到满足版本的 Node") + return False script = os.path.join(pkg_root, "scripts", "postinstall.js") r = subprocess.run([node, script], env=env) return r.returncode == 0 @@ -1048,8 +1193,13 @@ def coco_offline_install(a, payload): if not (os.path.exists(tgz) and os.path.exists(side) and os.path.exists(key_file)): log_err("CoCo 离线载荷不完整(缺 coco-0.8.0.tgz / .sha256 / agnes.key)") return False - expected = open(side, "r", encoding="utf-8").read().split()[0].lower() - h = hashlib.sha256(open(tgz, "rb").read()).hexdigest() + with open(side, "r", encoding="utf-8") as source: + expected = source.read().split()[0].lower() + hasher = hashlib.sha256() + with open(tgz, "rb") as source: + for chunk in iter(lambda: source.read(1 << 20), b""): + hasher.update(chunk) + h = hasher.hexdigest() if h != expected: log_err("CoCo 发行包 SHA-256 校验失败") return False @@ -1070,6 +1220,7 @@ def _node_ok(p): return False node_bin = shutil.which("node") + node_archive = None if node_bin and _node_ok(node_bin): log_ok("使用系统 Node") else: @@ -1078,17 +1229,7 @@ def _node_ok(p): if not ntgz: log_err("系统 Node 过旧且载荷无内置 Node 运行时") return False - runtime = os.path.join(install_dir, "runtime") - if os.path.isdir(runtime): - shutil.rmtree(runtime, ignore_errors=True) - os.makedirs(runtime, exist_ok=True) - with tarfile.open(ntgz, "r:gz") as t: - t.extractall(runtime, filter="tar") - inner = os.listdir(runtime)[0] - os.replace(os.path.join(runtime, inner), os.path.join(runtime, "node")) - node_bin = os.path.join(runtime, "node", "bin", "node") - os.chmod(node_bin, 0o755) - log_ok("使用载荷内置 Node:%s" % node_bin) + node_archive = ntgz # 备份用户 agent 配置 → 换新发行包 → 还原配置 agent_dir = os.path.join(install_dir, "agent") @@ -1106,7 +1247,7 @@ def _node_ok(p): shutil.rmtree(extract) os.makedirs(extract, exist_ok=True) with tarfile.open(tgz, "r:gz") as t: - t.extractall(extract, filter="tar") + _safe_extract_tar(t, extract) try: os.replace(os.path.join(extract, "package"), install_dir) shutil.rmtree(extract, ignore_errors=True) @@ -1120,6 +1261,22 @@ def _node_ok(p): os.makedirs(os.path.join(agent_dir, "sessions"), exist_ok=True) os.makedirs(os.path.join(agent_dir, "languages"), exist_ok=True) + if node_archive: + runtime = os.path.join(install_dir, "runtime") + shutil.rmtree(runtime, ignore_errors=True) + os.makedirs(runtime, exist_ok=True) + with tarfile.open(node_archive, "r:gz") as archive: + _safe_extract_tar(archive, runtime) + children = [name for name in os.listdir(runtime) if name != "node"] + if len(children) != 1 or not os.path.isdir(os.path.join(runtime, children[0])): + raise ValueError("CoCo Node 载荷结构无效") + os.replace(os.path.join(runtime, children[0]), os.path.join(runtime, "node")) + node_bin = os.path.join(runtime, "node", "bin", "node") + if not os.path.isfile(node_bin): + raise ValueError("CoCo Node 入口缺失") + os.chmod(node_bin, 0o755) + log_ok("使用载荷内置 Node:%s" % node_bin) + # 写配置:models 骨架 + Agnes 密钥 + 默认设置 registry_path = os.path.join(install_dir, "resources", "provider-registry.v1.json") providers = {} @@ -1137,7 +1294,8 @@ def _write_json(path, obj): _write_json(models_path, {"providers": providers}) auth_path = os.path.join(agent_dir, "auth.json") if not os.path.exists(auth_path): - agnes_key = open(key_file, "r", encoding="utf-8").read().strip() + with open(key_file, "r", encoding="utf-8") as source: + agnes_key = source.read().strip() _write_json(auth_path, {"agnes": {"type": "api_key", "key": agnes_key}}) settings_path = os.path.join(agent_dir, "settings.json") if not os.path.exists(settings_path): @@ -1253,37 +1411,55 @@ def offline_install(ids, payload_dir=None): fail_list.append(aid) continue dst = os.path.join(AGENTS_DIR, aid) - os.makedirs(dst, exist_ok=True) - dst_nm = os.path.join(dst, "node_modules") - if os.path.exists(dst_nm): - shutil.rmtree(dst_nm, ignore_errors=True) + candidate = dst + ".new.%s" % os.getpid() + backup = dst + ".old.%s" % os.getpid() + shutil.rmtree(candidate, ignore_errors=True) + shutil.rmtree(backup, ignore_errors=True) + os.makedirs(candidate, exist_ok=True) + candidate_nm = os.path.join(candidate, "node_modules") log_info(t("menu.offline_deploying") % (a["name"], dir_size_mb(src))) if POSIX: - shutil.copytree(src, dst_nm) + shutil.copytree(src, candidate_nm) else: # Windows:载荷可能含超长路径,robocopy 原生支持(exit<8 均为成功) - r = subprocess.run(["robocopy", src, dst_nm, "/E", "/NFL", "/NDL", "/NJH", "/NJS"], + r = subprocess.run(["robocopy", src, candidate_nm, "/E", "/NFL", "/NDL", "/NJH", "/NJS"], capture_output=True) if r.returncode >= 8: log_err("robocopy 部署失败(code=%d):%s" % (r.returncode, aid)) + shutil.rmtree(candidate, ignore_errors=True) fail_list.append(aid) continue + if os.path.isdir(dst): os.replace(dst, backup) + os.replace(candidate, dst) + dst_nm = os.path.join(dst, "node_modules") if aid == "hermes": - fixup_hermes_venv(os.path.join(pdir, "agents", "hermes", pid), dst_nm) + try: + fixup_hermes_venv(os.path.join(pdir, "agents", "hermes", pid), dst_nm) + except Exception as error: + log_err("Hermes 路径修复失败:%s" % error) + shutil.rmtree(dst, ignore_errors=True) + if os.path.isdir(backup): os.replace(backup, dst) + fail_list.append(aid) + continue env_extra, args_prefix = wire_agnes(a) shim = write_shim(a, env_extra, args_prefix, node_path=offline_node) if shim: + shutil.rmtree(backup, ignore_errors=True) record_install(a, "offline", os.path.join( AB_HOME, "bin", a["bin"] + ("" if POSIX else ".cmd"))) log_ok(t("menu.offline_ok") % (a["name"], a["bin"])) ok_list.append(aid) else: + shutil.rmtree(dst, ignore_errors=True) + if os.path.isdir(backup): os.replace(backup, dst) fail_list.append(aid) for note in a.get("notes", []) or []: print(" ℹ %s" % note) ensure_path_registered() - print("\n" + t("menu.install_summary") % (ok_list or "-", fail_list or "-")) - print(t("menu.install_hint_win")) + print("\n" + t("menu.install_summary") % + (", ".join(ok_list) or "-", ", ".join(fail_list) or "-")) + if not POSIX: + print(t("menu.install_hint_win")) return fail_list @@ -1332,6 +1508,22 @@ def offline_npm_entry(a): return None +def _npm_entry_kind(entry): + extension = os.path.splitext(entry)[1].lower() + if extension in (".js", ".mjs", ".cjs"): + return "node" + try: + with open(entry, "rb") as source: + first = source.readline(256).lower() + if first.startswith(b"#!") and b"node" in first: + return "node" + except OSError: + pass + if extension in (".cmd", ".bat"): + return "cmd" + return "direct" + + def write_shim(a, env_extra=None, args_prefix=None, node_path=None): """为离线安装的 Agent 生成启动 shim。""" aid, bin_ = a["id"], a["bin"] @@ -1357,8 +1549,11 @@ def write_shim(a, env_extra=None, args_prefix=None, node_path=None): if not entry: raise ValueError("未找到 %s 的真实 npm bin 入口" % aid) pre = " ".join('"%s"' % x for x in args_prefix) - lines.append('exec "%s" "%s"%s "$@"' % - (node_path, entry, (" " + pre) if pre else "")) + if _npm_entry_kind(entry) == "node": + lines.append('exec "%s" "%s"%s "$@"' % + (node_path, entry, (" " + pre) if pre else "")) + else: + lines.append('exec "%s"%s "$@"' % (entry, (" " + pre) if pre else "")) body = "\n".join(lines) + "\n" with open(path, "w", encoding="utf-8", newline="\n") as f: f.write("#!/bin/sh\n# AgentBoot shim for %s\nAB_ROOT=\"$HOME/.agentboot\"\n%s" % (aid, body)) @@ -1379,10 +1574,16 @@ def write_shim(a, env_extra=None, args_prefix=None, node_path=None): if not entry: raise ValueError("未找到 %s 的真实 npm bin 入口" % aid) pre = " ".join(args_prefix) - lines.append('"%s" "%s"%s %%*' % - (node_path, entry, (" " + pre) if pre else "")) + kind = _npm_entry_kind(entry) + if kind == "node": + lines.append('"%s" "%s"%s %%*' % + (node_path, entry, (" " + pre) if pre else "")) + elif kind == "cmd": + lines.append('call "%s"%s %%*' % (entry, (" " + pre) if pre else "")) + else: + lines.append('"%s"%s %%*' % (entry, (" " + pre) if pre else "")) body = "\r\n".join(lines) + "\r\n" - with open(path, "w", encoding="ascii", newline="") as f: + with open(path, "w", encoding="utf-8-sig", newline="") as f: f.write("@echo off\r\nrem AgentBoot shim for %s\r\n" 'set "AB_ROOT=%%USERPROFILE%%\\.agentboot"\r\n' 'if exist "%%AB_ROOT%%\\runtime\\node-win-x64\\node.exe" ' @@ -1399,9 +1600,10 @@ def ensure_path_registered(): if POSIX: block_begin = "# >>> agentboot >>>" block_end = "# <<< agentboot <<<" + npm_bin = os.path.join(NPM_PREFIX, "bin") block = "\n".join([block_begin, '# AgentBoot 添加的 PATH', - 'for _d in "$HOME/.agentboot/bin" "$HOME/.local/bin" "%s"; do' % NPM_PREFIX, + 'for _d in "$HOME/.agentboot/bin" "$HOME/.local/bin" "%s"; do' % npm_bin, ' [ -d "$_d" ] && case ":$PATH:" in *":$_d:"*) ;; *) export PATH="$_d:$PATH";; esac', 'done', 'unset _d', @@ -1413,10 +1615,15 @@ def ensure_path_registered(): if os.path.exists(rc): with open(rc, "r", encoding="utf-8") as f: content = f.read() - if block_begin in content: - continue - with open(rc, "a", encoding="utf-8") as f: - f.write("\n" + block) + if block_begin in content and block_end in content: + before, rest = content.split(block_begin, 1) + _old, after = rest.split(block_end, 1) + updated = before.rstrip("\n") + "\n" + block + after.lstrip("\n") + with open(rc, "w", encoding="utf-8") as f: + f.write(updated) + else: + with open(rc, "a", encoding="utf-8") as f: + f.write("\n" + block) except Exception: pass else: @@ -1464,8 +1671,14 @@ def set_npm_registry(url): def set_proxy(url=None): data = load_env_json() if url: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + log_err("代理地址必须是有效的 http:// 或 https:// URL") + return False data["proxy"] = url save_env_json(data) + os.environ["HTTP_PROXY"] = url + os.environ["HTTPS_PROXY"] = url if npm_cmd(): subprocess.run([npm_cmd(), "config", "set", "proxy", url], env=child_env(), capture_output=True) subprocess.run([npm_cmd(), "config", "set", "https-proxy", url], env=child_env(), capture_output=True) @@ -1476,9 +1689,12 @@ def set_proxy(url=None): print(' export HTTP_PROXY=%s HTTPS_PROXY=%s' % (url, url)) else: print(' setx HTTP_PROXY %s' % url) + return True else: data.pop("proxy", None) save_env_json(data) + os.environ.pop("HTTP_PROXY", None) + os.environ.pop("HTTPS_PROXY", None) if npm_cmd(): subprocess.run([npm_cmd(), "config", "delete", "proxy"], env=child_env(), capture_output=True) subprocess.run([npm_cmd(), "config", "delete", "https-proxy"], env=child_env(), capture_output=True) @@ -1488,6 +1704,7 @@ def set_proxy(url=None): except OSError: pass log_ok("代理已清除") + return True def write_env_scripts(url): @@ -1633,7 +1850,7 @@ def pick_platforms(): def pick_offline_agents(): """多选要打进离线包的 Agent(默认全选支持离线的)。返回 id 列表。""" - capable = [a for a in load_registry() if a.get("method") == "npm" or a.get("id") == "coco"] + capable = [a for a in load_registry() if a.get("offline")] print("\n" + t("menu.pick_offline_agents")) for i, a in enumerate(capable, 1): print(" [%2d] %-14s %-22s %s" % (i, a["id"], a["name"], a.get("desc", ""))) @@ -1793,7 +2010,7 @@ def write_online_shim(a, found, env_extra, args_prefix=None): lines += _agnes_env_cmd_lines(env_extra or {}) pre = " ".join(args_prefix) lines.append('"%s"%s %%*' % (found, (" " + pre) if pre else "")) - with open(p, "w", encoding="ascii", newline="") as f: + with open(p, "w", encoding="utf-8-sig", newline="") as f: f.write("\r\n".join(lines) + "\r\n") return True except Exception as e: @@ -1831,10 +2048,10 @@ def resolve_lang(): i18n.set_lang(lang) -def set_lang_persist(lang): +def set_lang_persist(lang, cfg=None): lang = "en" if str(lang or "").lower().startswith("en") else "zh" i18n.set_lang(lang) - cfg = agent.load_config() + cfg = cfg if isinstance(cfg, dict) else agent.load_config() cfg["lang"] = lang agent.save_config(cfg) if lang == "en": @@ -1847,7 +2064,7 @@ def lang_switch(cfg): print(i18n.t("menu.lang_title")) print(i18n.t("menu.lang_pick")) c = input(i18n.t("menu.pick")).strip() - set_lang_persist("en" if c == "2" else "zh") + set_lang_persist("en" if c == "2" else "zh", cfg) def banner(): @@ -1929,7 +2146,12 @@ def main(): doctor() elif cmd == "install": ids = [t for t in re.split(r"[,\s]+", " ".join(argv[1:])) if t and not t.startswith("-")] - install_online(ids) if ids else print("用法: menu.py install claude-code qwen-code") + if ids: + failures = install_online(ids) + if failures: + raise SystemExit(1) + else: + print("用法: menu.py install claude-code qwen-code") elif cmd == "offline": payload = None if "--payload" in argv: @@ -1984,6 +2206,9 @@ def main(): for a in items: print(" %-16s %-10s %-32s bin=%s" % (a.get("id"), a.get("method"), a.get("npm") or a.get("pip") or a.get("script"), a.get("bin"))) elif arg in ("--del", "del", "remove"): + if len(argv) < 3: + print("用法: add-agent --del ") + raise SystemExit(2) custom_remove_entry(argv[2]) log_ok("已删除自定义 Agent:%s" % argv[2]) elif len(argv) >= 4 and argv[2] in ("npm", "pip", "script"): @@ -1999,7 +2224,11 @@ def main(): entry["pip"] = pkg else: entry["script"] = pkg - custom_add_entry(entry) + try: + custom_add_entry(entry) + except ValueError as error: + log_err(str(error)) + raise SystemExit(2) log_ok("已添加自定义 Agent:%s(%s)" % (aid, pkg)) else: print(__doc__) @@ -2008,7 +2237,8 @@ def main(): if len(argv) >= 3: plats = [t for t in re.split(r"[,\s]+", argv[1]) if t] ids = [t for t in re.split(r"[,\s]+", argv[2]) if t] - build_offline_run(plats, ids) + if not build_offline_run(plats, ids): + raise SystemExit(1) else: build_offline_wizard() elif cmd in ("version", "--version"): diff --git a/docs/en/install-guide.md b/docs/en/install-guide.md index ae164ba..62263bc 100644 --- a/docs/en/install-guide.md +++ b/docs/en/install-guide.md @@ -21,7 +21,7 @@ curl -fsSL https://boot.ide.pub/install.sh | sh powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))" ``` -**Fallback entries** — the installer tries these sources in order automatically (no manual action needed): `boot.ide.pub` (Cloudflare) → GitHub Pages → GitHub Releases → China accelerators (`ghfast.top`, `gh-proxy.com`). +**Fallback entries** — the installer uses project-controlled origins only: `boot.ide.pub` → GitHub Pages → GitHub Releases, with same-origin SHA-256 enforcement. **After install** @@ -152,7 +152,7 @@ Custom packs use the same structure and install flow as Release packs. Size depe - Auto-detects `registry.npmjs.org` reachability and enables mirror mode; - npm → npmmirror, Node runtimes → npmmirror binary mirror, pip → Tsinghua mirror; -- Multi-source downloads: `boot.ide.pub` → GitHub Pages → GitHub Releases → `ghfast.top` / `gh-proxy.com`; +- Multi-source downloads: project-controlled `boot.ide.pub` → GitHub Pages → GitHub Releases, each with same-origin SHA-256; - Proxy: menu `[5]`, stored for npm and AgentBoot downloads; - Force with `AGENTBOOT_MIRROR=cn|off`. @@ -209,6 +209,10 @@ rm -rf ~/.agentboot ~/.local/bin/agentboot ~/.local/bin/ab # Linux/macOS rmdir /s /q "%USERPROFILE%\.agentboot" & rmdir /s /q "%LOCALAPPDATA%\AgentBoot" # Windows ``` +For a complete POSIX uninstall, also remove the block from `# >>> agentboot >>>` through `# <<< agentboot <<<` in `.bashrc`, `.profile`, and `.zshrc`. On Windows, remove the two AgentBoot bin entries from the user `Path`. + +Before extraction, download the Release's `SHA256SUMS.txt` and run `sha256sum -c SHA256SUMS.txt --ignore-missing`. The bundled `PAYLOAD_SHA256SUMS.txt` performs a second, per-file check after extraction. + You can also choose menu `[9] Uninstall Agents`. AgentBoot records ownership in `~/.agentboot/installed-agents.json`, so an unrelated command with the same name is never removed. Normal uninstall preserves every Agent's config and sessions; `--purge` currently has a defined data-removal boundary for CoCo only. Legacy installs are cleaned automatically only when ownership can be proven from an AgentBoot payload directory or marked shim; otherwise the command stops with manual removal guidance. ## ❓ Online install troubleshooting diff --git "a/docs/zh/\345\256\211\350\243\205\346\214\207\345\215\227.md" "b/docs/zh/\345\256\211\350\243\205\346\214\207\345\215\227.md" index 728a755..2a2fbf5 100644 --- "a/docs/zh/\345\256\211\350\243\205\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\256\211\350\243\205\346\214\207\345\215\227.md" @@ -2,7 +2,7 @@ > **版本 v1.1.0** · 支持 Linux / macOS / Windows · 界面默认中文(切换:`agentboot lang en`) > 语言 / Language: 中文(本页) | [English](../en/install-guide.md) | [根目录中文指南](../../安装指南.md) -> AgentBoot 是一个极简、极速、开箱即用的 AI Agent 启动器:内置一个保底最小 Agent(默认 Agnes 免费模型),其余 Agent(CoCo Agent、OpenCode、Hermes Agent、Cline、CodeBuddy、Pi、Claude Code、Codex、Qwen Code、MiMo Code、OpenClaw、Gemini CLI、iFlow CLI、Aider)通过**菜单自选安装**。除 Aider(pip 生态)外,**全部 Agent 支持离线安装**(离线包内置完整运行时,含 Hermes 的 Python venv 与 CoCo 的私有 Node)。 +> AgentBoot 是一个极简、极速、开箱即用的 AI Agent 启动器:内置保底 Agent,其余 Agent 通过菜单自选安装。离线能力以注册表和目标平台构建验证为准;Aider 与 OpenCode 当前仅在线安装,Hermes 必须在目标平台原生构建。 --- @@ -34,7 +34,7 @@ curl -fsSL https://bit-cook.github.io/AgentBoot/install.sh | sh # 直连 GitHub curl -fsSL https://raw.githubusercontent.com/bit-cook/AgentBoot/main/install.sh | sh # 国内加速代理 -curl -fsSL https://ghfast.top/https://raw.githubusercontent.com/bit-cook/AgentBoot/main/install.sh | sh +curl -fsSL https://bit-cook.github.io/AgentBoot/install.sh | sh ``` **安装完成后:** @@ -213,7 +213,7 @@ AgentBoot 对国内复杂网络做了开箱即用的处理: - **npm 镜像**:自动切换到 `https://registry.npmmirror.com` 安装所有 npm 类 Agent; - **Node 运行时镜像**:便携 Node 从 npmmirror 二进制镜像下载(备用 nodejs.org); - **pip 镜像**:Aider 等 Python 工具自动使用清华 PyPI 镜像; -- **多源下载容错**:安装包下载按 `boot.ide.pub(Cloudflare) → GitHub → ghfast.top → gh-proxy.com` 顺序自动重试; +- **多源下载容错**:安装包只从项目控制的 `boot.ide.pub → GitHub Pages → GitHub Release` 获取,并强制同源 SHA-256; - **代理支持**:菜单 `[5] 镜像与代理设置` 可一键为 npm 与 AgentBoot 自身配置 HTTP 代理; - **手动强制**:环境变量 `AGENTBOOT_MIRROR=cn|off` 可强制开启/关闭镜像模式(默认自动)。 @@ -280,6 +280,10 @@ rm -rf ~/.agentboot ~/.local/bin/agentboot ~/.local/bin/ab # Linux/macOS rmdir /s /q "%USERPROFILE%\.agentboot" & rmdir /s /q "%LOCALAPPDATA%\AgentBoot" # Windows ``` +POSIX 完全卸载后,还应删除 `~/.bashrc`、`~/.profile`、`~/.zshrc` 中 `# >>> agentboot >>>` 到 `# <<< agentboot <<<` 的整段;Windows 在“用户环境变量 Path”中删除 `%LOCALAPPDATA%\AgentBoot\bin` 与 `%USERPROFILE%\.agentboot\bin`。 + +下载离线包后可先验证 Release 同目录的 `SHA256SUMS.txt`:`sha256sum -c SHA256SUMS.txt --ignore-missing`;包内 `PAYLOAD_SHA256SUMS.txt` 用于解压后的逐文件二次校验。 + 也可在控制台选择菜单 `[9] 卸载 Agent`。AgentBoot 用 `~/.agentboot/installed-agents.json` 记录安装归属,避免误删系统中碰巧同名的外部命令。普通卸载保留各 Agent 的配置与会话;`--purge` 目前只定义了 CoCo 的数据清理边界。旧版遗留安装只有在 AgentBoot 能从离线载荷目录或专属 shim 证明归属时才会自动清理;无法证明时会停止并给出手工卸载提示。 diff --git a/install.bat b/install.bat index a28f548..315b5d3 100644 --- a/install.bat +++ b/install.bat @@ -10,6 +10,8 @@ echo -------------------------------- echo 即将下载并安装 AgentBoot 到 %%LOCALAPPDATA%%\AgentBoot echo. powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))" +set "AB_EXIT=%ERRORLEVEL%" echo. echo 如果上方出现错误,请检查网络后重试,或使用离线安装包(见项目《安装指南.md》)。 pause +exit /b %AB_EXIT% diff --git a/install.sh b/install.sh index 5f9125e..5727b0f 100755 --- a/install.sh +++ b/install.sh @@ -61,25 +61,41 @@ step "AgentBoot 在线安装 ${TAG} · $(uname -s) $(uname -m)" # 在替换 app 前先保护用户已有的同名命令,避免应用已升级但 launcher 更新失败。 for launcher in "${BIN_DIR}/agentboot" "${BIN_DIR}/ab"; do - if [ -e "$launcher" ] && ! grep -q "AgentBoot" "$launcher" 2>/dev/null; then + if [ -L "$launcher" ]; then + err "拒绝覆盖符号链接命令:$launcher" + exit 1 + fi + if [ -e "$launcher" ] && ! grep -q '^# AgentBoot ' "$launcher" 2>/dev/null; then err "拒绝覆盖不属于 AgentBoot 的命令:$launcher" exit 1 fi done -# ---------- 1. 下载在线包(多源容错:Cloudflare → GitHub → 国内加速镜像) ---------- +# ---------- 1. 下载在线包(项目控制的三源:Worker → Pages → GitHub Release) ---------- TMP="$(mktemp -d 2>/dev/null || echo /tmp/agentboot-install-$$)" mkdir -p "$TMP" STAGE="${TMP}/src" mkdir -p "$STAGE" +OLD_APP="" +SWAP_COMMITTED=0 +cleanup_install() { + code=$? + trap - EXIT HUP INT TERM + rm -f "${BIN_DIR}/.agentboot.new.$$" "${BIN_DIR}/.ab.new.$$" + if [ "$code" -ne 0 ] && [ "$SWAP_COMMITTED" -eq 0 ] && [ -n "$OLD_APP" ] && [ -d "$OLD_APP" ]; then + rm -rf "$APP_DIR" + mv "$OLD_APP" "$APP_DIR" || true + fi + rm -rf "$TMP" + exit "$code" +} +trap cleanup_install EXIT HUP INT TERM dl_ok="" for url in \ "${BOOT_BASE}/rel/${TARBALL}" \ "https://bit-cook.github.io/AgentBoot/${TARBALL}" \ - "${GH}/${TARBALL}" \ - "https://ghfast.top/${GH}/${TARBALL}" \ - "https://gh-proxy.com/${GH}/${TARBALL}" + "${GH}/${TARBALL}" do say "下载:${url}" if fetch "$url" "${TMP}/${TARBALL}" && fetch "${url}.sha256" "${TMP}/${TARBALL}.sha256"; then @@ -96,9 +112,16 @@ if [ -z "$dl_ok" ]; then err "所有下载源均失败。请检查网络,或使用离线安装包(见项目文档《安装指南.md》)。" exit 1 fi +[ "$(wc -c < "${TMP}/${TARBALL}")" -le 20971520 ] || { err "在线包异常过大"; exit 1; } # ---------- 2. 解压(系统自带 tar,无需安装解压软件) ---------- step "解压安装包" +members="${TMP}/members.txt" +tar -tzf "${TMP}/${TARBALL}" > "$members" || { err "无法读取安装包目录"; exit 1; } +[ "$(wc -l < "$members")" -le 20000 ] || { err "安装包文件数量异常"; exit 1; } +if awk 'BEGIN{bad=0} /^\//{bad=1} /(^|\/)\.\.($|\/)/{bad=1} END{exit bad?0:1}' "$members"; then + err "安装包包含越界路径"; exit 1 +fi if ! tar -xzf "${TMP}/${TARBALL}" -C "$STAGE"; then err "解压失败:下载文件可能不完整。" exit 1 @@ -108,6 +131,24 @@ if [ "$(ls -A "$STAGE" | wc -l)" = "1" ] && [ -d "$STAGE/$(ls -A "$STAGE")" ]; t SRC_DIR="$STAGE/$(ls -A "$STAGE")" fi +# 在提交应用目录前确认可执行的 Python 3;失败时现有安装保持不变。 +PY="$(command -v python3 || true)" +if [ -z "$PY" ] && command -v python >/dev/null 2>&1 && python -c 'import sys; raise SystemExit(sys.version_info[0] != 3)' >/dev/null 2>&1; then + PY="$(command -v python)" +fi +if [ -z "$PY" ]; then + step "未检测到 Python3,尝试自动安装" + if command -v apt-get >/dev/null 2>&1; then (sudo apt-get update -y && sudo apt-get install -y python3) >/dev/null 2>&1 || true + elif command -v dnf >/dev/null 2>&1; then (sudo dnf install -y python3) >/dev/null 2>&1 || true + elif command -v yum >/dev/null 2>&1; then (sudo yum install -y python3) >/dev/null 2>&1 || true + elif command -v pacman >/dev/null 2>&1; then (sudo pacman -Sy --noconfirm python) >/dev/null 2>&1 || true + elif command -v apk >/dev/null 2>&1; then (apk add --no-cache python3) >/dev/null 2>&1 || true + elif command -v brew >/dev/null 2>&1; then (brew install python3) >/dev/null 2>&1 || true + fi + PY="$(command -v python3 || true)" +fi +[ -n "$PY" ] || { err "未能准备 Python3,保留现有版本并退出。"; exit 1; } + step "安装程序到 ${APP_DIR}" mkdir -p "$AB_ROOT" NEW_APP="${AB_ROOT}/app.new.$$" @@ -122,7 +163,7 @@ if [ ! -f "$NEW_APP/core/menu.py" ] || [ ! -f "$NEW_APP/core/agent.py" ]; then fi if [ -d "$APP_DIR" ]; then mv "$APP_DIR" "$OLD_APP"; fi if mv "$NEW_APP" "$APP_DIR"; then - rm -rf "$OLD_APP" + : else err "切换新版本失败,正在恢复旧版本" [ -d "$OLD_APP" ] && mv "$OLD_APP" "$APP_DIR" @@ -134,19 +175,26 @@ chmod +x "${APP_DIR}/install.sh" 2>/dev/null || true # ---------- 3. 生成命令行入口 ---------- step "创建命令:agentboot(控制台) / ab(内置 Agent)" mkdir -p "$BIN_DIR" -cat > "${BIN_DIR}/agentboot" < "$agentboot_tmp" < "${BIN_DIR}/ab" < "$ab_tmp" </dev/null 2>&1; then - (sudo apt-get update -y && sudo apt-get install -y python3) >/dev/null 2>&1 || true - elif command -v dnf >/dev/null 2>&1; then - (sudo dnf install -y python3) >/dev/null 2>&1 || true - elif command -v yum >/dev/null 2>&1; then - (sudo yum install -y python3) >/dev/null 2>&1 || true - elif command -v pacman >/dev/null 2>&1; then - (sudo pacman -Sy --noconfirm python) >/dev/null 2>&1 || true - elif command -v apk >/dev/null 2>&1; then - (apk add --no-cache python3) >/dev/null 2>&1 || true - elif command -v brew >/dev/null 2>&1; then - (brew install python3) >/dev/null 2>&1 || true - fi - PY="$(command -v python3 || command -v python || true)" - if [ -z "$PY" ]; then - err "未能自动安装 Python3。请手动安装后运行:agentboot" - fi -fi - # ---------- 6. 体检 ---------- -if [ -n "$PY" ]; then - step "环境体检" - "$PY" "${APP_DIR}/core/agent.py" doctor >/dev/null 2>&1 || true - "$PY" "${APP_DIR}/core/agent.py" doctor 2>/dev/null || true -fi +step "环境体检" +"$PY" "${APP_DIR}/core/agent.py" doctor >/dev/null 2>&1 || true +"$PY" "${APP_DIR}/core/agent.py" doctor 2>/dev/null || true # ---------- 7. 完成 ---------- say "" diff --git a/pages/agentboot-online-v1.1.0.tar.gz b/pages/agentboot-online-v1.1.0.tar.gz index 7f777b7..27a2f93 100644 Binary files a/pages/agentboot-online-v1.1.0.tar.gz and b/pages/agentboot-online-v1.1.0.tar.gz differ diff --git a/pages/agentboot-online-v1.1.0.tar.gz.sha256 b/pages/agentboot-online-v1.1.0.tar.gz.sha256 index 0ff205b..af8f13b 100644 --- a/pages/agentboot-online-v1.1.0.tar.gz.sha256 +++ b/pages/agentboot-online-v1.1.0.tar.gz.sha256 @@ -1 +1 @@ -44472aad3a91b06b4853c66bbbcc71602fb859e768c5a57c4868dae36331a4c7 agentboot-online-v1.1.0.tar.gz +a597f71008061bfb8c71ceae1934e08e1dac638f02219c19eb0822950db1320e agentboot-online-v1.1.0.tar.gz diff --git a/pages/agentboot-online-v1.1.0.zip b/pages/agentboot-online-v1.1.0.zip index d9be262..f656f16 100644 Binary files a/pages/agentboot-online-v1.1.0.zip and b/pages/agentboot-online-v1.1.0.zip differ diff --git a/pages/agentboot-online-v1.1.0.zip.sha256 b/pages/agentboot-online-v1.1.0.zip.sha256 index 00baf34..e9b8874 100644 --- a/pages/agentboot-online-v1.1.0.zip.sha256 +++ b/pages/agentboot-online-v1.1.0.zip.sha256 @@ -1 +1 @@ -c29250773048c2083e5f7df2a7b0bb16a5db3b187be3ffc14d3b053803848f0d agentboot-online-v1.1.0.zip +83d19577916aed010dd8780a9aab2c94157e07693be2aa4fc5a5fd3fa5856d06 agentboot-online-v1.1.0.zip diff --git a/pages/en/index.html b/pages/en/index.html index f921a1b..9bb8216 100644 --- a/pages/en/index.html +++ b/pages/en/index.html @@ -60,7 +60,7 @@ -
+

English | 中文

@@ -79,7 +79,7 @@

Linux / macOS

Windows (PowerShell)

powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))"
-
Fallback entries: GitHub Pages (this page) / GitHub Releases / China mirrors — the installer retries sources in order automatically. The CLI UI is Chinese by default; language can be switched to English (agentboot lang en).
+
The installer uses project-controlled Worker / GitHub Pages / GitHub Release origins and verifies each archive against its same-origin SHA-256.

🚀 Three steps

    @@ -93,17 +93,17 @@

    ✨ Features

    📦 Choose what to install (no bundle bloat)

    14 mainstream agents to pick from: Claude Code, Codex, Qwen Code, OpenCode, CodeBuddy, MiMo, Cline, Pi, CoCo…

    🛟 Built-in fallback agent (ab)

    A zero-third-party-dependency Python core with Agnes, an offline Linux knowledge base, and session persistence.

    🧠 Model provider manager

    Agnes out of the box; named custom providers; Ollama / LM Studio local models; failover order.

    -
    🇨🇳 China network adaptive

    Auto-detects and switches to npmmirror / Node mirrors / Tsinghua PyPI; four-source download retry; one-click proxy.

    +
    🇨🇳 China network adaptive

    npm / Node / PyPI mirrors, Worker/Pages/Release fallback, and one-click proxy configuration.

    📴 Verified offline & custom builds

    Release packs pass real install/run/uninstall smoke tests; menu [7] builds selected Agents on their target platform.

    🧹 Ownership-aware uninstall

    Menu [9] or the uninstall command removes AgentBoot-managed programs in batches while preserving config, credentials, and sessions by default.

    ⚡ Measurable performance

    TLS reuse, pre-indexed KB, context trimming, and stream protection; /bench measures the current network and provider.

    -
+

🤖 Supported agents (14)

#AgentCommandVendorOffline
1CoCo AgentcocoBitCookLinux/macOS
2OpenCodeopencodeopencode.ai
2OpenCodeopencodeopencode.aionline only
3Hermes AgenthermesHermes✓ (needs Git)
4Cline CLIclineCline
5CodeBuddy CLIcodebuddyTencent
- + diff --git a/pages/index.html b/pages/index.html index 67a8960..bcf0cd0 100644 --- a/pages/index.html +++ b/pages/index.html @@ -60,7 +60,7 @@ -
+

AgentBoot

@@ -79,7 +79,7 @@

Linux / macOS

Windows(PowerShell)

powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object Net.WebClient).DownloadString('https://boot.ide.pub/install.ps1'))"
-
备用入口:GitHub Pages(本页)/ GitHub Releases / 国内加速镜像 —— 安装脚本内自动按序多源重试。
+
安装器只使用项目控制的 Worker / GitHub Pages / GitHub Release 三源,并对包与同源 SHA-256 一起校验。

🚀 三步上手

    @@ -93,17 +93,17 @@

    ✨ 特性

    📦 菜单自选安装(不是全家桶)

    14 个主流 Agent 按需勾选:Claude Code、Codex、Qwen Code、OpenCode、CodeBuddy、MiMo、Cline、Pi、CoCo…

    🛟 内置保底 Agent(ab)

    其他都装不上时它一定能用:零第三方依赖 Python 核心、Agnes 免费模型、离线 Linux 知识库、会话持久化。

    🧠 模型提供商管理器

    Agnes 零配置开箱;自定义提供商命名管理;Ollama / LM Studio 本地模型;故障切换顺序。

    -
    🇨🇳 中国网络自适应

    自动探测并切换 npmmirror / Node 镜像 / 清华 PyPI;四源下载容错;代理一键配置。

    +
    🇨🇳 中国网络自适应

    自动切换 npm / Node / PyPI 镜像;Worker / Pages / Release 三源容错;代理一键配置。

    📴 已验证离线 & 按需构建

    Release 精简包通过真实安装、启动与卸载冒烟;菜单 [7] 可在目标平台自选 Agent 构建。

    🧹 可追溯安全卸载

    菜单 [9] 或 uninstall 命令批量卸载;只清理由 AgentBoot 管理的程序,默认保留配置、认证与会话。

    ⚡ 可测性能

    TLS 连接复用、知识库预建索引、上下文自动瘦身与流式中断保护;/bench 按当前网络与模型现场测量。

    -
+

🤖 支持的 Agent(14 个)

#AgentCommandVendorOffline
1CoCo AgentcocoBitCookLinux/macOS
2OpenCodeopencodeopencode.ai
2OpenCodeopencodeopencode.aionline only
3Hermes AgenthermesHermes✓ (needs Git)
4Cline CLIclineCline
5CodeBuddy CLIcodebuddyTencent
- + @@ -139,7 +139,7 @@

📚 文档

diff --git a/pages/install.ps1 b/pages/install.ps1 index 37b220a..6509068 100644 --- a/pages/install.ps1 +++ b/pages/install.ps1 @@ -67,12 +67,53 @@ function Install-AppAtomic([string]$source, [string]$destination) { if (Test-Path $oldApp) { Move-Item $oldApp $destination } throw } - if (Test-Path $oldApp) { Remove-Item $oldApp -Recurse -Force } + $script:PendingOldApp = if (Test-Path $oldApp) { $oldApp } else { $null } + $script:PendingApp = $destination } finally { if (Test-Path $newApp) { Remove-Item $newApp -Recurse -Force -ErrorAction SilentlyContinue } } } +function Restore-AppAtomic { + if ($script:PendingApp -and (Test-Path $script:PendingApp)) { + Remove-Item $script:PendingApp -Recurse -Force -ErrorAction SilentlyContinue + } + if ($script:PendingOldApp -and (Test-Path $script:PendingOldApp)) { + Move-Item $script:PendingOldApp $script:PendingApp + } + $script:PendingOldApp = $null; $script:PendingApp = $null +} +function Complete-AppAtomic { + if ($script:PendingOldApp -and (Test-Path $script:PendingOldApp)) { Remove-Item $script:PendingOldApp -Recurse -Force } + $script:PendingOldApp = $null; $script:PendingApp = $null +} +$script:PendingOldApp = $null; $script:PendingApp = $null +trap { + Restore-AppAtomic + if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue } + Write-Error $_ + exit 1 +} + +function Assert-ManagedLauncher([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "拒绝覆盖重解析点命令:$path" + } + if (-not (Select-String -LiteralPath $path -Pattern '^rem AgentBoot ' -Quiet)) { + throw "拒绝覆盖不属于 AgentBoot 的命令:$path" + } +} + +function Set-LauncherAtomic([string]$path, [string]$content) { + $tmp = "$path.new.$([guid]::NewGuid().ToString('N').Substring(0,8))" + try { + $content -replace '\r?\n', "`r`n" | Set-Content -LiteralPath $tmp -Encoding ASCII + Move-Item -LiteralPath $tmp -Destination $path -Force + } finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } +} + function Expand-Pkg([string]$pkg, [string]$dest) { # 优先系统自带 tar(Win10+ 可解 zip),其次 .NET,最后 Shell COM —— 免装解压软件 $tar = Join-Path $env:SystemRoot 'System32\tar.exe' @@ -101,21 +142,17 @@ Write-Step "AgentBoot 在线安装 $Tag" $launchers = @((Join-Path $BinDir 'agentboot.cmd'), (Join-Path $BinDir 'ab.cmd')) foreach ($launcher in $launchers) { - if ((Test-Path $launcher) -and -not (Select-String -Path $launcher -Pattern 'AgentBoot' -Quiet)) { - throw "拒绝覆盖不属于 AgentBoot 的命令:$launcher" - } + Assert-ManagedLauncher $launcher } -# ---------- 1. 下载(多源容错:Cloudflare → GitHub → 国内加速镜像) ---------- +# ---------- 1. 下载(项目控制的三源:Worker → Pages → GitHub Release) ---------- $tmp = Join-Path $env:TEMP ("agentboot-" + [guid]::NewGuid().ToString('N').Substring(0,8)) New-Item -ItemType Directory -Path $tmp, (Join-Path $tmp 'src') -Force | Out-Null $pkg = Join-Path $tmp $ZipName $sources = @( "$BootBase/rel/$ZipName", "https://bit-cook.github.io/AgentBoot/$ZipName", - "$GH/$ZipName", - "https://ghfast.top/$GH/$ZipName", - "https://gh-proxy.com/$GH/$ZipName" + "$GH/$ZipName" ) $dl = $false foreach ($u in $sources) { @@ -123,7 +160,7 @@ foreach ($u in $sources) { $ok = Get-Url $u $pkg $sumFile = "$pkg.sha256" $sumOk = Get-Url "$u.sha256" $sumFile - if ($ok -and $sumOk -and (Test-Path $pkg) -and ((Get-Item $pkg).Length -gt 10KB)) { + if ($ok -and $sumOk -and (Test-Path $pkg) -and ((Get-Item $pkg).Length -gt 10KB) -and ((Get-Item $pkg).Length -le 20MB)) { $expected = ((Get-Content $sumFile -Raw).Trim() -split '\s+')[0].ToLowerInvariant() if ($expected -match '^[0-9a-f]{64}$' -and (Get-Sha256 $pkg) -eq $expected) { Write-Ok 'SHA-256 校验通过' @@ -142,10 +179,6 @@ if (-not (Expand-Pkg $pkg (Join-Path $tmp 'src'))) { Write-Err '解压失败'; e $srcDir = Get-ChildItem (Join-Path $tmp 'src') | Where-Object { $_.PSIsContainer } | Select-Object -First 1 if ($srcDir) { $srcDir = $srcDir.FullName } else { $srcDir = Join-Path $tmp 'src' } -Write-Step "安装程序到 $AppDir" -New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null -Install-AppAtomic $srcDir $AppDir - # ---------- 3. Python:优先内置便携版(免管理员、够用最快) ---------- Write-Step '准备 Python 运行时(内置 Agent ab 需要)' $pyExe = $null @@ -189,24 +222,38 @@ if (-not $pyExe) { $pyExe = (Get-Command python -ErrorAction SilentlyContinue).Source } } else { Write-Ok "检测到系统 Python:$pyExe" } +if (-not $pyExe) { throw '未能准备 Python3,保留现有版本并退出' } +try { $null = & $pyExe -c "import sys; assert sys.version_info[0] == 3" } +catch { throw 'Python3 执行验证失败,保留现有版本并退出' } + +Write-Step "安装程序到 $AppDir" +New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null +Install-AppAtomic $srcDir $AppDir # ---------- 4. 命令入口(agentboot / ab) ---------- Write-Step '创建命令:agentboot(控制台) / ab(内置 Agent)' New-Item -ItemType Directory -Path $BinDir -Force | Out-Null -$pyRef = if ($pyExe) { $pyExe } else { 'python' } -$abRootRef = $AbRoot - -@" +$pyCommand = if ($pyExe -and ([IO.Path]::GetFileName($pyExe) -like 'py*')) { 'py' } else { 'python' } +$agentbootLauncher = @" @echo off rem AgentBoot 控制台 -"$pyRef" "$AppDir\core\menu.py" %* -"@ -replace '\r?\n', "`r`n" | Set-Content -Path (Join-Path $BinDir 'agentboot.cmd') -Encoding ASCII +set "AB_INSTALL=%~dp0.." +set "PYTHON=$pyCommand" +if exist "%AB_INSTALL%\runtime\python\python.exe" set "PYTHON=%AB_INSTALL%\runtime\python\python.exe" +"%PYTHON%" "%AB_INSTALL%\app\core\menu.py" %* +"@ -@" +$abLauncher = @" @echo off rem AgentBoot 内置最小 Agent -"$pyRef" "$AppDir\core\agent.py" %* -"@ -replace '\r?\n', "`r`n" | Set-Content -Path (Join-Path $BinDir 'ab.cmd') -Encoding ASCII +set "AB_INSTALL=%~dp0.." +set "PYTHON=$pyCommand" +if exist "%AB_INSTALL%\runtime\python\python.exe" set "PYTHON=%AB_INSTALL%\runtime\python\python.exe" +"%PYTHON%" "%AB_INSTALL%\app\core\agent.py" %* +"@ +Set-LauncherAtomic (Join-Path $BinDir 'agentboot.cmd') $agentbootLauncher +Set-LauncherAtomic (Join-Path $BinDir 'ab.cmd') $abLauncher +Complete-AppAtomic Write-Ok "已写入 $BinDir" # ---------- 5. PATH 注册(用户级,幂等) ---------- @@ -227,6 +274,7 @@ Write-Step '环境体检' if ($pyExe) { try { & $pyExe (Join-Path $AppDir 'core\agent.py') doctor } catch { Write-Err '体检脚本执行失败(不影响安装)' } } +if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue } # ---------- 7. 完成 ---------- Write-Host '' diff --git a/pages/install.sh b/pages/install.sh index 5f9125e..5727b0f 100755 --- a/pages/install.sh +++ b/pages/install.sh @@ -61,25 +61,41 @@ step "AgentBoot 在线安装 ${TAG} · $(uname -s) $(uname -m)" # 在替换 app 前先保护用户已有的同名命令,避免应用已升级但 launcher 更新失败。 for launcher in "${BIN_DIR}/agentboot" "${BIN_DIR}/ab"; do - if [ -e "$launcher" ] && ! grep -q "AgentBoot" "$launcher" 2>/dev/null; then + if [ -L "$launcher" ]; then + err "拒绝覆盖符号链接命令:$launcher" + exit 1 + fi + if [ -e "$launcher" ] && ! grep -q '^# AgentBoot ' "$launcher" 2>/dev/null; then err "拒绝覆盖不属于 AgentBoot 的命令:$launcher" exit 1 fi done -# ---------- 1. 下载在线包(多源容错:Cloudflare → GitHub → 国内加速镜像) ---------- +# ---------- 1. 下载在线包(项目控制的三源:Worker → Pages → GitHub Release) ---------- TMP="$(mktemp -d 2>/dev/null || echo /tmp/agentboot-install-$$)" mkdir -p "$TMP" STAGE="${TMP}/src" mkdir -p "$STAGE" +OLD_APP="" +SWAP_COMMITTED=0 +cleanup_install() { + code=$? + trap - EXIT HUP INT TERM + rm -f "${BIN_DIR}/.agentboot.new.$$" "${BIN_DIR}/.ab.new.$$" + if [ "$code" -ne 0 ] && [ "$SWAP_COMMITTED" -eq 0 ] && [ -n "$OLD_APP" ] && [ -d "$OLD_APP" ]; then + rm -rf "$APP_DIR" + mv "$OLD_APP" "$APP_DIR" || true + fi + rm -rf "$TMP" + exit "$code" +} +trap cleanup_install EXIT HUP INT TERM dl_ok="" for url in \ "${BOOT_BASE}/rel/${TARBALL}" \ "https://bit-cook.github.io/AgentBoot/${TARBALL}" \ - "${GH}/${TARBALL}" \ - "https://ghfast.top/${GH}/${TARBALL}" \ - "https://gh-proxy.com/${GH}/${TARBALL}" + "${GH}/${TARBALL}" do say "下载:${url}" if fetch "$url" "${TMP}/${TARBALL}" && fetch "${url}.sha256" "${TMP}/${TARBALL}.sha256"; then @@ -96,9 +112,16 @@ if [ -z "$dl_ok" ]; then err "所有下载源均失败。请检查网络,或使用离线安装包(见项目文档《安装指南.md》)。" exit 1 fi +[ "$(wc -c < "${TMP}/${TARBALL}")" -le 20971520 ] || { err "在线包异常过大"; exit 1; } # ---------- 2. 解压(系统自带 tar,无需安装解压软件) ---------- step "解压安装包" +members="${TMP}/members.txt" +tar -tzf "${TMP}/${TARBALL}" > "$members" || { err "无法读取安装包目录"; exit 1; } +[ "$(wc -l < "$members")" -le 20000 ] || { err "安装包文件数量异常"; exit 1; } +if awk 'BEGIN{bad=0} /^\//{bad=1} /(^|\/)\.\.($|\/)/{bad=1} END{exit bad?0:1}' "$members"; then + err "安装包包含越界路径"; exit 1 +fi if ! tar -xzf "${TMP}/${TARBALL}" -C "$STAGE"; then err "解压失败:下载文件可能不完整。" exit 1 @@ -108,6 +131,24 @@ if [ "$(ls -A "$STAGE" | wc -l)" = "1" ] && [ -d "$STAGE/$(ls -A "$STAGE")" ]; t SRC_DIR="$STAGE/$(ls -A "$STAGE")" fi +# 在提交应用目录前确认可执行的 Python 3;失败时现有安装保持不变。 +PY="$(command -v python3 || true)" +if [ -z "$PY" ] && command -v python >/dev/null 2>&1 && python -c 'import sys; raise SystemExit(sys.version_info[0] != 3)' >/dev/null 2>&1; then + PY="$(command -v python)" +fi +if [ -z "$PY" ]; then + step "未检测到 Python3,尝试自动安装" + if command -v apt-get >/dev/null 2>&1; then (sudo apt-get update -y && sudo apt-get install -y python3) >/dev/null 2>&1 || true + elif command -v dnf >/dev/null 2>&1; then (sudo dnf install -y python3) >/dev/null 2>&1 || true + elif command -v yum >/dev/null 2>&1; then (sudo yum install -y python3) >/dev/null 2>&1 || true + elif command -v pacman >/dev/null 2>&1; then (sudo pacman -Sy --noconfirm python) >/dev/null 2>&1 || true + elif command -v apk >/dev/null 2>&1; then (apk add --no-cache python3) >/dev/null 2>&1 || true + elif command -v brew >/dev/null 2>&1; then (brew install python3) >/dev/null 2>&1 || true + fi + PY="$(command -v python3 || true)" +fi +[ -n "$PY" ] || { err "未能准备 Python3,保留现有版本并退出。"; exit 1; } + step "安装程序到 ${APP_DIR}" mkdir -p "$AB_ROOT" NEW_APP="${AB_ROOT}/app.new.$$" @@ -122,7 +163,7 @@ if [ ! -f "$NEW_APP/core/menu.py" ] || [ ! -f "$NEW_APP/core/agent.py" ]; then fi if [ -d "$APP_DIR" ]; then mv "$APP_DIR" "$OLD_APP"; fi if mv "$NEW_APP" "$APP_DIR"; then - rm -rf "$OLD_APP" + : else err "切换新版本失败,正在恢复旧版本" [ -d "$OLD_APP" ] && mv "$OLD_APP" "$APP_DIR" @@ -134,19 +175,26 @@ chmod +x "${APP_DIR}/install.sh" 2>/dev/null || true # ---------- 3. 生成命令行入口 ---------- step "创建命令:agentboot(控制台) / ab(内置 Agent)" mkdir -p "$BIN_DIR" -cat > "${BIN_DIR}/agentboot" < "$agentboot_tmp" < "${BIN_DIR}/ab" < "$ab_tmp" </dev/null 2>&1; then - (sudo apt-get update -y && sudo apt-get install -y python3) >/dev/null 2>&1 || true - elif command -v dnf >/dev/null 2>&1; then - (sudo dnf install -y python3) >/dev/null 2>&1 || true - elif command -v yum >/dev/null 2>&1; then - (sudo yum install -y python3) >/dev/null 2>&1 || true - elif command -v pacman >/dev/null 2>&1; then - (sudo pacman -Sy --noconfirm python) >/dev/null 2>&1 || true - elif command -v apk >/dev/null 2>&1; then - (apk add --no-cache python3) >/dev/null 2>&1 || true - elif command -v brew >/dev/null 2>&1; then - (brew install python3) >/dev/null 2>&1 || true - fi - PY="$(command -v python3 || command -v python || true)" - if [ -z "$PY" ]; then - err "未能自动安装 Python3。请手动安装后运行:agentboot" - fi -fi - # ---------- 6. 体检 ---------- -if [ -n "$PY" ]; then - step "环境体检" - "$PY" "${APP_DIR}/core/agent.py" doctor >/dev/null 2>&1 || true - "$PY" "${APP_DIR}/core/agent.py" doctor 2>/dev/null || true -fi +step "环境体检" +"$PY" "${APP_DIR}/core/agent.py" doctor >/dev/null 2>&1 || true +"$PY" "${APP_DIR}/core/agent.py" doctor 2>/dev/null || true # ---------- 7. 完成 ---------- say "" diff --git a/scripts/build-offline.ps1 b/scripts/build-offline.ps1 index dccb0dc..3bafd1c 100644 --- a/scripts/build-offline.ps1 +++ b/scripts/build-offline.ps1 @@ -114,9 +114,8 @@ if (Test-Path $Stage) { Remove-Item $Stage -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $Stage -Force | Out-Null -robocopy $Root $Stage /E /NFL /NDL /NJH /NJS /XD .git dist payloads node_modules __pycache__ .zcode pages /XF *.pyc | Out-Null -if ($LASTEXITCODE -ge 8) { Write-Err "robocopy 失败(code=$LASTEXITCODE)"; exit 1 } -$global:LASTEXITCODE = 0 +& python (Join-Path $Root 'scripts\tools\stage_application.py') $Root $Stage +if ($LASTEXITCODE -ne 0) { throw '复制应用显式清单失败' } # ---------- 2. 下载各平台 Node 运行时 ---------- New-Item -ItemType Directory -Path (Join-Path $Stage 'payloads\node') -Force | Out-Null @@ -308,7 +307,10 @@ tmp="$(mktemp -d 2>/dev/null || echo /tmp/agentboot-sfx-$$)" mkdir -p "$tmp" echo "==> AgentBoot 离线自解压安装:解压中,请稍候 …" tail -n +"$SKIP" "$0" | { base64 -d 2>/dev/null || base64 -D 2>/dev/null || openssl base64 -d -A; } | tar -xzf - -C "$tmp" -exec sh "$tmp/AgentBoot/install-offline.sh" "$@" +code=0 +sh "$tmp/AgentBoot/install-offline.sh" "$@" || code=$? +rm -rf "$tmp" +exit "$code" __AGENTBOOT_PAYLOAD_BELOW__ '@ [IO.File]::WriteAllText($sfx, ($header -replace "`r`n", "`n"), (New-Object System.Text.UTF8Encoding($false))) diff --git a/scripts/build-offline.sh b/scripts/build-offline.sh index ec49a5c..b5d7a8c 100755 --- a/scripts/build-offline.sh +++ b/scripts/build-offline.sh @@ -57,8 +57,6 @@ verify_node_archive() { # verify_node_archive [ "$actual" = "$expected" ] } -step "AgentBoot 离线包构建 $TAG · 平台:$PLATFORMS" - case "$(uname -s)-$(uname -m)" in Darwin-arm*) HOST_PLAT="darwin-arm64" ;; Darwin-*) HOST_PLAT="darwin-x64" ;; @@ -66,6 +64,7 @@ case "$(uname -s)-$(uname -m)" in *) HOST_PLAT="linux-x64" ;; esac PLATFORMS="${PLATFORMS:-$HOST_PLAT}" +step "AgentBoot 离线包构建 $TAG · 平台:$PLATFORMS" # ---------- 0. 确保 npm ---------- if ! command -v npm >/dev/null 2>&1; then @@ -93,10 +92,7 @@ ok "npm:$(command -v npm)" step '复制项目文件 …' rm -rf "$STAGE" mkdir -p "$STAGE" -(cd "$ROOT" && tar -cf - \ - --exclude='./.git' --exclude='./dist' --exclude='./payloads' \ - --exclude='./node_modules' --exclude='./__pycache__' --exclude='*.pyc' \ - .) | tar -xf - -C "$STAGE" +python3 "$ROOT/scripts/tools/stage_application.py" "$ROOT" "$STAGE" # ---------- 2. 各平台 Node 运行时 ---------- mkdir -p "$STAGE/payloads/node" @@ -143,10 +139,10 @@ PYEOF fi step "Agent 载荷:$WANT" for AID in $WANT; do - PKG="$(python3 - "$AID" <<'PYEOF' + PKG="$(python3 - "$ROOT/agents/registry.json" "$AID" <<'PYEOF' import json,sys -rid=sys.argv[1] -for a in json.load(open('agents/registry.json'))['agents']: +rid=sys.argv[2] +for a in json.load(open(sys.argv[1]))['agents']: if a['id']==rid: print(a.get('npm') or '') break @@ -203,6 +199,8 @@ mkdir -p "$STAGE/payloads/python" || fetch_file "https://www.python.org/ftp/python/3.12.10/python-3.12.10-embed-amd64.zip" \ "$STAGE/payloads/python/win-embed.zip" \ || { err "Windows Python 便携包下载失败"; exit 1; } +[ "$(sha256_file "$STAGE/payloads/python/win-embed.zip")" = "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3" ] \ + || { rm -f "$STAGE/payloads/python/win-embed.zip"; err "Windows Python SHA-256 校验失败"; exit 1; } ok 'win-embed.zip 就绪' # CoCo(script 类)离线载荷:发行包 + sha256 + Agnes 密钥 + Node 22.23 运行时 @@ -297,7 +295,10 @@ tmp="$(mktemp -d 2>/dev/null || echo /tmp/agentboot-sfx-$$)" mkdir -p "$tmp" echo "==> AgentBoot 离线自解压安装:解压中,请稍候 …" tail -n +"$SKIP" "$0" | { base64 -d 2>/dev/null || base64 -D 2>/dev/null || openssl base64 -d -A; } | tar -xzf - -C "$tmp" -exec sh "$tmp/AgentBoot/install-offline.sh" "$@" +code=0 +sh "$tmp/AgentBoot/install-offline.sh" "$@" || code=$? +rm -rf "$tmp" +exit "$code" __AGENTBOOT_PAYLOAD_BELOW__ HF python3 - "$GZ" "$SFX" <<'PYEOF' diff --git a/scripts/install-offline.ps1 b/scripts/install-offline.ps1 index 8ee8b44..96a9ada 100644 --- a/scripts/install-offline.ps1 +++ b/scripts/install-offline.ps1 @@ -22,6 +22,13 @@ function Write-Ok($m) { Write-Host "OK $m" -ForegroundColor Green } function Write-Err($m) { Write-Host "X $m" -ForegroundColor Red } function Write-Step($m) { Write-Host "`n==> $m" -ForegroundColor Cyan } +function Get-Sha256([string]$path) { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($path) + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } + finally { $stream.Dispose(); $sha.Dispose() } +} + function Expand-Pkg([string]$pkg, [string]$dest) { $tar = Join-Path $env:SystemRoot 'System32\tar.exe' if (Test-Path $tar) { @@ -44,6 +51,41 @@ function Expand-Pkg([string]$pkg, [string]$dest) { } catch { return $false } } +function Assert-ManagedLauncher([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "拒绝覆盖重解析点命令:$path" + } + if (-not (Select-String -LiteralPath $path -Pattern '^rem AgentBoot ' -Quiet)) { + throw "拒绝覆盖不属于 AgentBoot 的命令:$path" + } +} + +function Set-LauncherAtomic([string]$path, [string]$content) { + $tmp = "$path.new.$([guid]::NewGuid().ToString('N').Substring(0,8))" + try { + $content -replace '\r?\n', "`r`n" | Set-Content -LiteralPath $tmp -Encoding ASCII + Move-Item -LiteralPath $tmp -Destination $path -Force + } finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } +} + +function Restore-AppAtomic { + if ($script:PendingApp -and (Test-Path $script:PendingApp)) { + Remove-Item $script:PendingApp -Recurse -Force -ErrorAction SilentlyContinue + } + if ($script:PendingOldApp -and (Test-Path $script:PendingOldApp)) { + Move-Item $script:PendingOldApp $script:PendingApp + } + $script:PendingOldApp = $null; $script:PendingApp = $null +} +function Complete-AppAtomic { + if ($script:PendingOldApp -and (Test-Path $script:PendingOldApp)) { Remove-Item $script:PendingOldApp -Recurse -Force } + $script:PendingOldApp = $null; $script:PendingApp = $null +} +$script:PendingOldApp = $null; $script:PendingApp = $null +trap { Restore-AppAtomic; Write-Error $_; exit 1 } + $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $LocalRoot = Join-Path $env:LOCALAPPDATA 'AgentBoot' $AppDir = Join-Path $LocalRoot 'app' @@ -54,9 +96,7 @@ Write-Step 'AgentBoot 离线安装(无需联网)' $launchers = @((Join-Path $BinDir 'agentboot.cmd'), (Join-Path $BinDir 'ab.cmd')) foreach ($launcher in $launchers) { - if ((Test-Path $launcher) -and -not (Select-String -Path $launcher -Pattern 'AgentBoot' -Quiet)) { - throw "拒绝覆盖不属于 AgentBoot 的命令:$launcher" - } + Assert-ManagedLauncher $launcher } # ---------- 1. 校验载荷 ---------- @@ -73,7 +113,7 @@ foreach ($line in Get-Content $sums) { $parts = $line -split '\s+', 2 $file = Join-Path $ScriptDir $parts[1].Replace('/', [IO.Path]::DirectorySeparatorChar) if (-not (Test-Path $file)) { throw "载荷缺失:$($parts[1])" } - if ((Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() -ne $parts[0].ToLowerInvariant()) { + if ((Get-Sha256 $file) -ne $parts[0].ToLowerInvariant()) { throw "载荷 SHA-256 校验失败:$($parts[1])" } } @@ -97,7 +137,11 @@ if (-not (Test-Path (Join-Path $newApp 'core\menu.py')) -or -not (Test-Path (Joi Remove-Item $newApp -Recurse -Force -ErrorAction SilentlyContinue; throw '离线包结构无效' } if (Test-Path $AppDir) { Move-Item $AppDir $oldApp } -try { Move-Item $newApp $AppDir; if (Test-Path $oldApp) { Remove-Item $oldApp -Recurse -Force } } +try { + Move-Item $newApp $AppDir + $script:PendingOldApp = if (Test-Path $oldApp) { $oldApp } else { $null } + $script:PendingApp = $AppDir +} catch { if (Test-Path $oldApp) { Move-Item $oldApp $AppDir }; throw } # ---------- 3. Python:系统优先,否则用离线包内置便携版 ---------- @@ -133,18 +177,26 @@ if ($pyExe) { Write-Ok "Python:$pyExe" } # ---------- 4. 命令入口 ---------- Write-Step '创建命令:agentboot(控制台) / ab(内置 Agent)' New-Item -ItemType Directory -Path $BinDir, (Join-Path $AbRoot 'bin') -Force | Out-Null -$pyRef = if ($pyExe) { $pyExe } else { 'python' } - -@" +$pyCommand = if ($pyExe -and ([IO.Path]::GetFileName($pyExe) -like 'py*')) { 'py' } else { 'python' } +$agentbootLauncher = @" @echo off rem AgentBoot 控制台 -"$pyRef" "$AppDir\core\menu.py" %* -"@ -replace '\r?\n', "`r`n" | Set-Content (Join-Path $BinDir 'agentboot.cmd') -Encoding ASCII -@" +set "AB_INSTALL=%~dp0.." +set "PYTHON=$pyCommand" +if exist "%AB_INSTALL%\runtime\python\python.exe" set "PYTHON=%AB_INSTALL%\runtime\python\python.exe" +"%PYTHON%" "%AB_INSTALL%\app\core\menu.py" %* +"@ +$abLauncher = @" @echo off rem AgentBoot 内置最小 Agent -"$pyRef" "$AppDir\core\agent.py" %* -"@ -replace '\r?\n', "`r`n" | Set-Content (Join-Path $BinDir 'ab.cmd') -Encoding ASCII +set "AB_INSTALL=%~dp0.." +set "PYTHON=$pyCommand" +if exist "%AB_INSTALL%\runtime\python\python.exe" set "PYTHON=%AB_INSTALL%\runtime\python\python.exe" +"%PYTHON%" "%AB_INSTALL%\app\core\agent.py" %* +"@ +Set-LauncherAtomic (Join-Path $BinDir 'agentboot.cmd') $agentbootLauncher +Set-LauncherAtomic (Join-Path $BinDir 'ab.cmd') $abLauncher +Complete-AppAtomic Write-Ok "已写入 $BinDir" $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') @@ -175,7 +227,7 @@ if (($All -or $Agents) -and $pyExe) { if ($ids) { Write-Step "离线安装:$($ids -join ' ')" $menuArgs = @($menu, 'offline', '--payload', $PayloadDir) + @($ids) - & $pyRef @menuArgs + & $pyExe @menuArgs if ($LASTEXITCODE -ne 0) { throw "Agent 离线安装失败(exit=$LASTEXITCODE)" } } } elseif (($All -or $Agents) -and -not $pyExe) { @@ -191,5 +243,5 @@ Write-Host ' 内置 Agent : ab (默认 Agnes 免费模型;联网 Write-Host ' 纯离线用模型:菜单[4] → 配置本地模型(Ollama / LM Studio)' Write-Host '==============================================' -ForegroundColor Cyan if (-not $All -and -not $Agents) { - if ($pyExe) { & $pyRef (Join-Path $AppDir 'core\menu.py') } + if ($pyExe) { & $pyExe (Join-Path $AppDir 'core\menu.py') } } diff --git a/scripts/install-offline.sh b/scripts/install-offline.sh index b03a915..95f5300 100755 --- a/scripts/install-offline.sh +++ b/scripts/install-offline.sh @@ -23,8 +23,25 @@ step() { printf '\n==> %s\n' "$*"; } step "AgentBoot 离线安装(无需联网)" +OLD_APP="" +SWAP_COMMITTED=0 +rollback_install() { + code=$? + trap - EXIT HUP INT TERM + rm -f "${BIN_DIR}/.agentboot.new.$$" "${BIN_DIR}/.ab.new.$$" + if [ "$code" -ne 0 ] && [ "$SWAP_COMMITTED" -eq 0 ] && [ -n "$OLD_APP" ] && [ -d "$OLD_APP" ]; then + rm -rf "$APP_DIR" + mv "$OLD_APP" "$APP_DIR" || true + fi + exit "$code" +} +trap rollback_install EXIT HUP INT TERM + for launcher in "${BIN_DIR}/agentboot" "${BIN_DIR}/ab"; do - if [ -e "$launcher" ] && ! grep -q "AgentBoot" "$launcher" 2>/dev/null; then + if [ -L "$launcher" ]; then + err "拒绝覆盖符号链接命令:$launcher"; exit 1 + fi + if [ -e "$launcher" ] && ! grep -q '^# AgentBoot ' "$launcher" 2>/dev/null; then err "拒绝覆盖不属于 AgentBoot 的命令:$launcher"; exit 1 fi done @@ -49,6 +66,12 @@ while read -r expected relative; do done < "$SUMS" ok "离线载荷 SHA-256 校验通过:${SCRIPT_DIR}/payloads" +PY="$(command -v python3 || true)" +if [ -z "$PY" ] && command -v python >/dev/null 2>&1 && python -c 'import sys; raise SystemExit(sys.version_info[0] != 3)' >/dev/null 2>&1; then + PY="$(command -v python)" +fi +[ -n "$PY" ] || { err "本机没有可用 Python3,保留现有版本并退出。"; exit 1; } + # ---------- 2. 安装程序本体 ---------- step "安装程序到 ${APP_DIR}" mkdir -p "$AB_ROOT" @@ -67,35 +90,36 @@ if [ ! -f "$NEW_APP/core/menu.py" ] || [ ! -f "$NEW_APP/core/agent.py" ]; then err "离线包结构无效,保留现有版本"; rm -rf "$NEW_APP"; exit 1 fi [ -d "$APP_DIR" ] && mv "$APP_DIR" "$OLD_APP" -if mv "$NEW_APP" "$APP_DIR"; then rm -rf "$OLD_APP" +if mv "$NEW_APP" "$APP_DIR"; then : else [ -d "$OLD_APP" ] && mv "$OLD_APP" "$APP_DIR"; err "升级失败,已恢复旧版本"; exit 1 fi # ---------- 3. Python 检查(ab 需要;绝大多数系统自带) ---------- -PY="$(command -v python3 || command -v python || true)" -if [ -z "$PY" ]; then - err "本机没有 python3。请用系统包管理器安装(apt/dnf/apk/brew install python3),然后重跑本脚本。" - say "(Linux 服务器一般自带 python3;macOS 终端运行会自动触发安装。)" - exit 1 -fi ok "Python:$PY" # ---------- 4. 命令入口 ---------- step "创建命令:agentboot(控制台) / ab(内置 Agent)" mkdir -p "$BIN_DIR" -cat > "${BIN_DIR}/agentboot" < "$agentboot_tmp" < "${BIN_DIR}/ab" < "$ab_tmp" < [目标平台] """ import json +import hashlib import os import re import sys @@ -75,6 +76,11 @@ def main(): else: raise SystemExit("uv download failed from all sources") + actual = hashlib.sha256(open(archive, "rb").read()).hexdigest() + if actual != sha.lower(): + os.remove(archive) + raise SystemExit("uv SHA-256 mismatch: expected %s, got %s" % (sha, actual)) + if asset.endswith(".zip"): data = zipfile.ZipFile(archive).read(member) else: diff --git a/scripts/tools/stage_application.py b/scripts/tools/stage_application.py new file mode 100644 index 0000000..eeae122 --- /dev/null +++ b/scripts/tools/stage_application.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Copy the explicit AgentBoot application allowlist into a staging directory.""" + +from pathlib import Path +import shutil +import sys + + +FILES = ( + "VERSION", "LICENSE", "CHANGELOG.md", "README.md", "README.en.md", "install.sh", "install.bat", "安装指南.md", + "agents/registry.json", "core/agent.py", "core/i18n.py", "core/menu.py", + "docs/en/install-guide.md", "docs/zh/安装指南.md", + "scripts/build-offline.ps1", "scripts/build-offline.sh", "scripts/build-online.py", + "scripts/install-offline.ps1", "scripts/install-offline.sh", "scripts/install.ps1", "scripts/verify-live-release.py", + "scripts/tools/build_sfx.py", "scripts/tools/hash_tree.py", "scripts/tools/seed_uv_generic.py", + "scripts/tools/sfx_append.py", "scripts/tools/stage_application.py", + "scripts/tools/validate_offline_payload.py", "scripts/tools/zip_tree.py", + "tools/linux-kb/基础命令.md", "tools/linux-kb/故障排查.md", "tools/linux-kb/服务管理.md", + "tools/linux-kb/用户与权限.md", "tools/linux-kb/磁盘存储.md", "tools/linux-kb/系统信息.md", + "tools/linux-kb/终端技巧.md", "tools/linux-kb/网络配置.md", "tools/linux-kb/软件包管理.md", +) + + +def reject_symlinks(path): + if path.is_symlink(): + raise SystemExit("release source contains symlink: %s" % path) + if path.is_dir(): + for child in path.iterdir(): + reject_symlinks(child) + + +def main(): + source = Path(sys.argv[1]).resolve() + destination = Path(sys.argv[2]).resolve() + destination.mkdir(parents=True, exist_ok=True) + for name in FILES: + path = source / name + if not path.is_file() or path.is_symlink(): + raise SystemExit("missing or unsafe release file: %s" % path) + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + print("staged explicit application allowlist:", destination) + + +if __name__ == "__main__": + main() diff --git a/scripts/tools/validate_offline_payload.py b/scripts/tools/validate_offline_payload.py index 7ed96c0..9c5a017 100644 --- a/scripts/tools/validate_offline_payload.py +++ b/scripts/tools/validate_offline_payload.py @@ -55,6 +55,8 @@ def main(): agent = agents.get(aid) if not agent: errors.append("unknown requested Agent: %s" % aid) + elif not agent.get("offline"): + errors.append("%s is not marked offline-capable" % aid) elif agent.get("os") and platform_os.get(platform_id) not in agent["os"]: errors.append("%s does not support %s" % (aid, platform_id)) elif aid == "coco": diff --git a/tests/test_agent_lifecycle_methods.py b/tests/test_agent_lifecycle_methods.py new file mode 100644 index 0000000..7918a71 --- /dev/null +++ b/tests/test_agent_lifecycle_methods.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Special lifecycle and offline-support regressions.""" + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) + +import menu # noqa: E402 + + +class AgentLifecycleTests(unittest.TestCase): + def test_opencode_not_advertised_offline_until_postinstall_is_supported(self): + registry = json.loads((ROOT / "agents/registry.json").read_text(encoding="utf-8")) + opencode = next(agent for agent in registry["agents"] if agent["id"] == "opencode") + self.assertFalse(opencode["offline"]) + + def test_aider_uses_private_venv_lifecycle(self): + registry = json.loads((ROOT / "agents/registry.json").read_text(encoding="utf-8")) + aider = next(agent for agent in registry["agents"] if agent["id"] == "aider") + self.assertEqual((aider["method"], aider["pip"]), ("venv", "aider-chat")) + + def test_aider_install_dispatches_private_venv(self): + agent = {"id": "aider", "name": "Aider", "vendor": "Aider", "bin": "aider", + "method": "venv", "pip": "aider-chat"} + with mock.patch.object(menu, "load_registry", return_value=[agent]), \ + mock.patch.object(menu, "install_aider_venv", return_value=True) as install, \ + mock.patch.object(menu, "aider_venv_executable", return_value="/managed/aider"), \ + mock.patch.object(menu, "wire_agnes", return_value=({}, [])), \ + mock.patch.object(menu, "record_install") as record, \ + mock.patch.object(menu, "ensure_path_registered"): + self.assertEqual(menu.install_online(["aider"]), []) + install.assert_called_once_with(agent) + record.assert_called_once() + + def test_hermes_postinstall_uses_node_matching_selected_npm(self): + agent = {"id": "hermes", "name": "Hermes", "method": "npm", "npm": "hermes-agent", "node": ">=20"} + runtime_npm = "/managed/runtime/bin/npm" + runtime_node = "/managed/runtime/bin/node" + completed = subprocess.CompletedProcess([], 0, stdout="/managed/root\n") + with mock.patch.object(menu, "npm_cmd", return_value=runtime_npm), \ + mock.patch.object(menu, "ensure_npm_prefix"), \ + mock.patch.object(menu, "runtime_node_dir", return_value="/managed/runtime"), \ + mock.patch.object(menu, "node_exe", return_value=runtime_node), \ + mock.patch.object(menu, "node_ok", return_value=True), \ + mock.patch.object(menu, "_npm_global_root", return_value="/managed/root"), \ + mock.patch.object(menu.os.path, "isdir", return_value=True), \ + mock.patch.object(menu, "_seed_uv", return_value=True), \ + mock.patch.object(menu, "_github_git_reachable", return_value=True), \ + mock.patch.object(menu.subprocess, "run", return_value=completed) as run: + self.assertTrue(menu.install_hermes_special(agent)) + self.assertEqual(run.call_args_list[-1].args[0][0], runtime_node) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_command_safety.py b/tests/test_command_safety.py index 94f5116..0d8f608 100644 --- a/tests/test_command_safety.py +++ b/tests/test_command_safety.py @@ -14,8 +14,8 @@ class CommandClassificationTests(unittest.TestCase): - def test_all_read_only_chain_is_safe(self): - self.assertEqual(agent.classify_cmd("pwd && ls -la | head -n 5"), "safe") + def test_shell_chains_are_never_safe(self): + self.assertEqual(agent.classify_cmd("pwd && ls -la | head -n 5"), "normal") def test_mutating_command_after_safe_prefix_is_not_safe(self): self.assertEqual(agent.classify_cmd("echo ok; touch /tmp/agentboot-test"), "normal") @@ -56,10 +56,16 @@ def test_shell_expansion_and_process_substitution_are_not_safe(self): for command in commands: self.assertNotEqual(agent.classify_cmd(command), "safe", command) + def test_single_ampersand_redirection_find_writes_and_path_spoofing_are_not_safe(self): + commands = ("pwd & touch /tmp/x", "cat <> /tmp/x", "find /tmp -fprintf /tmp/x hi", + "/tmp/ls /tmp") + for command in commands: + self.assertNotEqual(agent.classify_cmd(command), "safe", command) + class ConfirmationPolicyTests(unittest.TestCase): def test_safe_mode_runs_read_only_command(self): - with mock.patch.object(agent, "run_cmd", return_value="ok") as run: + with mock.patch.object(agent, "run_safe_cmd", return_value="ok") as run: result, danger = agent.execute_tool( {"confirm": "safe"}, "run_cmd", {"command": "pwd"}, set()) self.assertEqual(result, "ok") @@ -104,6 +110,20 @@ def test_always_mode_allows_normal_command(self): self.assertEqual(result, "ok") run.assert_called_once() + def test_unknown_policy_fails_closed_to_smart(self): + with mock.patch.object(agent, "_is_interactive", return_value=False), \ + mock.patch.object(agent, "run_cmd") as run: + result, _danger = agent.execute_tool( + {"confirm": "typo"}, "run_cmd", {"command": "touch x"}, set()) + self.assertIn("非交互", result) + run.assert_not_called() + + def test_safe_command_uses_direct_argv_not_shell(self): + with mock.patch.object(agent, "_trusted_executable", return_value="/usr/bin/ls"), \ + mock.patch.object(agent.subprocess if hasattr(agent, "subprocess") else agent, "run", create=True): + parsed = agent.safe_command_argv("ls -la") + self.assertEqual(parsed, ["/usr/bin/ls", "-la"]) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_custom_agent_validation.py b/tests/test_custom_agent_validation.py new file mode 100644 index 0000000..0299073 --- /dev/null +++ b/tests/test_custom_agent_validation.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Custom Agent IDs remain unique and CLI deletion validates arguments.""" + +from pathlib import Path +import json +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) +import menu # noqa: E402 + + +class CustomAgentValidationTests(unittest.TestCase): + def test_duplicate_custom_id_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + custom = Path(tmp) / "custom.json" + custom.write_text('[{"id":"dup","bin":"dup"}]', encoding="utf-8") + with mock.patch.object(menu, "CUSTOM_AGENTS", str(custom)): + with self.assertRaisesRegex(ValueError, "已存在"): + menu.custom_add_entry({"id": "dup", "bin": "dup", "method": "npm", "npm": "dup"}) + + def test_delete_without_id_exits_usage(self): + with mock.patch.object(sys, "argv", ["menu.py", "add-agent", "--del"]), \ + mock.patch.object(menu, "resolve_lang"), mock.patch.object(menu.agent, "_utf8_console"): + with self.assertRaisesRegex(SystemExit, "2"): + menu.main() + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 6bfbcce..3a3bdf6 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -36,6 +36,7 @@ def test_web_surfaces_include_mobile_overflow_and_touch_fixes(self): self.assertIn("minmax(min(100%,280px),1fr)", surface) self.assertIn("min-height:44px", surface) self.assertIn("aria-live", surface) + self.assertIn(" 1] + self.assertEqual(duplicates, []) + + def test_format_mismatch_raises(self): + old = i18n.get_lang() + try: + i18n.set_lang("en") + with self.assertRaises((TypeError, ValueError)): + i18n.t("menu.install_ok", "only-one") + finally: + i18n.set_lang(old) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_install_tracking.py b/tests/test_install_tracking.py index 35c9230..d458a26 100644 --- a/tests/test_install_tracking.py +++ b/tests/test_install_tracking.py @@ -2,6 +2,8 @@ """Integration checks for install ownership recording and package delivery.""" from pathlib import Path +import multiprocessing +import os import sys import tempfile import unittest @@ -14,7 +16,26 @@ import menu # noqa: E402 +def _record_worker(index): + menu.record_install({"id": "agent-%d" % index, "name": "A", "bin": "a-%d" % index, + "method": "npm", "npm": "pkg-%d" % index}, "online") + + class InstallTrackingTests(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "fork-based lock stress test") + def test_install_state_serializes_parallel_process_updates(self): + with tempfile.TemporaryDirectory() as tmp: + state = Path(tmp) / "installed.json" + home = Path(tmp) / "home" + with mock.patch.object(menu, "AB_HOME", str(home)), \ + mock.patch.object(menu, "INSTALL_STATE", str(state)): + ctx = multiprocessing.get_context("fork") + processes = [ctx.Process(target=_record_worker, args=(index,)) for index in range(20)] + for process in processes: process.start() + for process in processes: process.join(10) + self.assertTrue(all(process.exitcode == 0 for process in processes)) + saved = menu.load_install_state()["agents"] + self.assertEqual(set(saved), {"agent-%d" % index for index in range(20)}) def test_online_npm_success_records_resolved_install(self): agent = {"id": "codex", "name": "Codex", "vendor": "OpenAI", "bin": "codex", "method": "npm", "npm": "@openai/codex@0.90.0"} @@ -92,11 +113,33 @@ def test_windows_offline_shim_escapes_cmd_percent_variables(self): mock.patch.object(menu, "AB_HOME", str(root)), \ mock.patch.object(menu, "POSIX", False): self.assertTrue(menu.write_shim(agent, node_path="C:\\portable\\node.exe")) - shim = (root / "bin" / "codex.cmd").read_text(encoding="ascii") + shim = (root / "bin" / "codex.cmd").read_text(encoding="utf-8-sig") self.assertIn("%AB_ROOT%", shim) self.assertIn("%PATH%", shim) self.assertIn('"C:\\portable\\node.exe"', shim) + def test_native_npm_entry_executes_directly_without_node(self): + with tempfile.TemporaryDirectory() as tmp: + native = Path(tmp) / "agent" + native.write_bytes(b"\x7fELFfake") + self.assertEqual(menu._npm_entry_kind(str(native)), "direct") + self.assertEqual(menu._npm_entry_kind("tool.cmd"), "cmd") + self.assertEqual(menu._npm_entry_kind("tool.js"), "node") + + def test_posix_path_block_uses_npm_bin_and_upgrades_existing_block(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + bashrc = home / ".bashrc" + bashrc.write_text("before\n# >>> agentboot >>>\nold\n# <<< agentboot <<<\nafter\n", encoding="utf-8") + with mock.patch.object(menu, "POSIX", True), \ + mock.patch.object(menu, "AB_HOME", str(home / ".agentboot")), \ + mock.patch.object(menu, "NPM_PREFIX", str(home / ".agentboot" / "npm-prefix")), \ + mock.patch.object(menu.os.path, "expanduser", side_effect=lambda p: str(home / p[2:]) if p.startswith("~/") else p): + menu.ensure_path_registered() + content = bashrc.read_text(encoding="utf-8") + self.assertIn("npm-prefix/bin", content) + self.assertNotIn("\nold\n", content) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_installer_transactions.py b/tests/test_installer_transactions.py index 50eb633..54b92b2 100644 --- a/tests/test_installer_transactions.py +++ b/tests/test_installer_transactions.py @@ -15,6 +15,8 @@ def test_posix_installers_use_atomic_app_switch(self): self.assertIn("app.new.", text, relative) self.assertIn("app.old.", text, relative) self.assertIn('mv "$OLD_APP" "$APP_DIR"', text, relative) + self.assertIn("SWAP_COMMITTED", text, relative) + self.assertIn("trap", text, relative) def test_installers_refuse_unowned_launcher_collision(self): for relative in ("install.sh", "scripts/install-offline.sh", @@ -39,6 +41,45 @@ def test_offline_installers_copy_version_source(self): text = (ROOT / relative).read_text(encoding="utf-8-sig") self.assertIn("VERSION", text, relative) + def test_launchers_reject_links_and_write_atomically(self): + for relative in ("install.sh", "scripts/install-offline.sh"): + text = (ROOT / relative).read_text(encoding="utf-8") + self.assertIn('[ -L "$launcher" ]', text, relative) + self.assertIn('.agentboot.new.', text, relative) + self.assertIn('mv -f "$agentboot_tmp"', text, relative) + for relative in ("scripts/install.ps1", "scripts/install-offline.ps1"): + text = (ROOT / relative).read_text(encoding="utf-8-sig") + self.assertIn("ReparsePoint", text, relative) + self.assertIn("Set-LauncherAtomic", text, relative) + self.assertIn("Restore-AppAtomic", text, relative) + self.assertIn("Complete-AppAtomic", text, relative) + + def test_batch_bootstrap_propagates_installer_exit(self): + text = (ROOT / "install.bat").read_text(encoding="utf-8") + self.assertIn("set \"AB_EXIT=%ERRORLEVEL%\"", text) + self.assertIn("exit /b %AB_EXIT%", text) + + def test_online_installers_validate_python_before_app_commit(self): + shell = (ROOT / "install.sh").read_text(encoding="utf-8") + self.assertLess(shell.index("未能准备 Python3"), shell.index("安装程序到 ${APP_DIR}")) + powershell = (ROOT / "scripts/install.ps1").read_text(encoding="utf-8-sig") + self.assertLess(powershell.index("未能准备 Python3"), powershell.index('Install-AppAtomic $srcDir $AppDir')) + + def test_online_installers_reject_untrusted_accelerators_and_oversized_archives(self): + shell = (ROOT / "install.sh").read_text(encoding="utf-8") + powershell = (ROOT / "scripts/install.ps1").read_text(encoding="utf-8-sig") + self.assertNotIn("ghfast.top", shell + powershell) + self.assertNotIn("gh-proxy.com", shell + powershell) + self.assertIn("20971520", shell) + self.assertIn("20MB", powershell) + self.assertIn("越界路径", shell) + + def test_online_installers_clean_temporary_directories(self): + shell = (ROOT / "install.sh").read_text(encoding="utf-8") + powershell = (ROOT / "scripts/install.ps1").read_text(encoding="utf-8-sig") + self.assertIn('rm -rf "$TMP"', shell) + self.assertIn("Remove-Item $tmp -Recurse -Force", powershell) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_language_persistence.py b/tests/test_language_persistence.py new file mode 100644 index 0000000..d13ffb0 --- /dev/null +++ b/tests/test_language_persistence.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Language changes persist in the active config and prompt.""" + +from pathlib import Path +import sys +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) +import agent # noqa: E402 +import i18n # noqa: E402 +import menu # noqa: E402 + + +class LanguagePersistenceTests(unittest.TestCase): + def test_language_switch_mutates_live_config(self): + cfg = agent.default_config() + with mock.patch.object(menu.agent, "save_config"): + menu.set_lang_persist("en", cfg) + self.assertEqual(cfg["lang"], "en") + + def test_english_prompt_does_not_order_chinese(self): + previous = i18n.get_lang() + try: + i18n.set_lang("en") + prompt = agent.system_prompt() + finally: + i18n.set_lang(previous) + self.assertIn("English", prompt) + self.assertNotIn("简体中文", prompt) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_network_stream_safety.py b/tests/test_network_stream_safety.py new file mode 100644 index 0000000..846d690 --- /dev/null +++ b/tests/test_network_stream_safety.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Model transport, SSE completeness, and HTTP SSRF security regressions.""" + +from pathlib import Path +import socket +import sys +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) + +import agent # noqa: E402 + + +class FakeStream: + def __init__(self, lines): + self.lines = iter(lines) + + def readline(self): + return next(self.lines, b"") + + def read(self, *_args): + return b"" + + +class StreamSafetyTests(unittest.TestCase): + def test_clean_eof_without_terminal_frame_is_rejected(self): + response = FakeStream([b'data: {"choices":[{"delta":{"content":"partial"}}]}\n']) + with self.assertRaisesRegex(agent.ApiError, "中断"): + agent._read_stream(response, lambda _piece: None, None, "https", "example.com", 443) + + def test_partial_tool_call_is_never_returned(self): + response = FakeStream([b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"x","function":{"name":"run_cmd","arguments":"{\\\"command\\\":"}}]}}]}\n']) + with self.assertRaises(agent.StreamInterrupted): + agent._read_stream(response, lambda _piece: None, None, "https", "example.com", 443) + + def test_malformed_sse_is_rejected(self): + response = FakeStream([b"data: {not-json}\n", b"data: [DONE]\n"]) + with self.assertRaisesRegex(agent.ApiError, "JSON"): + agent._read_stream(response, lambda _piece: None, None, "https", "example.com", 443) + + def test_complete_tool_arguments_must_be_valid_object_json(self): + response = FakeStream([ + b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"x","function":{"name":"run_cmd","arguments":"[]"}}]}}]}\n', + b"data: [DONE]\n", + ]) + with self.assertRaisesRegex(agent.ApiError, "工具参数"): + agent._read_stream(response, lambda _piece: None, None, "https", "example.com", 443) + + +class NetworkBoundaryTests(unittest.TestCase): + def test_invalid_model_scheme_is_rejected(self): + with self.assertRaises(agent.ApiError): + agent._validate_model_transport("htps", "127.0.0.1", "secret") + + def test_http_model_with_key_is_rejected_even_on_loopback(self): + with self.assertRaises(agent.ApiError): + agent._validate_model_transport("http", "127.0.0.1", "secret") + + def test_keyless_loopback_http_model_is_allowed(self): + agent._validate_model_transport("http", "localhost", "") + + def test_http_get_rejects_loopback_without_opening(self): + with mock.patch("urllib.request.OpenerDirector.open") as opened: + result = agent.http_get("http://127.0.0.1:12345/internal") + self.assertIn("拒绝", result) + opened.assert_not_called() + + def test_public_url_validation_rejects_private_dns_resolution(self): + private = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.8", 0))] + with mock.patch.object(agent.socket, "getaddrinfo", return_value=private), \ + self.assertRaisesRegex(ValueError, "非公网"): + agent._validate_public_http_url("https://internal.example/path") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_offline_manifest.py b/tests/test_offline_manifest.py index bfd319d..536e08f 100644 --- a/tests/test_offline_manifest.py +++ b/tests/test_offline_manifest.py @@ -37,7 +37,7 @@ def test_hash_manifest_is_generated_per_platform(self): def test_windows_installer_preserves_single_agent_as_argument(self): powershell = (ROOT / "scripts/install-offline.ps1").read_text(encoding="utf-8-sig") self.assertIn("$menuArgs = @($menu, 'offline', '--payload', $PayloadDir) + @($ids)", powershell) - self.assertIn("& $pyRef @menuArgs", powershell) + self.assertIn("& $pyExe @menuArgs", powershell) self.assertIn("Agent 离线安装失败", powershell) diff --git a/tests/test_offline_payload_validation.py b/tests/test_offline_payload_validation.py index a72e4a5..17c6fcd 100644 --- a/tests/test_offline_payload_validation.py +++ b/tests/test_offline_payload_validation.py @@ -20,7 +20,7 @@ def test_missing_npm_payload_fails(self): stage = Path(tmp) (stage / "agents").mkdir() (stage / "agents" / "registry.json").write_text(json.dumps({"agents": [{ - "id": "demo", "method": "npm", "npm": "@scope/demo", "bin": "demo"}]}), + "id": "demo", "method": "npm", "npm": "@scope/demo", "bin": "demo", "offline": True}]}), encoding="utf-8") (stage / "payloads" / "node" / "linux-x64").mkdir(parents=True) result = subprocess.run([sys.executable, str(VALIDATOR), str(stage), @@ -46,13 +46,26 @@ def test_unsupported_requested_platform_fails(self): stage = Path(tmp) (stage / "agents").mkdir() (stage / "agents" / "registry.json").write_text(json.dumps({"agents": [{ - "id": "coco", "method": "script", "bin": "coco", "os": ["linux", "darwin"]}]}), + "id": "coco", "method": "script", "bin": "coco", "offline": True, + "os": ["linux", "darwin"]}]}), encoding="utf-8") result = subprocess.run([sys.executable, str(VALIDATOR), str(stage), "win-x64", "coco"], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertIn("does not support", result.stderr) + def test_agent_not_marked_offline_fails(self): + with tempfile.TemporaryDirectory() as tmp: + stage = Path(tmp) + (stage / "agents").mkdir() + (stage / "agents" / "registry.json").write_text(json.dumps({"agents": [{ + "id": "online", "method": "npm", "npm": "online", "bin": "online", "offline": False}]}), + encoding="utf-8") + result = subprocess.run([sys.executable, str(VALIDATOR), str(stage), "linux-x64", "online"], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not marked offline-capable", result.stderr) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_offline_transaction.py b/tests/test_offline_transaction.py new file mode 100644 index 0000000..f834e43 --- /dev/null +++ b/tests/test_offline_transaction.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Offline Agent updates roll back when shim commit fails.""" + +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) + +import menu # noqa: E402 + + +class OfflineTransactionTests(unittest.TestCase): + def test_existing_payload_restored_when_shim_fails(self): + agent = {"id": "demo", "name": "Demo", "bin": "demo", "method": "npm", + "npm": "demo", "node": ">=18"} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload = root / "payloads" + source = payload / "agents" / "demo" / menu.plat_id() / "node_modules" / "demo" + source.mkdir(parents=True) + (source / "package.json").write_text('{"bin":{"demo":"demo.js"}}', encoding="utf-8") + (source / "demo.js").write_text("new", encoding="utf-8") + agents_dir = root / "managed" + old = agents_dir / "demo" / "node_modules" / "demo" + old.mkdir(parents=True) + (old / "old.txt").write_text("keep", encoding="utf-8") + with mock.patch.object(menu, "AGENTS_DIR", str(agents_dir)), \ + mock.patch.object(menu, "RUNTIME_DIR", str(root / "runtime")), \ + mock.patch.object(menu, "load_registry", return_value=[agent]), \ + mock.patch.object(menu, "find_payload_dir", return_value=str(payload)), \ + mock.patch.object(menu.shutil, "which", return_value="node"), \ + mock.patch.object(menu, "node_ok", return_value=True), \ + mock.patch.object(menu, "write_shim", return_value=False), \ + mock.patch.object(menu, "ensure_path_registered"): + failures = menu.offline_install(["demo"], str(payload)) + self.assertEqual(failures, ["demo"]) + self.assertTrue((old / "old.txt").is_file()) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_process_timeout.py b/tests/test_process_timeout.py new file mode 100644 index 0000000..c5aa878 --- /dev/null +++ b/tests/test_process_timeout.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Command timeout must terminate descendants and keep output bounded.""" + +from pathlib import Path +import os +import sys +import tempfile +import time +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) + +import agent # noqa: E402 + + +@unittest.skipIf(os.name == "nt", "POSIX process-group regression") +class ProcessTimeoutTests(unittest.TestCase): + def test_timeout_kills_background_child(self): + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "survived" + command = "(sleep 6; touch %s) & sleep 30" % marker + result = agent.run_cmd(command, timeout=5) + self.assertIn("exit=124", result) + time.sleep(1.5) + self.assertFalse(marker.exists()) + + def test_large_output_is_bounded(self): + result = agent.run_cmd("yes x | head -c 200000", timeout=10) + self.assertLess(len(result), 10000) + self.assertIn("截断", result) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_proxy_behavior.py b/tests/test_proxy_behavior.py new file mode 100644 index 0000000..638a984 --- /dev/null +++ b/tests/test_proxy_behavior.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Saved proxy settings apply to Python downloads and model connections.""" + +from pathlib import Path +import json +import os +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) +import agent # noqa: E402 +import menu # noqa: E402 + + +class ProxyBehaviorTests(unittest.TestCase): + def test_invalid_proxy_is_rejected(self): + with mock.patch.object(menu, "load_env_json", return_value={}), \ + mock.patch.object(menu, "save_env_json") as save: + self.assertFalse(menu.set_proxy("socks5://localhost:1080")) + save.assert_not_called() + + def test_saved_proxy_updates_python_environment(self): + with mock.patch.object(menu, "load_env_json", return_value={}), \ + mock.patch.object(menu, "save_env_json"), \ + mock.patch.object(menu, "npm_cmd", return_value=None), \ + mock.patch.object(menu, "write_env_scripts"): + self.assertTrue(menu.set_proxy("http://127.0.0.1:7890")) + self.assertEqual(os.environ.get("HTTPS_PROXY"), "http://127.0.0.1:7890") + os.environ.pop("HTTP_PROXY", None); os.environ.pop("HTTPS_PROXY", None) + + def test_https_model_connection_tunnels_through_proxy(self): + fake = mock.Mock() + with mock.patch.dict(os.environ, {"HTTPS_PROXY": "http://proxy.example:8080"}, clear=False), \ + mock.patch("http.client.HTTPSConnection", return_value=fake) as connection: + agent._POOL.clear() + self.assertIs(agent._connect("https", "api.example", 443), fake) + connection.assert_called_once() + fake.set_tunnel.assert_called_once_with("api.example", 443, headers={}) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index f70114d..ae27d2c 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -78,6 +78,22 @@ def read(self): self.assertTrue(response.drained) drop.assert_not_called() + def test_ab_run_prints_final_answer_in_interactive_mode(self): + with mock.patch.object(agent, "load_config", return_value=agent.default_config()), \ + mock.patch.object(agent, "_is_interactive", return_value=True), \ + mock.patch.object(agent, "agent_loop", return_value=("answer", [])), \ + mock.patch.object(sys, "argv", ["agent.py", "run", "task"]), \ + mock.patch("builtins.print") as printed: + agent.main() + self.assertTrue(any(call.args == ("answer",) for call in printed.call_args_list)) + + def test_provider_name_selects_requested_provider(self): + cfg = {"active": "first", "providers": {"first": {}, "second": {}}} + with mock.patch.object(agent, "chat", return_value=("ok", [])) as chat: + ok, _message = agent.test_provider(cfg, name="second") + self.assertTrue(ok) + self.assertEqual(chat.call_args.args[0]["active"], "second") + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_release_staging.py b/tests/test_release_staging.py new file mode 100644 index 0000000..11bca5f --- /dev/null +++ b/tests/test_release_staging.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Release staging excludes ambient and ignored workspace files.""" + +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +STAGER = ROOT / "scripts" / "tools" / "stage_application.py" + + +class ReleaseStagingTests(unittest.TestCase): + def test_explicit_stage_excludes_workspace_artifacts(self): + with tempfile.TemporaryDirectory() as tmp: + stage = Path(tmp) / "stage" + subprocess.run([sys.executable, str(STAGER), str(ROOT), str(stage)], check=True) + self.assertTrue((stage / "core" / "agent.py").is_file()) + for relative in ("tests", "pages", ".github", "results.tsv", "run.log", ".env"): + self.assertFalse((stage / relative).exists(), relative) + + def test_builders_use_explicit_stager(self): + for relative in ("scripts/build-offline.sh", "scripts/build-offline.ps1"): + text = (ROOT / relative).read_text(encoding="utf-8-sig") + self.assertIn("stage_application.py", text, relative) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 6bed7fe..373c5af 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -18,11 +18,21 @@ def test_release_supports_prerelease_dry_run_and_tag_only_publish(self): self.assertIn("--prerelease", text) self.assertIn("verify-live-release.py", text) self.assertIn("--prerelease=false --latest", text) - self.assertIn("needs: [validate, online, offline-linux, offline-windows]", text) + self.assertIn("needs: [validate, online, offline-linux, offline-windows, macos-smoke]", text) self.assertIn("$smokeHome", text) + self.assertIn("测试-home", text) self.assertNotIn("$home =", text) + self.assertIn("contents: read", text) + self.assertIn("contents: write", text) + self.assertIn("environment: release-production", text) + self.assertIn("persist-credentials: false", text) + self.assertIn("actions/checkout@11d5960a326750d5838078e36cf38b85af677262", text) + self.assertIn("gh release upload", text) + self.assertIn("--clobber", text) self.assertIn("install-offline.sh\" codex", text) self.assertIn("install-offline.ps1\" -Agents codex", text) + self.assertIn("runs-on: macos-15-intel", text) + self.assertIn("PLATFORMS=darwin-x64", text) def test_live_verifier_covers_primary_and_mirror(self): text = (ROOT / "scripts/verify-live-release.py").read_text(encoding="utf-8") @@ -37,12 +47,19 @@ def test_worker_has_reproducible_wrangler_config(self): self.assertIn('"name": "boot"', text) self.assertIn('"pattern": "boot.ide.pub/*"', text) + def test_pages_workflow_pins_actions_and_drops_checkout_credentials(self): + text = (ROOT / ".github/workflows/deploy-pages.yml").read_text(encoding="utf-8") + self.assertNotIn("actions/checkout@v4", text) + self.assertIn("persist-credentials: false", text) + self.assertIn("actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e", text) + def test_worker_health_checks_assets_and_proxy_forwards_ranges(self): text = (ROOT / "cloudflare/worker.js").read_text(encoding="utf-8") self.assertIn("const required =", text) self.assertIn("assets[name] = response.status", text) self.assertIn('"Range", "If-Range"', text) self.assertIn("cacheEverything: !requestHeaders.has(\"Range\")", text) + self.assertIn("resp.status !== 304", text) if __name__ == "__main__": diff --git a/tests/test_runtime_platforms.py b/tests/test_runtime_platforms.py index 866c1ba..93a99d3 100644 --- a/tests/test_runtime_platforms.py +++ b/tests/test_runtime_platforms.py @@ -48,6 +48,12 @@ def test_or_range_rejects_unsupported_node_major(self): self.assertTrue(menu._version_satisfies((24, 15, 0), requirement)) self.assertFalse(menu._version_satisfies((24, 14, 9), requirement)) + def test_unknown_architecture_is_rejected(self): + with mock.patch.object(menu.platform, "system", return_value="Linux"), \ + mock.patch.object(menu.platform, "machine", return_value="riscv64"), \ + self.assertRaisesRegex(RuntimeError, "不支持"): + menu.plat_id() + class OfflineBuilderTests(unittest.TestCase): def test_default_build_targets_native_platform_only(self): @@ -78,6 +84,16 @@ def test_online_uv_seed_verifies_downloaded_digest(self): self.assertIn("hashlib.sha256(open(archive", source) self.assertIn("uv SHA-256 校验失败", source) + def test_generic_uv_seeder_verifies_downloaded_digest(self): + source = (ROOT / "scripts/tools/seed_uv_generic.py").read_text(encoding="utf-8") + self.assertIn("hashlib.sha256", source) + self.assertIn("uv SHA-256 mismatch", source) + + def test_posix_builder_uses_root_registry_and_verifies_windows_python(self): + shell = (ROOT / "scripts/build-offline.sh").read_text(encoding="utf-8") + self.assertIn('"$ROOT/agents/registry.json" "$AID"', shell) + self.assertIn("4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3", shell) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_state_safety.py b/tests/test_state_safety.py new file mode 100644 index 0000000..97804ad --- /dev/null +++ b/tests/test_state_safety.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Config/session privacy, validation, and atomic state regressions.""" + +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "core")) + +import agent # noqa: E402 + + +class StateSafetyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name) / "agentboot" + self.config = self.home / "config.json" + self.session = self.home / "last-session.json" + self.patches = [mock.patch.object(agent, "AB_HOME", str(self.home)), + mock.patch.object(agent, "CONFIG_PATH", str(self.config)), + mock.patch.object(agent, "SESSION_FILE", str(self.session))] + for patch in self.patches: patch.start() + + def tearDown(self): + for patch in reversed(self.patches): patch.stop() + self.tmp.cleanup() + + def test_sensitive_files_and_home_are_private(self): + agent.save_config(agent.default_config()) + agent.save_session([{"role": "user", "content": "secret"}]) + self.assertEqual(stat.S_IMODE(os.stat(self.home).st_mode), 0o700) + self.assertEqual(stat.S_IMODE(os.stat(self.config).st_mode), 0o600) + self.assertEqual(stat.S_IMODE(os.stat(self.session).st_mode), 0o600) + + def test_invalid_root_and_values_fall_back_safely(self): + self.home.mkdir() + self.config.write_text("[]", encoding="utf-8") + self.assertEqual(agent.load_config()["confirm"], "smart") + self.config.write_text(json.dumps({"confirm": "TYPO", "max_steps": 0, "providers": []}), encoding="utf-8") + cfg = agent.load_config() + self.assertEqual((cfg["confirm"], cfg["max_steps"], cfg["providers"]), ("smart", 12, {})) + + def test_max_steps_is_bounded(self): + self.home.mkdir() + self.config.write_text(json.dumps({"max_steps": 999999}), encoding="utf-8") + self.assertEqual(agent.load_config()["max_steps"], 50) + + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 6d65fd7..a2ea026 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -2,6 +2,7 @@ """Fixed acceptance tests for AgentBoot's Agent uninstall lifecycle.""" import contextlib +import hashlib import io import json import os @@ -10,6 +11,7 @@ import subprocess import sys import tempfile +import tarfile import unittest from unittest import mock @@ -121,6 +123,19 @@ def test_online_npm_uninstall_uses_exact_package_name(self): self.assertEqual(command[:4], ["npm", "uninstall", "-g", "@openai/codex"]) self.assertFalse(wrapper.exists()) + def test_managed_uninstall_prefers_recorded_package_over_changed_registry(self): + installed = self.npm_agent(package="old-package@1.0.0") + current = self.npm_agent(package="new-package@2.0.0") + wrapper = self.write_wrapper() + menu.record_install(installed, "online", str(wrapper)) + completed = subprocess.CompletedProcess([], 0) + with mock.patch.object(menu, "npm_cmd", return_value="npm"), \ + mock.patch.object(menu.subprocess, "run", return_value=completed) as run: + ok, _message = menu.uninstall_one(current) + self.assertTrue(ok) + self.assertIn("old-package", run.call_args.args[0]) + self.assertNotIn("new-package", run.call_args.args[0]) + def test_online_pip_uninstall_uses_noninteractive_mode(self): agent = {"id": "aider", "name": "Aider", "bin": "aider", "method": "pip", "pip": "aider-install"} @@ -165,6 +180,69 @@ def test_purge_removes_coco_user_data_after_explicit_request(self): self.assertTrue(ok) self.assertFalse(coco.exists()) + def test_coco_uninstall_refuses_symlinked_root(self): + target = self.home / "coco-target" + target.mkdir() + program = target / "bin" + program.mkdir() + (program / "coco").write_text("keep", encoding="utf-8") + (self.home / ".coco").symlink_to(target, target_is_directory=True) + with self.assertRaisesRegex(OSError, "符号链接"): + menu._remove_coco(False) + self.assertTrue((program / "coco").exists()) + + def test_coco_uninstall_removes_verified_external_launchers(self): + coco = self.home / ".coco" + (coco / "bin").mkdir(parents=True) + target = coco / "bin" / "coco" + target.write_text("app", encoding="utf-8") + external_dir = self.home / ".local" / "bin" + external_dir.mkdir(parents=True) + for name in ("coco", "web", "coweb"): + (external_dir / name).symlink_to(target) + entry = {"executable": str(external_dir / "coco")} + menu._remove_coco_external_launchers(entry) + self.assertFalse(any((external_dir / name).exists() for name in ("coco", "web", "coweb"))) + + def test_coco_offline_private_node_survives_install_swap(self): + payload = self.home / "payload" + payload.mkdir() + tgz = payload / "coco-0.8.0.tgz" + with tarfile.open(tgz, "w:gz") as archive: + info = tarfile.TarInfo("package/bin/coco") + data = b"console.log('coco')\n" + info.size = len(data) + info.mode = 0o755 + archive.addfile(info, io.BytesIO(data)) + digest = hashlib.sha256(tgz.read_bytes()).hexdigest() + (payload / "coco-0.8.0.tgz.sha256").write_text(digest + "\n", encoding="ascii") + (payload / "agnes.key").write_text("test-key", encoding="ascii") + node_archive = payload / "node-v22.23.2-linux-x64.tar.gz" + with tarfile.open(node_archive, "w:gz") as archive: + info = tarfile.TarInfo("node-v22.23.2-linux-x64/bin/node") + data = b"#!/bin/sh\necho v22.23.2\n" + info.size = len(data) + info.mode = 0o755 + archive.addfile(info, io.BytesIO(data)) + agent = {"id": "coco", "name": "CoCo", "bin": "coco", "method": "script"} + with mock.patch.object(menu.shutil, "which", return_value=None): + self.assertTrue(menu.coco_offline_install(agent, str(payload))) + node = self.home / ".coco" / "runtime" / "node" / "bin" / "node" + self.assertTrue(node.is_file()) + shim = (self.bin_dir / "coco").read_text(encoding="utf-8") + self.assertIn(str(node), shim) + + def test_safe_tar_rejects_parent_traversal(self): + archive_path = self.home / "evil.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + info = tarfile.TarInfo("../escaped") + data = b"bad" + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + with tarfile.open(archive_path, "r:gz") as archive, \ + self.assertRaisesRegex(ValueError, "越界"): + menu._safe_extract_tar(archive, str(self.home / "extract")) + def test_generic_script_install_refuses_unsafe_automatic_removal(self): agent = {"id": "custom-script", "name": "Custom", "bin": "custom", "method": "script", "script": "https://example.test/install.sh", "custom": True} diff --git "a/\345\256\211\350\243\205\346\214\207\345\215\227.md" "b/\345\256\211\350\243\205\346\214\207\345\215\227.md" index 7ef8d0f..d113265 100644 --- "a/\345\256\211\350\243\205\346\214\207\345\215\227.md" +++ "b/\345\256\211\350\243\205\346\214\207\345\215\227.md" @@ -34,7 +34,7 @@ curl -fsSL https://bit-cook.github.io/AgentBoot/install.sh | sh # 直连 GitHub curl -fsSL https://raw.githubusercontent.com/bit-cook/AgentBoot/main/install.sh | sh # 国内加速代理 -curl -fsSL https://ghfast.top/https://raw.githubusercontent.com/bit-cook/AgentBoot/main/install.sh | sh +curl -fsSL https://bit-cook.github.io/AgentBoot/install.sh | sh ``` **安装完成后:** @@ -213,7 +213,7 @@ AgentBoot 对国内复杂网络做了开箱即用的处理: - **npm 镜像**:自动切换到 `https://registry.npmmirror.com` 安装所有 npm 类 Agent; - **Node 运行时镜像**:便携 Node 从 npmmirror 二进制镜像下载(备用 nodejs.org); - **pip 镜像**:Aider 等 Python 工具自动使用清华 PyPI 镜像; -- **多源下载容错**:安装包下载按 `boot.ide.pub(Cloudflare) → GitHub → ghfast.top → gh-proxy.com` 顺序自动重试; +- **多源下载容错**:安装包只从项目控制的 `boot.ide.pub → GitHub Pages → GitHub Release` 获取,并强制同源 SHA-256; - **代理支持**:菜单 `[5] 镜像与代理设置` 可一键为 npm 与 AgentBoot 自身配置 HTTP 代理; - **手动强制**:环境变量 `AGENTBOOT_MIRROR=cn|off` 可强制开启/关闭镜像模式(默认自动)。 @@ -280,6 +280,10 @@ rm -rf ~/.agentboot ~/.local/bin/agentboot ~/.local/bin/ab # Linux/macOS rmdir /s /q "%USERPROFILE%\.agentboot" & rmdir /s /q "%LOCALAPPDATA%\AgentBoot" # Windows ``` +POSIX 完全卸载后,还应删除 shell 配置中 `# >>> agentboot >>>` 到 `# <<< agentboot <<<` 的整段;Windows 在用户 Path 中删除 AgentBoot 的两个 bin 目录。 + +下载离线包后可先运行 `sha256sum -c SHA256SUMS.txt --ignore-missing` 验证外部归档;包内 `PAYLOAD_SHA256SUMS.txt` 用于解压后的逐文件二次校验。 + 也可在控制台选择菜单 `[9] 卸载 Agent`。AgentBoot 会记录安装归属,避免误删系统中碰巧同名的外部命令;默认保留 Agent 配置、认证信息与会话。`--purge` 目前只定义了 CoCo 的数据清理边界,其他 Agent 的数据目录不会被猜测删除。
#Agent命令厂商离线
1CoCo AgentcocoBitCookLinux/macOS
2OpenCodeopencodeopencode.ai
2OpenCodeopencodeopencode.ai仅在线
3Hermes AgenthermesHermes✓(需 Git)
4Cline CLIclineCline
5CodeBuddy CLIcodebuddyTencent