diff --git a/.gitignore b/.gitignore index 3cab8632..066a334c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,12 @@ target/ coverage.out coverage.html +# One-click update artifacts (internal/update): machine-local, written at the install root +.update-staging/ +.update-backup/ +.update-state.json +cyberstrike-ai.prev + # Logs and temporary files /log/ *.log diff --git a/README.md b/README.md index 38970846..2d0c3610 100644 --- a/README.md +++ b/README.md @@ -269,17 +269,31 @@ If server logs show `client sent an HTTP request to an HTTPS server`, a client i ### Upgrade and Compatibility -**CyberStrikeAI one-click upgrade:** +**Prefer the platform's own one-click update** (console Platform management -> One-click update, or +`./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls **the remote this +installation directory already tracks**, fast-forwards, rebuilds and swaps the binary atomically, keeps +your `roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it +kept), and refuses - rather than overwrites - when source is modified locally or the branch has +diverged. See the [deployment guide](docs/en-US/deployment.md). + +`upgrade.sh` still works: when this directory is a git work tree it is a thin shell over the command +above; only a non-git (tarball) installation falls back to the old "download a GitHub Release and +rsync it in" path, where the source repository comes from `--repo owner/name` or `GITHUB_REPO` and the +built-in default applies only when neither is given (with a warning naming the repository). + +**CyberStrikeAI upgrade script:** 1. (First time) enable the script: `chmod +x upgrade.sh` -2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--yes`). Local `tools/`, `roles/`, and `skills/` are always preserved. -3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server. +2. Upgrade with: `./upgrade.sh` (optional flags: `--check`, `--tag vX.Y.Z`, `--repo owner/name`, `--no-venv`, `--yes`). Local `tools/`, `roles/`, `skills/` and `agents/` are always preserved. +3. The script will back up your `config.yaml` and `data/`, upgrade the code, update `config.yaml`'s `version`, then restart the server. Recommended one-liner: `chmod +x upgrade.sh && ./upgrade.sh --yes` -If something goes wrong, you can restore from `.upgrade-backup/` (or manually copy `/data` and `config.yaml` back) and run `./run.sh` again. +If something goes wrong: inside a git work tree just run `./cyberstrike-ai -update-rollback` (back to +the commit and binary kept before that update); for a tarball installation restore from +`.upgrade-backup/` (or manually copy `data/` and `config.yaml` back) and run `./run.sh` again. -Requirements / tips: +Requirements / tips (tarball path only): * You need `curl` or `wget` for downloading Release packages. * `rsync` is recommended/required for the safe code sync. * If GitHub API rate-limits you, set `export GITHUB_TOKEN="..."` before running `./upgrade.sh`. diff --git a/README_CN.md b/README_CN.md index 869feb2f..9930b34f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -268,16 +268,26 @@ go build -o cyberstrike-ai cmd/server/main.go ### 版本升级与兼容性 +**优先用平台自带的「一键更新」**(控制台「平台管理 → 一键更新」,或 `./cyberstrike-ai -update` / +`-check-update` / `-update-rollback`):它拉的是**本安装目录自己跟踪的远端**,快进后重编译并原子换二进制, +你的 `roles/skills/tools/agents/data/config.yaml` 会被原样保留(结果里逐个点名保留了什么), +本地源码有改动或分支已分叉时它会拒绝而不是覆盖。详见 [部署指南](docs/zh-CN/deployment.md)。 + +`upgrade.sh` 仍然可用:本目录是 git 工作树时它就是上面那条命令的薄壳;不是 git 工作树(tarball 安装)时 +才回落到"下载 GitHub Release 包 + rsync"的老路径,此时源码仓库由 `--repo owner/name` 或 `GITHUB_REPO` +决定(都不给才用内置默认值,并会警告说明代码来自哪个仓库)。 + 1. (首次使用)启用脚本:`chmod +x upgrade.sh` -2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/` 会始终保留不被覆盖。 -3. 脚本会备份你的 `config.yaml` 和 `data/`,从 GitHub Release 升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。 +2. 升级:`./upgrade.sh`(可选参数:`--check`、`--tag vX.Y.Z`、`--repo owner/name`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/`、`agents/` 会始终保留不被覆盖。 +3. 脚本会备份你的 `config.yaml` 和 `data/`,升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。 推荐的一键指令: `chmod +x upgrade.sh && ./upgrade.sh --yes` -如果升级失败,可以从 `.upgrade-backup/` 恢复,或按旧方式手动拷贝 `/data` 和 `config.yaml` 后再运行 `./run.sh`。 +如果升级失败:git 工作树里直接 `./cyberstrike-ai -update-rollback`(回到那次更新前的提交与二进制); +tarball 安装则从 `.upgrade-backup/` 恢复,或手动拷回 `data/` 和 `config.yaml` 后再运行 `./run.sh`。 -依赖/提示: +依赖/提示(只有 tarball 路径用得到): * 需要 `curl` 或 `wget` 用于下载 GitHub Release 包。 * 建议/需要 `rsync` 用于安全同步代码。 * 如果遇到 GitHub API 限流,运行前设置 `export GITHUB_TOKEN="..."` 再执行 `./upgrade.sh`。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 898abf2a..b2581e5e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,8 +27,15 @@ func main() { var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file") var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit") checkIsolation := flag.Bool("check-process-isolation", false, "Probe task containment and cleanup, then exit without starting services") + checkUpdate := flag.Bool("check-update", false, "Report whether this installation's own repository has anything newer, then exit") + applyUpdate := flag.Bool("update", false, "Fast-forward this installation to its own remote, rebuild the binary, and exit") + updateRollback := flag.Bool("update-rollback", false, "Undo the last --update: back to the commit and binary kept before it") flag.Parse() + if handled, code := updateCommandIfNeeded(strings.TrimSpace(*configPath), *checkUpdate, *applyUpdate, *updateRollback); handled { + os.Exit(code) + } + // 环境变量兼容(便于 systemd/docker 等不传参场景) if *httpsBootstrap && *httpBootstrap { fmt.Fprintln(os.Stderr, "--http and --https cannot be used together") diff --git a/cmd/server/update_cli.go b/cmd/server/update_cli.go new file mode 100644 index 00000000..fde9f0dc --- /dev/null +++ b/cmd/server/update_cli.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "cyberstrike-ai/internal/update" + "fmt" + "os" + "path/filepath" + "time" +) + +// The same one-click update the console page drives, for the two cases a page cannot +// serve: the process is not running at all, and somebody is ssh'd in with a keyboard. +// Keeping it in the binary rather than only in a shell script means one implementation of +// "fetch, fast-forward, rebuild, swap" instead of two that can disagree. +func updateCommandIfNeeded(configPath string, check, apply, rollback bool) (bool, int) { + if !check && !apply && !rollback { + return false, 0 + } + root := filepath.Dir(configPath) + opts := update.Options{Root: root} + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute) + defer cancel() + + fmt.Printf("安装目录:%s\n", root) + switch { + case check: + snap, err := update.Check(ctx, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "检查更新失败: %v\n", err) + return true, 1 + } + printSnapshot(snap) + return true, 0 + case apply: + res, err := update.Apply(ctx, opts, func(s update.Step) { + fmt.Printf("[%s] %s\n", s.Phase, s.Message) + }) + if res != nil { + printResult(res) + } + if err != nil { + fmt.Fprintf(os.Stderr, "更新未完成: %v\n", err) + return true, 1 + } + fmt.Println("源码与二进制已就位;重启服务后新代码生效。") + return true, 0 + case rollback: + res, err := update.Rollback(ctx, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "回滚未完成: %v\n", err) + return true, 1 + } + printResult(res) + return true, 0 + } + return true, 0 +} + +func printSnapshot(s *update.Snapshot) { + if !s.Installed { + fmt.Printf("这个目录不是 git 工作树(%s),无法自动更新\n", s.Root) + return + } + fmt.Printf("分支 %s / 远端 %s / 提交 %s (%s)\n", s.Branch, s.Remote, s.Commit, s.Subject) + if s.CheckError != "" { + fmt.Printf("检查失败:%s\n", s.CheckError) + return + } + fmt.Printf("远端最新 %s (%s),落后 %d 个提交,本地领先 %d 个提交\n", s.RemoteCommit, s.RemoteSubject, s.Behind, s.Ahead) + for _, c := range s.Incoming { + fmt.Printf(" ○ %s %s\n", c.Commit, c.Subject) + } + if s.Behind > len(s.Incoming) { + fmt.Printf(" …(共 %d 个,只列出 %d 个)\n", s.Behind, len(s.Incoming)) + } + if len(s.BlockingChanges) > 0 { + fmt.Printf("本机改过 %d 个源码文件,更新会拒绝执行:\n", len(s.BlockingChanges)) + for _, c := range s.BlockingChanges { + fmt.Printf(" ! %s (%s)\n", c.Path, c.Status) + } + } + if !s.CanBuild { + fmt.Println("本机没有 Go 工具链:更新只搬源码,不会重编译") + } + if s.HasRollback { + fmt.Printf("可回滚到 %s\n", s.RollbackTo) + } +} + +func printResult(r *update.Result) { + if r.Commits == 0 && r.ToCommit == r.FromCommit { + fmt.Println("已经是最新,没有改动") + return + } + fmt.Printf("%s → %s(%d 个提交,涉及 %d 个文件,用时 %s)\n", r.FromCommit, r.ToCommit, r.Commits, r.FilesTouched, r.Duration) + if len(r.KeptContent) > 0 { + fmt.Printf("保留了 %d 个本机内容文件(roles/skills/tools 等,没有被上游覆盖):\n", len(r.KeptContent)) + for _, p := range r.KeptContent { + fmt.Printf(" = %s\n", p) + } + } + if r.BinaryBuilt { + fmt.Printf("二进制已换新:%s(旧版本留在 %s 以便回滚)\n", r.BinaryPath, r.PrevBinary) + } +} diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index b202973f..4a324e5d 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -75,6 +75,62 @@ proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` +## Upgrading + +**Prefer the platform's own one-click update** (console Platform management -> One-click update, +`POST /api/system/update/apply`, or `./cyberstrike-ai -update` at a keyboard). It pulls **the remote +this install directory already tracks**: fetch, fast-forward, `go build`, atomic binary swap (the old +binary is kept as `cyberstrike-ai.prev`), while operator content - `roles/ skills/ tools/ agents/ +knowledge_base/ data/ config.yaml` - is put aside and restored, and the result names every file it +kept. For a fork this is the only path that does not overwrite you with somebody else's code; when +source is modified locally or the branch has diverged it refuses and says why instead of forcing. +See [the developer guide](developer-guide.md) for the full semantics. + +`upgrade.sh` still works and is now a thin shell over that implementation, with two paths decided by +the installation kind: + +- **git work tree**: calls `./cyberstrike-ai -update` directly (building + `go build -o cyberstrike-ai ./cmd/server` first when there is no binary yet, and failing with + instructions on installing Go when there is neither). `--check` inspects only, through + `-check-update`. +- **not a git work tree** (Release tarball install): only here does it fall back to downloading a + Release tarball and `rsync --delete`-ing it in. The source repository then comes from + `--repo owner/name` or the `GITHUB_REPO` environment variable, and the built-in default is used only + when neither is given - with a warning naming which repository the code is coming from. + `config.yaml`, `data/`, `tools/`, `roles/`, `skills/`, `agents/`, `venv/` (skippable with + `--no-venv`) and the rollback points `.update-backup/`, `.update-state.json`, `cyberstrike-ai.prev` + are on the rsync keep list, so `--delete` cannot remove them. + +Recommended flow either way: + +1. Stop the service. +2. Back up `config.yaml`, `data/` and your custom directories. +3. Pull or replace the new code/binary. +4. Keep your own configuration and add fields the new `config.yaml` example introduced. +5. Start the service and check login, model test, tool list and knowledge-base status. + +The one-click update performs step 3 (plus the rebuild); 1, 2, 4 and 5 stay operator decisions - a +database migration does not become compatible just because the binary changed. `upgrade.sh` suits +quick upgrades without compatibility risk; in production still back up first. Python dependencies: +`venv/` is created and managed by `run.sh`, and keeping it means an upgrade does not remove it - only +`--no-venv` deletes it and lets `run.sh` reinstall. + +## Rollback + +The one-click update leaves its own rollback point: `./cyberstrike-ai -update-rollback`, or the button +on the page, goes back to the commit before that update and puts `cyberstrike-ai.prev` back. It runs +only while HEAD is still the commit that update wrote, so work done afterwards is not discarded. +Content that was put aside also survives under `.update-backup//`. + +Beyond that, roll back together: + +- The previous binary or code. +- The pre-upgrade `config.yaml`. +- The pre-upgrade `data/`. + +If the new version already wrote database schema changes, rolling back the binary alone may not be +enough; restore the whole backup. + ## Deployment Decision Table | Scenario | Recommended setup | Key settings | Avoid | diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index 8a77f675..7f7d5de3 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -35,6 +35,79 @@ go run ./cmd/server --config config.yaml The frontend is static. Most JS/CSS/template changes only require a browser refresh. +## One-Click Update: Letting an Installation Update Its Own Source + +On a deployed machine the routine is "my tree moved forward by a few dozen commits -> bring this +installation up -> rebuild -> swap the binary -> restart". This used to be only `upgrade.sh`, which +pointed at one fixed upstream repository, so for anyone running a fork "upgrade" meant "overwrite +yourself with somebody else's code". The capability now lives in the platform (`internal/update`) and +the console page, the REST API and the CLI share one implementation, so the three surfaces cannot +disagree about why an update was refused. + +### Three entry points + +| entry | how it is used | +|---|---| +| console | Platform management -> One-click update (`#system-update`). Opening the page reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the page also carries the "exit after updating" option and a rollback button | +| REST | `GET /api/system/update` (state on disk, no network), `POST /api/system/update/check` (fetch, then report the gap), `POST /api/system/update/apply` (`202` with `job_id`, polled through `GET /api/system/update/job`), `POST /api/system/update/rollback` | +| CLI | `./cyberstrike-ai -check-update`, `./cyberstrike-ai -update`, `./cyberstrike-ai -update-rollback`. The install root is the directory holding `--config`, or the current directory when `--config` was not given | + +Permissions are `update:read` (GET) and `update:apply` (the four mutating endpoints). `update:apply` +additionally requires a global (`all` scope) session: one machine has one source tree, and an +`assigned`/`own` session must not be able to move the code everybody else is running. + +### Where the code comes from + +From **the remote this directory already tracks**; no third-party repository is hardcoded. The remote +is picked from the ones this tree has, in the order `mine`, `origin`, `upstream` (falling back to the +first remote when none of those names exists), and the branch defaults to what the current branch's +`@{upstream}` names (the current branch itself when there is no upstream). `upgrade.sh` is now a thin +shell over the same implementation: when this directory is a git work tree it just calls +`./cyberstrike-ai -update`. + +### The four refusals + +| situation | reason | meaning and what to do | +|---|---|---| +| product **source** modified locally (a path outside the protected list) | `local_source_edits` | An update is a download, not a conflict decision. The file names are listed; commit or restore them first | +| branch **diverged** from its remote (ahead and behind at once) | `diverged` | That is a merge decision; the counts are reported so you can resolve it by hand | +| no Go toolchain on this box | `no_toolchain` | **The source still updates**; only the binary is not swapped. Install `go` and press update again to finish | +| this directory is not a git work tree | `not_a_repo` (`installed: false` in the status) | A tarball installation has no remote to fast-forward; update it through the release package | + +`apply` is asynchronous, so it always answers `202` first and the refusals above arrive in the polled +`job.failure` (`reason` / `message` / `items`). `rollback` is synchronous and maps refusals to status +codes (`local_source_edits`, `diverged`, `no_state`, `moved_since_update`, `no_binary` -> 409, anything +else -> 400). Other failures (`merge_failed`, `build_failed`, `swap_failed`) carry git's or the +compiler's own output; a failed build leaves the previous binary in place while the source has already +moved and the state file is written, so it stays rollback-able. + +### How operator content survives + +`roles/ skills/ tools/ agents/ knowledge_base/ data/ log/ venv/ config.yaml .env` belong to the +**operator** (`update.Protected` is the judge). Before the merge, only those of those paths the +update actually writes to are copied into `.update-backup//` and removed from the tree - +your extra directories that upstream never touched are not disturbed - and they are put back +afterwards, which is what "update the code, keep my work" means. The result names every file in +`keptContent` instead of quietly dropping it: where both you and upstream changed the same file, the +version that lands is yours, and the result says so. + +### Rollback + +`./cyberstrike-ai -update-rollback`, or the button on the page: `git reset --hard` back to the commit +the update came from, the binary kept as `cyberstrike-ai.prev` is put back, and `.update-state.json` +is removed. It refuses when there is no update record (`no_state`), no kept binary (`no_binary`), the +recorded commit is not in this repository (`bad_state`), **HEAD has moved since that update** +(`moved_since_update` - a rollback undoes the update, not the work done after it), or source is +modified locally (`local_source_edits`). + +### The two restart cases + +The process only stands down (graceful `Shutdown`, then exit 0) when the request explicitly says +`restart: true` **and** a restart hook was wired at startup. Whether it actually comes back is up to a +supervisor (systemd, `run.sh`), and the page says exactly that rather than promising a boot that may +not happen. Otherwise the API only reports `needsRestart` and the old binary keeps running. Asking for +a restart with no hook wired is a `400`, instead of stopping the service and claiming it restarted. + ## Adding a Business Module Do not add only a handler. A complete module usually needs: @@ -109,3 +182,6 @@ High-value tests: - OpenAPI: `internal/handler/openapi.go` - Tool executor: `internal/security/executor.go` - Skill package: `internal/skillpackage/` +- One-click update: `internal/update/` (status, apply, rollback), HTTP surface in + `internal/handler/update.go`, CLI switches in `cmd/server/update_cli.go`, thin shell in + `upgrade.sh` diff --git a/docs/en-US/rbac.md b/docs/en-US/rbac.md index 3f76f261..170c64d7 100644 --- a/docs/en-US/rbac.md +++ b/docs/en-US/rbac.md @@ -74,6 +74,7 @@ Permissions use `module:action`. Common actions are `read`, `write`, `delete`, a | WebShell | `webshell:read`, `webshell:write`, `webshell:delete` | | C2 | `c2:read`, `c2:write`, `c2:delete` | | MCP | `mcp:read`, `mcp:execute`, `mcp:write`, `mcp:external:execute` | +| One-click update (local source) | `update:read`, `update:apply` | | Knowledge | `knowledge:read`, `knowledge:write`, `knowledge:delete` | | Skills | `skills:read`, `skills:write`, `skills:delete` | | Markdown Agents | `agents:read`, `agents:write`, `agents:delete` | @@ -99,6 +100,11 @@ Important distinctions: - `mcp:external:execute` allows Agent calls to external MCP tools and currently also requires `all` scope. - `fofa:execute` is kept for backward compatibility, but it now protects the Reconnaissance page for FOFA, ZoomEye, Quake, and Shodan searches. - `mcp:write` manages external MCP configuration; it is separate from external tool execution. +- One-click update is two permissions: `update:read` shows the installed version and the gap against + the remote, while `update:apply` pulls this installation's own repository, rebuilds the platform + binary and swaps it. The mutating side additionally requires the session to hold that permission + with `all` scope - one machine has one source tree, so an `assigned`/`own` session must not be able + to move the code everybody else is running. - `robot:write` manages robot configuration and the test endpoint. Chatbot conversations use the bound user or configured service account's business permissions. --- @@ -143,6 +149,7 @@ Some definitions have no owner. Their mutations require the corresponding permis - Knowledge mutations other than search. - Global HITL allowlist, reviewer, and audit policy. - C2 Profile mutations. +- One-click update mutations (`check`/`apply`/`rollback`, i.e. `update:apply`). - Some global monitor statistics. --- diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 4304fb17..5cd929d5 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -179,6 +179,24 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal` ## 升级 +**优先用平台自带的「一键更新」**(控制台「平台管理 → 一键更新」、`POST /api/system/update/apply`、 +或键盘上的 `./cyberstrike-ai -update`)。它拉的是**这个安装目录自己跟踪的远端**:fetch → 快进合并 → +`go build` → 原子换二进制(旧的留作 `cyberstrike-ai.prev`),并把 `roles/ skills/ tools/ agents/ +knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果里点名保留了哪些文件。 +跑 fork 时这是唯一不会"用别人的代码覆盖自己"的路径;本地源码有改动或分支已分叉时它会拒绝并说明原因, +而不是硬来。细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。 + +`upgrade.sh` 仍然可用,它现在只是这条实现的薄壳,并按安装形态分两条路: + +- **本目录是 git 工作树**:直接调用 `./cyberstrike-ai -update`(还没有二进制时先 + `go build -o cyberstrike-ai ./cmd/server` 再更新;两者都没有就明确报错并说明怎么装 Go)。 + `--check` 只检查不改动,走 `-check-update`。 +- **本目录不是 git 工作树**(Release tarball 安装):才回落到"下载 Release 包 + `rsync --delete`"的老路径。 + 此时源码仓库取自 `--repo owner/name` 或环境变量 `GITHUB_REPO`,两者都没给才用内置默认值, + 并且会打一行警告说明代码正从哪个仓库取。`config.yaml`、`data/`、`tools/`、`roles/`、`skills/`、 + `agents/`、`venv/`(`--no-venv` 可关闭)以及回滚点 `.update-backup/`、`.update-state.json`、 + `cyberstrike-ai.prev` 都在 rsync 的保留名单里,不会被 `--delete` 顺手删掉。 + 推荐流程: 1. 停止服务。 @@ -187,11 +205,18 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal` 4. 保留原配置,按新版 `config.yaml` 示例补新增字段。 5. 启动服务,检查登录、模型测试、工具列表、知识库状态。 -仓库提供 `upgrade.sh`,适合无兼容性问题的快速升级;生产环境仍建议先备份再运行。 +一键更新只做 3(以及需要时的换二进制),1/2/4/5 仍是运维者的判断:数据库结构变更不会因为二进制换了 +就自动兼容。`upgrade.sh` 适合无兼容性问题的快速升级;生产环境仍建议先备份再运行。 +Python 依赖方面:`venv/` 由 `run.sh` 自动创建和管理,保留它就不会被升级打掉;用 `--no-venv` 才会删除并由 +`run.sh` 重装。 ## 回滚 -回滚时同时恢复: +一键更新自带回滚点:`./cyberstrike-ai -update-rollback` 或页面上的回滚按钮,会撤到那次更新前的提交并把 +`cyberstrike-ai.prev` 换回原位;它只在"HEAD 仍是那次更新写下的提交"时才执行,之后的工作不会被顺手抹掉。 +被暂存过的内容也留在 `.update-backup/<时间戳>/` 里。 + +除此之外,回滚时同时恢复: - 上一版本二进制或代码。 - 升级前的 `config.yaml`。 diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index 2ce43245..e7fe0932 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -43,6 +43,66 @@ go run ./cmd/server --config config.yaml 4. 如需对外文档,更新 `internal/handler/openapi.go`。 5. 如需前端调用,更新 `web/static/js/`。 +## 一键更新:让安装目录更新它自己的源码 + +部署现场的日常是"我的 fork 又前进了几十个提交 → 把这套安装带上去 → 重新编译 → 换二进制 → 重启"。 +这件事以前只有 `upgrade.sh`,而它写死了别人的仓库:对跑 fork 的人来说"升级"等于"用别人的代码覆盖自己"。 +现在这个能力做进了平台自己(`internal/update`),页面、REST 与 CLI 三条入口共用同一份实现, +所以它们给出的拒绝理由不可能各说各话。 + +### 三种入口 + +| 入口 | 怎么用 | +|---|---| +| 控制台 | 「平台管理 → 一键更新」(`#system-update`)。打开页面只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;页面另有"更新完成后退出进程"勾选与回滚按钮 | +| REST | `GET /api/system/update`(磁盘现状,不联网)、`POST /api/system/update/check`(fetch 后报告差集)、`POST /api/system/update/apply`(`202` 返回 `job_id`,用 `GET /api/system/update/job` 轮询)、`POST /api/system/update/rollback` | +| CLI | `./cyberstrike-ai -check-update`、`./cyberstrike-ai -update`、`./cyberstrike-ai -update-rollback`。安装目录 = `--config` 所在目录,未给 `--config` 时是当前目录 | + +权限是 `update:read`(GET)与 `update:apply`(四个写接口)。`update:apply` 还要求会话是 global(`all` scope): +一台机器只有一份源码,`assigned`/`own` scope 的账号不该能移动别人正在跑的代码。 + +### 它从哪里取代码 + +从**这个目录自己的远端**,没有任何写死的第三方仓库地址。远端按 `mine → origin → upstream` 的次序在这个目录已有的 +remote 里取(都不在时取第一个 remote),分支默认取当前分支 `@{upstream}` 指向的名字(没有 upstream 时用当前分支名)。 +`upgrade.sh` 现在只是这条实现的薄壳:本目录是 git 工作树时它直接调 `./cyberstrike-ai -update`。 + +### 四种拒绝场景 + +| 场景 | reason | 含义与处理 | +|---|---|---| +| 本地改过**产品源码**(不在 protected 清单里的路径) | `local_source_edits` | 一键更新是"下载",不是"替你决定冲突"。点名文件,先提交或还原再重试 | +| 分支与远端**分叉**(本地既领先又落后) | `diverged` | 那是合并决策,给出 ahead/behind 数字;手工处理后再点 | +| 本机没有 Go 工具链 | `no_toolchain` | **源码照样更新**,只有二进制没换;装好 `go` 再点一次即可补上 | +| 这个目录不是 git 工作树 | `not_a_repo`(状态里 `installed: false`) | tarball 安装的目录没有可快进的远端,按发布包方式更新 | + +`apply` 是异步的,所以它总是先回 `202`,上表的拒绝出现在轮询到的 `job.failure`(`reason`/`message`/`items`)里; +`rollback` 是同步的,拒绝直接落到状态码(`local_source_edits`、`diverged`、`no_state`、`moved_since_update`、 +`no_binary` → 409,其余 → 400)。其他失败(`merge_failed`、`build_failed`、`swap_failed`)带着 git/go 的原始输出; +编译失败时二进制保持原版本,而源码已移动、状态文件已写好,因此仍然可以回滚。 + +### 运维者的内容如何被保留 + +`roles/ skills/ tools/ agents/ knowledge_base/ data/ log/ venv/ config.yaml .env` 是**运维者的内容**, +判定在 `update.Protected`。合并前,只有本次更新**确实会写到**的那些路径被复制到 +`.update-backup/<时间戳>/` 并从工作树移开(你多出来的、上游没动的目录连碰都不碰),合并后再原样放回, +于是"更新代码、留下我的工作"成立。结果里的 `keptContent` 逐个点名被保留的文件——绝不静默丢弃: +当上游与你在同一个文件上都有改动,落地的是你的版本,而这件事写在结果里。 + +### 回滚 + +`./cyberstrike-ai -update-rollback` 或页面按钮:`git reset --hard` 回到那次更新前的提交, +并把换二进制时留下的 `cyberstrike-ai.prev` 放回原位,随后删除 `.update-state.json`。 +它会拒绝的情形:没有更新记录(`no_state`)、没有留下旧二进制(`no_binary`)、记录里的提交在本仓库不存在 +(`bad_state`)、**HEAD 自那次更新之后又动过**(`moved_since_update`:回滚只该撤到更新前,不该顺手抹掉之后的工作)、 +以及存在本地源码改动(`local_source_edits`)。 + +### 重启的两种情形 + +进程只有在请求显式带 `restart: true`、且启动时装配了重启钩子时才会优雅退出(`Shutdown` 后退出码 0)。 +能不能被拉起来取决于外部守护(systemd、`run.sh`),页面如实这么写,而不是承诺一次可能不发生的启动; +没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。未重启时接口只报告 `needsRestart`,旧二进制继续跑。 + ## 数据库 默认 SQLite。新增表或字段时: diff --git a/docs/zh-CN/rbac.md b/docs/zh-CN/rbac.md index 36ff4efe..fb52e411 100644 --- a/docs/zh-CN/rbac.md +++ b/docs/zh-CN/rbac.md @@ -81,6 +81,7 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角 | WebShell | `webshell:read`、`webshell:write`、`webshell:delete` | | C2 | `c2:read`、`c2:write`、`c2:delete` | | MCP | `mcp:read`、`mcp:execute`、`mcp:write`、`mcp:external:execute` | +| 一键更新(本机源码) | `update:read`、`update:apply` | | 知识库 | `knowledge:read`、`knowledge:write`、`knowledge:delete` | | Skills | `skills:read`、`skills:write`、`skills:delete` | | Markdown Agents | `agents:read`、`agents:write`、`agents:delete` | @@ -107,6 +108,8 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角 - `mcp:execute` 用于访问认证后的 MCP HTTP 入口。 - `mcp:external:execute` 用于 Agent 调用外部 MCP 工具,当前还要求该权限的 Scope 为 `all`。 - 管理外部 MCP 配置使用 `mcp:write`,与执行外部工具是两项权限。 +- 一键更新是**两项**:`update:read` 看本机安装的版本与远端差集;`update:apply` 拉自己的远端、重编译并换掉 + 平台二进制。写侧还要求会话是 `all` scope——一台机器一份源码,`assigned`/`own` 不该能移动别人正在跑的代码。 - `robot:write` 管理机器人配置和测试入口;机器人聊天本身使用绑定用户或服务账号的业务权限。 --- @@ -152,6 +155,7 @@ project:write → own - 知识库写操作(搜索除外)。 - HITL 全局白名单、默认审核方和审计策略。 - C2 Profile 写操作。 +- 一键更新的写操作(`check`/`apply`/`rollback`,即 `update:apply`)。 - 部分全局监控统计。 --- diff --git a/internal/app/app.go b/internal/app/app.go index 31b7bd0d..a0a8b4ea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -610,6 +610,15 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error return nil }) + // The one-click update acts on this installation's own tree, rooted where the live config + // file is. Its restart hook exits after a graceful shutdown, which is only "a restart" when + // something supervises the process - so the endpoint reports whether it is standing down or + // whether somebody has to start it again, instead of promising a boot that may not happen. + updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() { + app.Shutdown() + os.Exit(0) + }) + // 设置路由(使用 App 实例以便动态获取 handler) setupRoutes( router, @@ -642,6 +651,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error mcpServer, authManager, openAPIHandler, + updateHandler, ) return app, nil @@ -947,6 +957,7 @@ func setupRoutes( mcpServer *mcp.Server, authManager *security.AuthManager, openAPIHandler *handler.OpenAPIHandler, + updateHandler *handler.UpdateHandler, ) { // API路由 api := router.Group("/api") @@ -1123,6 +1134,13 @@ func setupRoutes( protected.POST("/config/test-vision", configHandler.TestVision) protected.POST("/config/list-models", configHandler.ListModels) + // 系统更新(保持本安装自身源码最新:查看、检查、应用、进度、回滚) + protected.GET("/system/update", updateHandler.GetStatus) + protected.GET("/system/update/job", updateHandler.Job) + protected.POST("/system/update/check", updateHandler.Check) + protected.POST("/system/update/apply", updateHandler.Apply) + protected.POST("/system/update/rollback", updateHandler.Rollback) + // 系统设置 - 终端(执行命令,提高运维效率) protected.POST("/terminal/run", terminalHandler.RunCommand) protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream) diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index db76a79d..ed13a735 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -6449,6 +6449,110 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/system/update": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "查看本安装的源码版本", + "description": "读取安装目录自己的 git 工作树(分支、远端、当前提交、本地改动、是否有 Go 工具链、是否有可回滚点),不发起任何网络请求", + "operationId": "getUpdateStatus", + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "安装状态与最近一次更新任务", + }, + "401": map[string]interface{}{ + "description": "未授权", + }, + }, + }, + }, + "/api/system/update/check": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "检查本安装自己的远端有无新提交", + "description": "对安装目录已经跟踪的远端分支执行 fetch,报告落后/领先提交数与新提交列表;取代码的来源永远是本目录自己的远端,不是任何写死的第三方仓库。检查失败返回 200 且带 checkError,不把「查不了」说成「已是最新」", + "operationId": "checkUpdate", + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "检查结果(含 checkError 时为检查未成功)", + }, + "401": map[string]interface{}{ + "description": "未授权", + }, + }, + }, + }, + "/api/system/update/apply": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "一键更新源码并重编译", + "description": "快进到远端分支、重新编译二进制并原子换入(旧二进制留作 .prev 以便回滚)。本机改过源码文件或分支已分叉时拒绝执行并点名;roles/skills/tools/agents/bundles/knowledge_base/data/config.yaml 等运维者内容在合并前暂存、合并后放回,被保留的文件列在 keptContent。请求立即返回 job_id,进度用 GET /api/system/update/job 轮询;已有任务在跑时返回 409", + "operationId": "applyUpdate", + "requestBody": map[string]interface{}{ + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "restart": map[string]interface{}{ + "type": "boolean", + "description": "更新成功后优雅关闭并退出进程;只在有外部守护时才会被拉起,默认 false", + }, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "202": map[string]interface{}{ + "description": "任务已受理,返回 job_id", + }, + "400": map[string]interface{}{ + "description": "请求体不合法,或要求重启但本次启动没有装配重启钩子", + }, + "409": map[string]interface{}{ + "description": "已有一次更新在进行中", + }, + "401": map[string]interface{}{ + "description": "未授权", + }, + }, + }, + }, + "/api/system/update/job": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "查看更新任务进度", + "description": "返回正在运行或最近一次更新任务的状态、逐行进度 steps、结果 result 与失败 failure;从未更新过时 job 为 null", + "operationId": "getUpdateJob", + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "任务状态", + }, + "401": map[string]interface{}{ + "description": "未授权", + }, + }, + }, + }, + "/api/system/update/rollback": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "回滚到本次更新之前", + "description": "撤到上一次更新记录里的旧提交,并换回换入前保留的旧二进制。自那次更新之后 HEAD 已经变化、或本机有源码改动时返回 409 拒绝,而不是丢弃更新之后的工作", + "operationId": "rollbackUpdate", + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "已回滚", + }, + "409": map[string]interface{}{ + "description": "没有可回滚的记录、更新进行中、或更新之后 HEAD 已被移动", + }, + "401": map[string]interface{}{ + "description": "未授权", + }, + }, + }, + }, }, } diff --git a/internal/handler/update.go b/internal/handler/update.go new file mode 100644 index 00000000..64a34682 --- /dev/null +++ b/internal/handler/update.go @@ -0,0 +1,327 @@ +package handler + +import ( + "context" + "cyberstrike-ai/internal/audit" + "cyberstrike-ai/internal/update" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// UpdateHandler is the "update my own source" surface: what this installation is, what +// its remote has that it does not, and one action that moves the tree and rebuilds the +// binary. +// +// It exists because the alternative was a shell script pointed at one fixed upstream +// repository, which for anyone running a fork meant "downgrade yourself to somebody +// else's code". Here the remote is whatever this directory already tracks, and the person +// at the keyboard never types git or go build. +type UpdateHandler struct { + root string // the installation to update: the directory the config file lives in + logger *zap.Logger + audit *audit.Service + // restart is the operator's own shutdown hook. It is only ever called when the + // request asked for a restart, because an unsupervised process that exits stays + // exited - "I restarted you" would be a lie on most deployments. + restart func() + + mu sync.Mutex + jobs map[string]*updateJob + order []string + active string +} + +// updateJob is one running or finished apply. A build takes minutes, so the request +// returns a handle and the page polls it rather than sitting in one HTTP call until the +// compiler finishes. +type updateJob struct { + ID string `json:"id"` + State string `json:"state"` // running | succeeded | failed + Started string `json:"started"` + Finished string `json:"finished,omitempty"` + Steps []update.Step `json:"steps"` + Result *update.Result `json:"result,omitempty"` + Failure *update.Error `json:"failure,omitempty"` + Restart bool `json:"restartRequested"` +} + +// view copies a job for transport, because its fields are written by the job's own +// goroutine: a response must never serialize the live struct. Callers hold h.mu. +func (j *updateJob) view() *updateJob { + out := *j + out.Steps = append([]update.Step{}, j.Steps...) + return &out +} + +// NewUpdateHandler takes the audit service as a constructor argument rather than through +// a SetAudit the assembly has to remember: forgetting here is a compile error instead of +// a privileged endpoint that writes no audit records at all. +func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func()) *UpdateHandler { + return &UpdateHandler{ + root: root, + logger: logger, + audit: auditSvc, + restart: restart, + jobs: map[string]*updateJob{}, + } +} + +func (h *UpdateHandler) options() update.Options { return update.Options{Root: h.root} } + +// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a +// network round trip, so opening the page is never gated on GitHub being reachable. +func (h *UpdateHandler) GetStatus(c *gin.Context) { + snap, err := update.Status(c.Request.Context(), h.options()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "status": snap, + "job": h.latest(), + "canRestart": h.restart != nil, + }) +} + +// Check answers POST /api/system/update/check: fetch the tracked branch and report the +// gap. A failed fetch is a 200 with checkError, not a 500 - "offline" and "bad token" +// are answers the operator needs to read, not server faults to debug. +func (h *UpdateHandler) Check(c *gin.Context) { + snap, err := update.Check(c.Request.Context(), h.options()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + h.record(c, "update.check", resultOf(snap.CheckError), fmt.Sprintf("检查更新:%s/%s", snap.Remote, snap.Branch), map[string]interface{}{ + "commit": snap.Commit, "remote_commit": snap.RemoteCommit, "behind": snap.Behind, "error": snap.CheckError, + }) + c.JSON(http.StatusOK, gin.H{"status": snap}) +} + +// Apply answers POST /api/system/update/apply. It starts one job and returns immediately; +// a second concurrent apply is refused, because two processes moving the same working +// tree and swapping the same binary is how an installation becomes unrecoverable. +func (h *UpdateHandler) Apply(c *gin.Context) { + var body struct { + Restart bool `json:"restart"` + } + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"}) + return + } + } + if body.Restart && h.restart == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"}) + return + } + + job, started := h.startJob(body.Restart) + if !started { + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + return + } + h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart}) + + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) +} + +// Job answers GET /api/system/update/job: the running job, or the most recent one, with +// every progress line so far. +func (h *UpdateHandler) Job(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"job": h.latest()}) +} + +// Rollback answers POST /api/system/update/rollback: back to the commit the last update +// came from, with the binary kept before the swap. It refuses when HEAD has moved since +// that update, rather than resetting away work nobody asked it to touch. +func (h *UpdateHandler) Rollback(c *gin.Context) { + if busy := h.activeJob(); busy != nil { + c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,不能同时回滚", "job": busy}) + return + } + res, err := update.Rollback(c.Request.Context(), h.options()) + if err != nil { + h.record(c, "update.rollback", "failure", "回滚失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)}) + c.JSON(statusForError(err), errorBody(err)) + return + } + h.record(c, "update.rollback", "success", fmt.Sprintf("已回滚到 %s", res.ToCommit), map[string]interface{}{ + "from": res.FromCommit, "to": res.ToCommit, + }) + c.JSON(http.StatusOK, gin.H{"result": res}) +} + +func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) { + h.mu.Lock() + if h.active != "" { + return h.jobs[h.active].view(), false + } + job := &updateJob{ + ID: fmt.Sprintf("upd-%d", time.Now().UnixNano()), + State: "running", + Started: time.Now().Format(time.RFC3339), + Restart: restart, + } + h.jobs[job.ID] = job + h.order = append(h.order, job.ID) + for len(h.order) > 20 { + oldest := h.order[0] + h.order = h.order[1:] + if oldest != h.active { + delete(h.jobs, oldest) + } + } + h.active = job.ID + snapshot := job.view() + h.mu.Unlock() + + // The update outlives its HTTP request on purpose: the caller gets a handle, and a + // browser that closes the tab must not cancel a merge halfway through. Once the + // working tree has moved there is no going back, so the context is generous with + // time instead of inheriting the request's deadline. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute) + defer cancel() + + onStep := func(s update.Step) { + h.mu.Lock() + job.Steps = append(job.Steps, s) + h.mu.Unlock() + if h.logger != nil { + h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message)) + } + } + res, err := update.Apply(ctx, h.options(), onStep) + finish := "succeeded" + if err != nil { + finish = "failed" + } + + h.mu.Lock() + job.Result = res + if ue, ok := asUpdateError(err); ok { + job.Failure = ue + } else if err != nil { + job.Failure = &update.Error{Reason: "error", Message: err.Error()} + } + job.State = finish + job.Finished = time.Now().Format(time.RFC3339) + if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.restart != nil { + h.active = "" + h.mu.Unlock() + // Give the polling page one more chance to read the final state before the + // process it is watching goes away. + time.AfterFunc(2*time.Second, h.restart) + return + } + h.active = "" + h.mu.Unlock() + }() + return snapshot, true +} + +func (h *UpdateHandler) activeJob() *updateJob { + h.mu.Lock() + defer h.mu.Unlock() + if h.active == "" { + return nil + } + if job := h.jobs[h.active]; job != nil { + return job.view() + } + return nil +} + +func (h *UpdateHandler) latest() *updateJob { + h.mu.Lock() + defer h.mu.Unlock() + if len(h.order) == 0 { + return nil + } + if job := h.jobs[h.order[len(h.order)-1]]; job != nil { + return job.view() + } + return nil +} + +func (h *UpdateHandler) record(c *gin.Context, action, result, message string, detail map[string]interface{}) { + if h.audit == nil { + return + } + h.audit.Record(c, audit.Entry{ + Level: auditLevelFor(result), + Category: "update", + Action: action, + Result: result, + Message: message, + ResourceType: "system", + ResourceID: h.root, + Detail: detail, + }) +} + +func auditLevelFor(result string) string { + if result == "failure" { + return "error" + } + return "info" +} + +func resultOf(checkError string) string { + if checkError != "" { + return "failure" + } + return "success" +} + +func asUpdateError(err error) (*update.Error, bool) { + if err == nil { + return nil, false + } + if ue, ok := err.(*update.Error); ok { + return ue, true + } + return nil, false +} + +func errMessage(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// statusForError keeps a refusal distinguishable from an accident: the precondition +// failures are the ones the page can act on (409), while anything else is a bad request it +// can show. No case maps a refusal onto a 2xx - a status that reads as accepted would +// contradict the failure the same response carries, and an asynchronous update never asks +// this function (the job's own state says whether it worked). +func statusForError(err error) int { + ue, ok := asUpdateError(err) + if !ok { + return http.StatusInternalServerError + } + switch ue.Reason { + case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary": + return http.StatusConflict + default: + return http.StatusBadRequest + } +} + +func errorBody(err error) gin.H { + body := gin.H{"error": errMessage(err)} + if ue, ok := asUpdateError(err); ok { + body["reason"] = ue.Reason + if len(ue.Items) > 0 { + body["items"] = ue.Items + } + } + return body +} diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go new file mode 100644 index 00000000..ac8036af --- /dev/null +++ b/internal/handler/update_http_test.go @@ -0,0 +1,168 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// The HTTP contract of "update my own source in one click": status without a network +// round trip, an apply that returns a job handle instead of blocking a request open for +// the length of a compile, and refusals that stay distinguishable from accidents. + +func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) { + gin.SetMode(gin.TestMode) + h := NewUpdateHandler(root, nil, nil, restart) + router := gin.New() + router.GET("/api/system/update", h.GetStatus) + router.GET("/api/system/update/job", h.Job) + router.POST("/api/system/update/check", h.Check) + router.POST("/api/system/update/apply", h.Apply) + router.POST("/api/system/update/rollback", h.Rollback) + return router, h +} + +func doUpdate(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func TestUpdateStatusIsServedWithoutTouchingTheNetwork(t *testing.T) { + router, _ := newUpdateRouter(t.TempDir(), nil) + + w := doUpdate(router, http.MethodGet, "/api/system/update", "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body) + } + var body struct { + Status struct { + Root string `json:"root"` + Installed bool `json:"installed"` + CheckError string `json:"checkError"` + CanBuild bool `json:"canBuild"` + } `json:"status"` + Job *struct{ ID string } `json:"job"` + CanRestart bool `json:"canRestart"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Status.Root == "" || body.Status.Installed { + t.Errorf("a plain directory must report its root and that it is not a git tree: %+v", body.Status) + } + if body.Status.CheckError == "" { + t.Error("the reason an update cannot run must be readable on the page, not just a status code") + } + if body.Job != nil { + t.Error("job must be null before anything has been applied") + } + if body.CanRestart { + t.Error("canRestart must be false when no restart hook was wired") + } +} + +func TestUpdateApplyReturnsAJobAndReportsAnHonestFailure(t *testing.T) { + router, _ := newUpdateRouter(t.TempDir(), nil) + + w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":false}`) + if w.Code != http.StatusAccepted { + t.Fatalf("apply should be accepted and return a handle, got %d %s", w.Code, w.Body) + } + var started struct { + JobID string `json:"job_id"` + State string `json:"state"` + } + if err := json.Unmarshal(w.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.JobID == "" { + t.Fatal("apply must return the job id the page polls") + } + + // The job runs against a non-git directory, so it finishes with a refusal rather + // than a merge. What matters is that the reported failure is the real reason. + deadline := time.Now().Add(10 * time.Second) + var finished struct { + Job struct { + State string `json:"state"` + Failure struct { + Reason string `json:"reason"` + } `json:"failure"` + } `json:"job"` + } + for time.Now().Before(deadline) { + w = doUpdate(router, http.MethodGet, "/api/system/update/job", "") + if err := json.Unmarshal(w.Body.Bytes(), &finished); err != nil { + t.Fatal(err) + } + if finished.Job.State != "running" { + break + } + time.Sleep(20 * time.Millisecond) + } + if finished.Job.State != "failed" { + t.Fatalf("job state = %q, want failed for a non-git install", finished.Job.State) + } + if finished.Job.Failure.Reason != "not_a_repo" { + t.Errorf("failure reason = %q, want not_a_repo", finished.Job.Failure.Reason) + } +} + +func TestUpdateApplyRefusesRestartWhenNothingCanStartTheProcessAgain(t *testing.T) { + router, _ := newUpdateRouter(t.TempDir(), nil) + + w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":true}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: restarting without a supervisor would just stop the platform", w.Code) + } + if !strings.Contains(w.Body.String(), "重启") { + t.Errorf("the refusal must say why: %s", w.Body) + } +} + +func TestUpdateApplyRejectsMalformedBody(t *testing.T) { + router, _ := newUpdateRouter(t.TempDir(), nil) + + w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart": "yes please"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400 for a body that is not valid JSON", w.Code, w.Body) + } + // A rejected request must not have left a job behind: the page would then poll a + // phantom update. + if job := doUpdate(router, http.MethodGet, "/api/system/update/job", ""); !strings.Contains(job.Body.String(), `"job":null`) { + t.Errorf("a 400 apply must not create a job, got %s", job.Body) + } +} + +func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) { + root := t.TempDir() + router, _ := newUpdateRouter(root, nil) + + w := doUpdate(router, http.MethodPost, "/api/system/update/rollback", "") + if w.Code != http.StatusConflict { + t.Fatalf("status = %d body = %s, want 409", w.Code, w.Body) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["reason"] != "no_state" { + t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"]) + } +} diff --git a/internal/security/rbac.go b/internal/security/rbac.go index 9c8fa8d7..d7d29372 100644 --- a/internal/security/rbac.go +++ b/internal/security/rbac.go @@ -80,6 +80,8 @@ var PermissionCatalog = map[string]string{ "attackchain:write": "Regenerate attack chains", "fofa:execute": "Run FOFA searches and query parsing", "openapi:read": "Read OpenAPI aggregation results", + "update:read": "View this installation's source version and whether its own repository has anything newer", + "update:apply": "Pull this installation's own repository, rebuild the platform binary, and optionally stand the process down", "monitor:read": "View execution monitor", "monitor:write": "Cancel monitor executions", "monitor:delete": "Delete monitor executions", diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index a72dfc82..7adf5d9c 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -102,6 +102,13 @@ func permissionForRequest(method, fullPath string) string { return "rbac:read" } return "rbac:write" + case strings.HasPrefix(path, "/system/update"): + // Reading the version of the installation is a read. Applying an update replaces the + // program that is answering the request, so it is not a flavour of write. + if method == http.MethodGet || method == http.MethodHead { + return "update:read" + } + return "update:apply" case strings.HasPrefix(path, "/robot/wechat/status"): return "robot:read" case strings.HasPrefix(path, "/robot"): @@ -269,6 +276,11 @@ func isMutationMethod(method string) bool { } func isProcessGlobalMutationPath(path string) bool { + if strings.HasPrefix(path, "/system/update") { + // One installation, one source tree: an assigned/own-scoped session must not be able to + // move the code every other user is running. + return true + } if strings.HasPrefix(path, "/roles") || strings.HasPrefix(path, "/skills") || strings.HasPrefix(path, "/external-mcp") || strings.HasPrefix(path, "/robot") { return true diff --git a/internal/security/rbac_middleware_test.go b/internal/security/rbac_middleware_test.go index ab478400..4d74b9ad 100644 --- a/internal/security/rbac_middleware_test.go +++ b/internal/security/rbac_middleware_test.go @@ -273,3 +273,30 @@ func TestAssignedScopeCannotMutateProcessGlobalAssets(t *testing.T) { }) } } + +// TestUpdateApplyIsItsOwnPermissionFromReadingTheVersion: replacing the program that is +// answering the request is not a flavour of reading it, and it is process-global - one +// tree, one binary, every user of this installation. +func TestUpdateApplyIsItsOwnPermissionFromReadingTheVersion(t *testing.T) { + cases := []struct { + method, path, apiPath, want string + }{ + {http.MethodGet, "/api/system/update", "/system/update", "update:read"}, + {http.MethodGet, "/api/system/update/job", "/system/update/job", "update:read"}, + {http.MethodPost, "/api/system/update/check", "/system/update/check", "update:apply"}, + {http.MethodPost, "/api/system/update/apply", "/system/update/apply", "update:apply"}, + {http.MethodPost, "/api/system/update/rollback", "/system/update/rollback", "update:apply"}, + } + for _, tc := range cases { + got := permissionForRequest(tc.method, tc.path) + if got != tc.want { + t.Errorf("%s %s -> %q, want %q", tc.method, tc.path, got, tc.want) + } + if _, ok := PermissionCatalog[got]; !ok { + t.Errorf("%s %s maps to %q which is not in PermissionCatalog", tc.method, tc.path, got) + } + if tc.method != http.MethodGet && !isProcessGlobalMutationPath(tc.apiPath) { + t.Errorf("%s must require a global-scope session: it changes what every user runs", tc.path) + } + } +} diff --git a/internal/update/apply.go b/internal/update/apply.go new file mode 100644 index 00000000..ad794659 --- /dev/null +++ b/internal/update/apply.go @@ -0,0 +1,385 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// stateFile records what the last update moved, so a rollback has a target that is not +// guessed at. It lives in the install tree and is deliberately not in git: it describes +// this machine's installation. +const stateFile = ".update-state.json" + +// State is the rollback contract of one installation. +type State struct { + PreviousCommit string `json:"previous_commit"` + UpdatedCommit string `json:"updated_commit"` + BackupDir string `json:"backup_dir"` + UpdatedAt string `json:"updated_at"` +} + +func statePath(root string) string { return filepath.Join(root, stateFile) } + +func readState(root string) (State, bool) { + var st State + data, err := os.ReadFile(statePath(root)) + if err != nil { + return st, false + } + if err := json.Unmarshal(data, &st); err != nil || st.PreviousCommit == "" || st.UpdatedCommit == "" { + return st, false + } + return st, true +} + +func writeState(root string, st State) error { + st.UpdatedAt = time.Now().Format(time.RFC3339) + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + tmp := statePath(root) + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, statePath(root)) +} + +// Step is one progress line. A build takes minutes, so Apply reports where it is rather +// than appearing to hang; the console renders these directly. +type Step struct { + Phase string `json:"phase"` + Message string `json:"message"` + At string `json:"at"` +} + +// Result is what one update attempt produced. Everything the operator would otherwise +// have to go and inspect by hand is in here. +type Result struct { + FromCommit string `json:"fromCommit"` + ToCommit string `json:"toCommit"` + Commits int `json:"commits"` + FilesTouched int `json:"filesTouched"` + // KeptContent names the operator-owned files that were restored over the update. + // Upstream may well have changed them; saying which ones is the difference between + // "my roles disappeared" and "my roles are still mine, and these upstream files did + // not land". + KeptContent []string `json:"keptContent"` + BinaryPath string `json:"binaryPath"` + BinaryBuilt bool `json:"binaryBuilt"` + PrevBinary string `json:"prevBinary"` + BackupDir string `json:"backupDir"` + NeedsRestart bool `json:"needsRestart"` + Duration string `json:"duration"` +} + +// Error is a refusal with the information needed to act on it. Callers render the +// Message; Details is for the page to list file names or commits. +type Error struct { + Reason string `json:"reason"` + Message string `json:"message"` + Items []string `json:"items,omitempty"` +} + +func (e *Error) Error() string { return e.Message } + +// Apply carries an installation to the tip of the branch it tracks, then rebuilds and +// swaps the binary. It is all-or-nothing up to the point of the working-tree move: a +// failed build leaves the tree updated (source and previous binary are both recoverable), +// while a refused precondition leaves nothing touched at all. +func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) { + if onStep == nil { + onStep = func(Step) {} + } + step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) } + + snap, err := Check(ctx, opts) + if err != nil { + return nil, err + } + start := time.Now() + + // A refusal is not the end of the timeline: the page renders these lines, so the first + // one says what was looked at even when the answer is "nothing to do". + step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.Remote, snap.Commit)) + + if !snap.Installed { + return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"} + } + if snap.CheckError != "" { + return nil, &Error{Reason: "check_failed", Message: "检查更新失败:" + snap.CheckError} + } + if len(snap.BlockingChanges) > 0 { + items := make([]string, 0, len(snap.BlockingChanges)) + for _, c := range snap.BlockingChanges { + items = append(items, fmt.Sprintf("%s (%s)", c.Path, c.Status)) + } + return nil, &Error{ + Reason: "local_source_edits", + Message: fmt.Sprintf("有 %d 个源码文件被本地改过,自动更新不会覆盖它们。先提交或还原这些文件再更新。", len(items)), + Items: items, + } + } + if snap.Diverged { + return nil, &Error{ + Reason: "diverged", + Message: fmt.Sprintf("本地领先 %d 个提交、落后 %d 个提交:这是合并而不是下载,请手工处理后重试。", snap.Ahead, snap.Behind), + } + } + if !snap.UpdateAvailable { + return &Result{ + FromCommit: snap.Commit, ToCommit: snap.Commit, + BinaryPath: filepath.Join(snap.Root, opts.binaryName()), + Duration: time.Since(start).Round(time.Millisecond).String(), + }, nil + } + + step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.Remote, snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit)) + + // Put operator-owned content aside, and clear the paths the incoming change would + // write to, so the fast-forward cannot be refused by "your local changes would be + // overwritten". Nothing here deletes a file the operator made: every path is copied + // out first and copied back after. + root := snap.Root + ref := snap.Remote + "/" + snap.Branch + backupDir, kept, err := stashProtected(ctx, root, ref) + if err != nil { + return nil, err + } + step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept))) + + if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil { + restoreProtected(backupDir) + return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))} + } + // The merge wrote upstream's version of the content paths; the operator's copies win + // them back, which is what "update the code, keep my work" means. + if err := restoreProtected(backupDir); err != nil { + return nil, err + } + + files, _ := gitRaw(ctx, root, "diff", "--name-only", "-z", snap.Commit+".."+snap.RemoteCommit) + res := &Result{ + FromCommit: snap.Commit, + ToCommit: snap.RemoteCommit, + Commits: snap.Behind, + KeptContent: kept, + BackupDir: backupDir, + FilesTouched: len(nulList(files)), + } + + if err := writeState(root, State{PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir}); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()} + } + + bin := filepath.Join(root, opts.binaryName()) + res.BinaryPath = bin + if opts.binaryName() == "none" { + step("build", "按请求跳过重新编译;源码已更新,重启后由外部构建流程产出二进制") + res.NeedsRestart = true + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, nil + } + if !snap.CanBuild { + res.NeedsRestart = true + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, &Error{ + Reason: "no_toolchain", + Message: "源码已更新,但本机没有 Go 工具链,无法自动编译。装好 go 后再点一次更新即可补上二进制。", + } + } + + step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)") + staging := filepath.Join(root, ".update-staging") + if err := os.MkdirAll(staging, 0o755); err != nil { + return res, &Error{Reason: "staging_failed", Message: err.Error()} + } + defer os.RemoveAll(staging) + + newBin := filepath.Join(staging, opts.binaryName()) + if err := build(ctx, root, newBin); err != nil { + return res, &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()} + } + prev, err := installBinary(newBin, bin) + if err != nil { + return res, &Error{Reason: "swap_failed", Message: err.Error()} + } + res.BinaryBuilt = true + res.PrevBinary = prev + res.NeedsRestart = true + step("done", fmt.Sprintf("已更新 %d 个提交并换好二进制:%s → %s", res.Commits, res.FromCommit, res.ToCommit)) + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, nil +} + +// stashProtected copies every operator-owned file that stands in the way of the +// fast-forward into backupDir and removes it from the tree. Only paths the update +// actually touches are removed - a user's extra skill directory is never disturbed. +func stashProtected(ctx context.Context, root, ref string) (backupDir string, kept []string, err error) { + ts := time.Now().Format("20060102_150405") + backupDir = filepath.Join(root, ".update-backup", ts) + + tracked, _ := localChanges(ctx, root) + incoming, err := gitRaw(ctx, root, "diff", "--name-only", "-z", "HEAD.."+ref) + if err != nil { + return "", nil, &Error{Reason: "diff_failed", Message: err.Error()} + } + willWrite := map[string]bool{} + for _, f := range nulList(incoming) { + willWrite[filepath.ToSlash(f)] = true + } + + // A locally modified content file would refuse the merge; an untracked content file + // whose path upstream now adds would also refuse it. Both are put aside. + targets := map[string]bool{} + for _, c := range tracked { + if c.Protected && willWrite[c.Path] { + targets[c.Path] = true + } + } + for _, p := range untrackedUnder(ctx, root, willWrite) { + targets[p] = true + } + + written := false + for p := range targets { + abs := filepath.Join(root, p) + info, statErr := os.Stat(abs) + if statErr != nil || info.IsDir() { + continue + } + if err := copyFile(abs, filepath.Join(backupDir, p), info.Mode()); err != nil { + return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} + } + kept = append(kept, p) + if err := os.Remove(abs); err != nil { + return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("移开 %s 失败:%v", p, err)} + } + written = true + } + if written || len(kept) > 0 { + sort.Strings(kept) + return backupDir, kept, nil + } + // Nothing to put aside: don't leave an empty backup directory behind. + return "", nil, nil +} + +// restoreProtected puts the operator's copies back over whatever the merge wrote. +func restoreProtected(backupDir string) error { + if backupDir == "" { + return nil + } + return filepath.WalkDir(backupDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(backupDir, path) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + return copyFile(path, filepath.Join(filepath.Dir(backupDir), "..", rel), info.Mode()) + }) +} + +// untrackedUnder lists untracked files under the given repository paths - the operator's +// own additions, which must be backed up but never deleted by an update. +func untrackedUnder(ctx context.Context, root string, willWrite map[string]bool) []string { + out, err := gitRaw(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames") + if err != nil { + return nil + } + var list []string + for _, entry := range nulList(out) { + if len(entry) < 4 || entry[:2] != "??" { + continue + } + p := filepath.ToSlash(entry[3:]) + if willWrite[p] && Protected(p) { + list = append(list, p) + } + } + return list +} + +// Rollback returns an installation to the commit it came from, using the binary kept +// before the swap. It refuses unless HEAD is still exactly what the update wrote, so it +// can never discard work done after that update. +func Rollback(ctx context.Context, opts Options) (*Result, error) { + root, err := opts.installRoot() + if err != nil { + return nil, err + } + st, ok := readState(root) + if !ok { + return nil, &Error{Reason: "no_state", Message: "没有可回滚的更新记录"} + } + bin := filepath.Join(root, opts.binaryName()) + if !fileExists(bin + ".prev") { + return nil, &Error{Reason: "no_binary", Message: "上一次更新没有留下旧二进制,无法回滚二进制(源码仍可用 git 处理)"} + } + head, err := gitCmd(ctx, root, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + updated, err := gitCmd(ctx, root, "rev-parse", st.UpdatedCommit) + if err != nil { + return nil, &Error{Reason: "bad_state", Message: "更新记录里的提交在本仓库不存在:" + st.UpdatedCommit} + } + if head != updated { + return nil, &Error{ + Reason: "moved_since_update", + Message: fmt.Sprintf("自那次更新之后 HEAD 已经变了(现在是 %s),回滚只会撤到更新前的提交,因此拒绝执行。", shortHash(head)), + } + } + + start := time.Now() + if _, blocking := localChanges(ctx, root); len(blocking) > 0 { + items := make([]string, 0, len(blocking)) + for _, c := range blocking { + items = append(items, c.Path) + } + return nil, &Error{Reason: "local_source_edits", Message: "有本地源码改动,回滚会覆盖它们", Items: items} + } + + if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil { + return nil, &Error{Reason: "reset_failed", Message: err.Error()} + } + if err := restorePrevBinary(bin); err != nil { + return nil, &Error{Reason: "swap_failed", Message: err.Error()} + } + if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) { + return nil, err + } + return &Result{ + FromCommit: shortHash(head), + ToCommit: shortHash(st.PreviousCommit), + BinaryPath: bin, + BinaryBuilt: true, + BackupDir: st.BackupDir, + NeedsRestart: true, + Duration: time.Since(start).Round(time.Millisecond).String(), + }, nil +} + +func shortHash(h string) string { + if len(h) > 8 { + return h[:8] + } + return h +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/update/build.go b/internal/update/build.go new file mode 100644 index 00000000..e9c887a3 --- /dev/null +++ b/internal/update/build.go @@ -0,0 +1,147 @@ +package update + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// toolchain reports whether a Go compiler is reachable from the install tree. An update +// that cannot compile must still update the source and say so plainly, because "no Go on +// this box" is an environment fact the operator can fix, not a reason to refuse the pull. +func toolchain(ctx context.Context, root string) (string, bool) { + path, err := exec.LookPath("go") + if err != nil { + return "", false + } + out, err := exec.CommandContext(ctx, path, "env", "GOVERSION").Output() + if err != nil { + return path, true + } + v := strings.TrimSpace(string(out)) + if v == "" { + return path, true + } + return v, true +} + +// build compiles the server binary into outPath, never into the tree's own executable +// name: a build that fails halfway must not leave a truncated binary where the running +// one lives. Output is returned on failure because the operator needs the compiler's +// message, not "build failed". +func build(ctx context.Context, root, outPath string) error { + cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, "./cmd/server") + cmd.Dir = root + // The module cache goes next to the install rather than into whoever's HOME started + // the service, so a first build under a service account cannot fail on a HOME that is + // not writable. + if home := os.Getenv("HOME"); home == "" || home == "/" { + cmd.Env = append(os.Environ(), "HOME="+root) + } + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("%s", tail(msg, 4000)) + } + if _, err := os.Stat(outPath); err != nil { + return fmt.Errorf("go build reported success but produced no binary at %s", outPath) + } + return nil +} + +// installBinary puts src in place of dst and keeps the previous dst as dst+".prev", which +// is what makes a rollback possible without a second download. +// +// The rename is the whole trick: a running executable's file cannot be written to on +// Linux (ETXTBSY) but renaming it aside is always allowed, so the old process keeps its +// inode and the new path is complete before anyone can observe it. +func installBinary(src, dst string) (string, error) { + prev := "" + if fileExists(dst) { + prev = dst + ".prev" + if err := os.Remove(prev); err != nil && !os.IsNotExist(err) { + return "", err + } + if err := os.Rename(dst, prev); err != nil { + return "", fmt.Errorf("保留旧二进制失败:%w", err) + } + } + if err := os.Chmod(src, 0o755); err != nil { + return prev, err + } + if err := os.Rename(src, dst); err != nil { + // Put the kept-previous binary back rather than leaving the install with no + // executable because one rename failed. + if prev != "" && !fileExists(dst) { + _ = os.Rename(prev, dst) + } + return "", fmt.Errorf("换入新二进制失败:%w", err) + } + return prev, nil +} + +// restorePrevBinary rolls back one swap. It is deliberately not installBinary in +// reverse: renaming dst onto src first would overwrite the very .prev file being +// restored, so the current binary is discarded instead. +func restorePrevBinary(bin string) error { + prev := bin + ".prev" + if !fileExists(prev) { + return fmt.Errorf("没有旧二进制:%s", prev) + } + if fileExists(bin) { + if err := os.Remove(bin); err != nil { + return err + } + } + if err := os.Chmod(prev, 0o755); err != nil { + return err + } + return os.Rename(prev, bin) +} + +// tail keeps the last bytes of a compiler's output: the end of a build log is where the +// error is, while the beginning is where the module downloads are. +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…(前文省略)\n" + s[len(s)-n:] +} + +// copyFile is used for the operator-owned content that an update puts aside and takes +// back. It creates the parent directories because the backup tree mirrors repository +// paths, and it preserves the mode because a tool recipe or a script that lost its exec +// bit stops working in a way nothing reports. +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, dst) +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..f90ce0c3 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,398 @@ +// Package update makes "bring this installation up to the newest commit of the +// repository it was cloned from" one action: fetch, move the working tree, rebuild the +// binary, swap it in. +// +// Why this exists rather than more shell: the shipped upgrade.sh downloads a Release +// tarball of one fixed upstream repository. For anyone running a fork - which is how +// this project is actually deployed here - "upgrade" therefore means "overwrite yourself +// with somebody else's code", and the honest alternative is to pull by hand and remember +// the build command. The platform has to answer "is there anything new" and "take it" +// against the remote *this directory already tracks*, with nobody typing git or go. +// +// Two properties hold the design down: +// +// - Nothing destructive happens by default. Local source edits, or a branch that has +// diverged from its remote, stop with the file names and commit counts in the error +// instead of being reset to match. +// - Content the operator owns is not ours to overwrite. roles/, skills/, tools/, +// agents/, bundles/, knowledge_base/, data/, config.yaml and friends are put aside +// and restored on top of the merge, so an update moves code while the roles and +// skills created from the console last week survive it. The result names what was +// kept rather than quietly dropping it on the floor. +package update + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Binary is the default executable name an update replaces. +const Binary = "cyberstrike-ai" + +// maxIncoming caps how many commits a status page lists; the count is reported +// separately so "37 new commits, showing 50" is impossible. +const maxIncoming = 50 + +// namePattern guards remote and branch names before they reach git's argv. Git treats a +// leading dash as an option, and an argument like this one comes from the working tree +// rather than from a request - but the same validator is used for both, so there is one +// rule instead of one that is stricter in the case nobody attacks. +var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/@-]*$`) + +// protectedDirs are install-tree directories whose contents the operator may have +// changed through the console or by hand. An update never overwrites them. +var protectedDirs = []string{ + "roles/", "skills/", "tools/", "agents/", "bundles/", "knowledge_base/", + "data/", "log/", "logs/", "tmp/", "venv/", ".upgrade-backup/", +} + +// protectedFiles are single files with the same rule - most importantly the live +// configuration, which is never in the repository in the first place. +var protectedFiles = []string{"config.yaml", "config.yml", ".env"} + +// Protected reports whether a repository-relative path belongs to the operator rather +// than to the product. +func Protected(rel string) bool { + rel = filepath.ToSlash(rel) + for _, f := range protectedFiles { + if rel == f { + return true + } + } + for _, d := range protectedDirs { + if strings.HasPrefix(rel, d) { + return true + } + } + return false +} + +// Options describes which installation to act on. Root is the only required field; +// Remote and Branch exist for a tree that tracks something other than its own upstream. +type Options struct { + Root string + Remote string + Branch string + // BinaryName overrides the executable swapped after a successful update. Empty + // means "cyberstrike-ai" in Root; "none" means leave the binary alone. + BinaryName string +} + +func (o Options) binaryName() string { + if o.BinaryName == "" { + return Binary + } + return o.BinaryName +} + +// installRoot resolves the configured directory to an absolute path. A relative config path +// arrives as "." from filepath.Dir, and while git answers it correctly the page would show +// an installation rooted at "." - and an operator reading the refusal cannot tell which +// directory was actually inspected. +func (o Options) installRoot() (string, error) { + root := strings.TrimSpace(o.Root) + if root == "" { + return "", fmt.Errorf("update: install root is required") + } + if abs, err := filepath.Abs(root); err == nil { + return abs, nil + } + return root, nil +} + +// Change is one locally modified or deleted tracked file. +type Change struct { + Path string `json:"path"` + Status string `json:"status"` + Protected bool `json:"protected"` +} + +// Commit is one line of the incoming history. +type Commit struct { + Commit string `json:"commit"` + Subject string `json:"subject"` +} + +// Snapshot is everything a person needs to decide whether to press the button. +type Snapshot struct { + Root string `json:"root"` + Installed bool `json:"installed"` + Branch string `json:"branch"` + Remote string `json:"remote"` + Remotes []string + Commit string `json:"commit"` + Subject string `json:"subject"` + CommittedAt string `json:"committedAt"` + + Behind int `json:"behind"` + Ahead int `json:"ahead"` + // Diverged means this tree has commits the remote does not. Updating then is a + // merge decision, not a download, so Apply refuses it. + Diverged bool `json:"diverged"` + + UpdateAvailable bool `json:"updateAvailable"` + RemoteCommit string `json:"remoteCommit"` + RemoteSubject string `json:"remoteSubject"` + Incoming []Commit `json:"incoming"` + IncomingTotal int `json:"incomingTotal"` + + LocalChanges []Change `json:"localChanges"` + // BlockingChanges are local edits to product source. Content edits are not + // blocking: they are preserved across the update. + BlockingChanges []Change `json:"blockingChanges"` + + GoToolchain string `json:"goToolchain"` + CanBuild bool `json:"canBuild"` + + HasBinary bool `json:"hasBinary"` + HasRollback bool `json:"hasRollback"` + // RollbackTo is the commit the previous update came from, when there is one. + RollbackTo string `json:"rollbackTo"` + + // CheckError carries a fetch or parse failure to the page instead of turning the + // whole endpoint into a 500: "offline" and "your token expired" are answers, not + // server faults. + CheckError string `json:"checkError,omitempty"` +} + +// git runs one git command in the install tree. Everything goes through argv, never +// through a shell, so a repository name with a metacharacter in it is inert. +func gitCmd(ctx context.Context, root string, args ...string) (string, error) { + out, err := gitRaw(ctx, root, args...) + return strings.TrimSpace(out), err +} + +// gitRaw is the untrimmed form, and the difference is load-bearing: in a `--porcelain -z` +// record the first two bytes are the X and Y columns, and an unstaged modification is +// " M" - trimming the whole blob eats that leading space and shifts every path one byte +// to the left, so "internal/x.go" arrives as "nternal/x.go". Callers that parse the +// machine formats must use this, not gitCmd. +func gitRaw(ctx context.Context, root string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return string(out), fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr))) + } + return string(out), fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return string(out), nil +} + +// nulList splits a `-z` machine-readable git output into its records. +func nulList(blob string) []string { + var out []string + for _, rec := range strings.Split(blob, "\x00") { + if rec != "" { + out = append(out, rec) + } + } + return out +} + +// ValidName reports whether s can safely reach git's argv as a remote or branch name. +func ValidName(s string) bool { return namePattern.MatchString(s) } + +// Status reads the install tree without touching the network. +func Status(ctx context.Context, opts Options) (*Snapshot, error) { + root, err := opts.installRoot() + if err != nil { + return nil, err + } + snap := &Snapshot{ + Root: root, + Incoming: []Commit{}, + LocalChanges: []Change{}, + BlockingChanges: []Change{}, + } + + if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err != nil { + // Not a git tree: the page still has to say something useful, so this is a + // state rather than an error. + snap.Installed = false + snap.CheckError = "这个目录不是 git 工作树,无法自动更新;请用源码包方式更新" + snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) + snap.HasBinary = fileExists(filepath.Join(root, opts.binaryName())) + return snap, nil + } + snap.Installed = true + + for _, q := range []struct { + args []string + dest *string + }{ + {[]string{"rev-parse", "--abbrev-ref", "HEAD"}, &snap.Branch}, + {[]string{"rev-parse", "--short", "HEAD"}, &snap.Commit}, + {[]string{"log", "-1", "--format=%s"}, &snap.Subject}, + {[]string{"log", "-1", "--format=%ci"}, &snap.CommittedAt}, + } { + if v, err := gitCmd(ctx, root, q.args...); err == nil { + *q.dest = v + } + } + if opts.Branch != "" { + snap.Branch = opts.Branch + } + + remotes, err := gitCmd(ctx, root, "remote") + if err == nil { + for _, r := range strings.Fields(remotes) { + if ValidName(r) { + snap.Remotes = append(snap.Remotes, r) + } + } + } + snap.Remote = pickRemote(snap.Remotes, snap.Branch, opts.Remote) + + if upstream, err := gitCmd(ctx, root, "rev-parse", "--abbrev-ref", "@{upstream}"); err == nil && ValidName(upstream) { + if i := strings.Index(upstream, "/"); i > 0 { + if snap.Remote == "" { + snap.Remote = upstream[:i] + } + if opts.Branch == "" && upstream[i+1:] != "HEAD" { + snap.Branch = upstream[i+1:] + } + } + } + + snap.LocalChanges, snap.BlockingChanges = localChanges(ctx, root) + snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) + bin := filepath.Join(root, opts.binaryName()) + snap.HasBinary = fileExists(bin) + if st, ok := readState(root); ok { + snap.HasRollback = fileExists(bin+".prev") && st.UpdatedCommit != "" + snap.RollbackTo = st.PreviousCommit + } + return snap, nil +} + +// pickRemote chooses which remote to pull from: an explicit option, then the usual +// suspects in the order that matches how this project is actually cloned (a fork named +// for its owner tracking the owner's work, with the upstream kept for reference). +func pickRemote(remotes []string, branch, optsRemote string) string { + if optsRemote != "" && ValidName(optsRemote) { + return optsRemote + } + ranked := []string{"mine", "origin", "upstream"} + known := map[string]bool{} + for _, r := range remotes { + known[r] = true + } + for _, r := range ranked { + if known[r] { + return r + } + } + if len(remotes) > 0 { + return remotes[0] + } + _ = branch + return "" +} + +// localChanges lists modified and deleted tracked files, and splits them by who owns +// the path. +func localChanges(ctx context.Context, root string) (all, blocking []Change) { + out, err := gitRaw(ctx, root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=no") + if err != nil { + return all, blocking + } + for _, entry := range nulList(out) { + if len(entry) < 4 { + continue + } + status, path := entry[:2], filepath.ToSlash(entry[3:]) + if status == "??" || strings.HasPrefix(status, "!") { + continue + } + c := Change{Path: path, Status: changeVerb(status), Protected: Protected(path)} + all = append(all, c) + if !c.Protected { + blocking = append(blocking, c) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path }) + sort.Slice(blocking, func(i, j int) bool { return blocking[i].Path < blocking[j].Path }) + return all, blocking +} + +func changeVerb(status string) string { + switch { + case strings.Contains(status, "D"): + return "deleted" + case strings.Contains(status, "A"): + return "added" + default: + return "modified" + } +} + +// Check fetches the chosen remote and reports what would come in. The fetch is the only +// network operation in this package, and a failure here is a readable answer rather than +// a fault: the page has to distinguish "up to date" from "could not look". +func Check(ctx context.Context, opts Options) (*Snapshot, error) { + snap, err := Status(ctx, opts) + if err != nil { + return nil, err + } + if !snap.Installed { + return snap, nil + } + root := snap.Root + if snap.Remote == "" { + snap.CheckError = "该安装没有配置任何 git remote,无法检查更新" + return snap, nil + } + if snap.Branch == "" || snap.Branch == "HEAD" || !ValidName(snap.Branch) { + snap.CheckError = "当前不在可自动更新的分支上(游离 HEAD 或分支名不合规范),无法确定要跟踪哪个分支" + return snap, nil + } + // --recurse-submodules=no and --prune keep the fetch to this repository's own + // history. A detached fetch of one branch means a remote with many refs cannot + // turn the update check into a full clone. + if _, err := gitCmd(ctx, root, "fetch", "--quiet", "--no-tags", "--prune", "--recurse-submodules=no", + snap.Remote, "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.Remote+"/"+snap.Branch); err != nil { + snap.CheckError = err.Error() + return snap, nil + } + + ref := snap.Remote + "/" + snap.Branch + if v, err := gitCmd(ctx, root, "rev-parse", "--short", ref); err == nil { + snap.RemoteCommit = v + } else { + snap.CheckError = err.Error() + return snap, nil + } + snap.RemoteSubject, _ = gitCmd(ctx, root, "log", "-1", "--format=%s", ref) + + if counts, err := gitCmd(ctx, root, "rev-list", "--left-right", "--count", "HEAD..."+ref); err == nil { + var ahead, behind int + if _, scanErr := fmt.Sscanf(counts, "%d %d", &ahead, &behind); scanErr == nil { + snap.Ahead, snap.Behind = ahead, behind + snap.Diverged = ahead > 0 && behind > 0 + } + } + snap.UpdateAvailable = snap.Behind > 0 + snap.IncomingTotal = snap.Behind + + logs, err := gitCmd(ctx, root, "log", "--format=%h\x1f%s", "-n", fmt.Sprint(maxIncoming), "HEAD.."+ref) + if err == nil { + for _, line := range strings.Split(logs, "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\x1f", 2) + c := Commit{Commit: parts[0]} + if len(parts) > 1 { + c.Subject = parts[1] + } + snap.Incoming = append(snap.Incoming, c) + } + } + return snap, nil +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..1ee7a8f2 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,409 @@ +package update + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// These tests drive real git repositories in temporary directories. A fake would let the +// thing this package exists for - "git says no because your files would be overwritten" - +// pass silently. + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +type tree struct { + upstream string + install string +} + +// newTree builds an upstream repository with one code file and one shipped role, then +// clones it the way an operator would (the clone's origin is the user's own fork, not a +// fixed third-party repository). +func newTree(t *testing.T) *tree { + t.Helper() + root := t.TempDir() + up := filepath.Join(root, "fork") + install := filepath.Join(root, "install") + mustGit(t, root, "init", "-q", "-b", "main", up) + + writeFile(t, filepath.Join(up, "internal_service.go"), "package service\n\nconst Version = \"1\"\n") + writeFile(t, filepath.Join(up, "roles", "shipped.yaml"), "name: shipped\ndescription: upstream copy\n") + writeFile(t, filepath.Join(up, "run.sh"), "#!/bin/sh\necho old\n") + mustGit(t, up, "add", ".") + mustGit(t, up, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "first") + + mustGit(t, root, "clone", "-q", up, install) + mustGit(t, install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "--allow-empty", "-m", "clone marker") + // The clone's own commit makes HEAD and origin/main differ only by the operator's + // history; drop it so the tree starts exactly level with its remote. + mustGit(t, install, "reset", "--hard", "--quiet", "origin/main") + + // The live config file is operator data that is never in the repository. + writeFile(t, filepath.Join(install, "config.yaml"), "server:\n port: 8088\n") + return &tree{upstream: up, install: install} +} + +func (t *tree) upstreamCommit(t2 *testing.T, message string, files map[string]string) { + t2.Helper() + for path, content := range files { + writeFile(t2, filepath.Join(t.upstream, path), content) + } + mustGit(t2, t.upstream, "add", ".") + mustGit(t2, t.upstream, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", message) +} + +func opts(root string) Options { return Options{Root: root, BinaryName: "none"} } + +func TestStatusWithoutNetworkDescribesTheTree(t *testing.T) { + requireGit(t) + tr := newTree(t) + + snap, err := Status(context.Background(), opts(tr.install)) + if err != nil { + t.Fatal(err) + } + if !snap.Installed { + t.Fatal("a clone must be reported as an installable tree") + } + if snap.Branch != "main" || snap.Remote != "origin" { + t.Errorf("branch/remote = %q/%q, want main/origin", snap.Branch, snap.Remote) + } + if snap.Commit == "" { + t.Error("commit is empty") + } + // config.yaml is operator data and is untracked, so it is not a blocking change. + if len(snap.BlockingChanges) != 0 { + t.Errorf("untracked config.yaml must not block an update: %+v", snap.BlockingChanges) + } +} + +func TestStatusReportsTheInstallRootInAbsoluteForm(t *testing.T) { + requireGit(t) + tr := newTree(t) + + // A relative config path gives filepath.Dir as "."; the page must still be able to say + // which directory is being updated. + t.Chdir(tr.install) + snap, err := Status(context.Background(), opts(".")) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(snap.Root) || filepath.Base(snap.Root) != "install" { + t.Errorf("root = %q, want the absolute install directory", snap.Root) + } + + if _, err := Status(context.Background(), Options{Root: " "}); err == nil { + t.Error("an empty install root must be an error, not a scan of the process CWD") + } + if _, err := Rollback(context.Background(), Options{Root: ""}); err == nil { + t.Error("rollback must refuse an empty install root for the same reason") + } +} + +func TestStatusOnNonGitTreeIsAStateNotAnError(t *testing.T) { + requireGit(t) + dir := t.TempDir() + snap, err := Status(context.Background(), opts(dir)) + if err != nil { + t.Fatalf("a tarball install is a readable state, not a fault: %v", err) + } + if snap.Installed || snap.CheckError == "" { + t.Errorf("installed=%v checkError=%q, want false with a reason", snap.Installed, snap.CheckError) + } +} + +func TestCheckFetchesAndListsIncoming(t *testing.T) { + requireGit(t) + tr := newTree(t) + tr.upstreamCommit(t, "second: bump service", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) + tr.upstreamCommit(t, "third: new role", map[string]string{"roles/extra.yaml": "name: extra\n"}) + + snap, err := Check(context.Background(), opts(tr.install)) + if err != nil { + t.Fatal(err) + } + if snap.CheckError != "" { + t.Fatalf("check failed: %s", snap.CheckError) + } + if !snap.UpdateAvailable || snap.Behind != 2 || snap.Ahead != 0 || snap.Diverged { + t.Errorf("behind=%d ahead=%d available=%v diverged=%v, want 2/0/true/false", snap.Behind, snap.Ahead, snap.UpdateAvailable, snap.Diverged) + } + if len(snap.Incoming) != 2 || snap.Incoming[0].Subject != "third: new role" { + t.Errorf("incoming = %+v, want the two new commits newest first", snap.Incoming) + } + if snap.IncomingTotal != 2 { + t.Errorf("incomingTotal = %d, want 2", snap.IncomingTotal) + } +} + +// TestApplyKeepsOperatorContentIsThePointOfTheWholePackage: an update moves code and does +// not destroy the roles, skills or tools the operator made or edited from the console. +func TestApplyKeepsOperatorContentAndMovesCode(t *testing.T) { + requireGit(t) + tr := newTree(t) + // The operator edits a shipped role and adds their own; upstream later rewrites that + // same role and takes the untracked file's name for itself. + writeFile(t, filepath.Join(tr.install, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n") + writeFile(t, filepath.Join(tr.install, "roles", "mine.yaml"), "name: mine\n") + + tr.upstreamCommit(t, "upstream rewrites roles and code", map[string]string{ + "roles/shipped.yaml": "name: shipped\ndescription: upstream rewrite\n", + "roles/mine.yaml": "name: mine\ndescription: upstream stole the name\n", + "internal_service.go": "package service\n\nconst Version = \"3\"\n", + }) + + res, err := Apply(context.Background(), opts(tr.install), nil) + if err != nil { + t.Fatalf("apply failed: %v\nresult: %+v", err, res) + } + if res.Commits == 0 || res.FromCommit == res.ToCommit { + t.Errorf("nothing moved: %+v", res) + } + + if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"3\"") { + t.Errorf("code was not updated, got %q", got) + } + if got := readFile(t, filepath.Join(tr.install, "roles", "shipped.yaml")); !strings.Contains(got, "mine, do not touch") { + t.Errorf("the operator's role was overwritten by upstream: %q", got) + } + if _, err := os.Stat(filepath.Join(tr.install, "roles", "mine.yaml")); err != nil { + t.Errorf("the operator's own role must survive: %v", err) + } + if got := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(got, "8088") { + t.Errorf("config.yaml was disturbed: %q", got) + } + if len(res.KeptContent) == 0 { + t.Error("keptContent must name the files that were protected, not silently drop them") + } + found := false + for _, p := range res.KeptContent { + if p == "roles/shipped.yaml" { + found = true + } + } + if !found { + t.Errorf("keptContent = %v, want roles/shipped.yaml in it", res.KeptContent) + } + if res.BinaryBuilt { + t.Error("BinaryName none must not claim a build") + } + if !res.NeedsRestart { + t.Error("an update that moved code reports that a restart is needed") + } + if _, ok := readState(tr.install); !ok { + t.Error("state file missing: rollback would be unavailable") + } +} + +func TestApplyRefusesLocalSourceEdits(t *testing.T) { + requireGit(t) + tr := newTree(t) + tr.upstreamCommit(t, "new upstream", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"}) + writeFile(t, filepath.Join(tr.install, "internal_service.go"), "package service\n\nconst Version = \"local hand edit\"\n") + + _, err := Apply(context.Background(), opts(tr.install), nil) + ue, ok := err.(*Error) + if !ok { + t.Fatalf("want *Error, got %v", err) + } + if ue.Reason != "local_source_edits" { + t.Errorf("reason = %q, want local_source_edits", ue.Reason) + } + if !strings.Contains(strings.Join(ue.Items, " "), "internal_service.go") { + t.Errorf("the refusal must name the file, got %v", ue.Items) + } + if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "local hand edit") { + t.Errorf("a refused update must not touch anything, got %q", got) + } +} + +func TestApplyRefusesDivergedBranch(t *testing.T) { + requireGit(t) + tr := newTree(t) + tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"}) + writeFile(t, filepath.Join(tr.install, "my_patch.go"), "package service\n") + mustGit(t, tr.install, "add", "my_patch.go") + mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "my own commit") + + _, err := Apply(context.Background(), opts(tr.install), nil) + ue, ok := err.(*Error) + if !ok || ue.Reason != "diverged" { + t.Fatalf("a branch with its own commits is a merge decision, not a download; got %v", err) + } +} + +func TestApplyWhenAlreadyUpToDateIsANoOp(t *testing.T) { + requireGit(t) + tr := newTree(t) + res, err := Apply(context.Background(), opts(tr.install), nil) + if err != nil { + t.Fatalf("up to date must not be an error: %v", err) + } + if res.Commits != 0 || res.ToCommit != res.FromCommit { + t.Errorf("res = %+v, want zero movement", res) + } + if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) { + t.Error("a no-op update must not write a rollback state") + } +} + +func TestRollbackMovesBackToTheRecordedCommit(t *testing.T) { + requireGit(t) + tr := newTree(t) + before := mustGit(t, tr.install, "rev-parse", "HEAD") + tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"}) + if _, err := Apply(context.Background(), opts(tr.install), nil); err != nil { + t.Fatal(err) + } + // Rollback restores the binary kept before a swap; stand one up the way installBinary + // would have left it. + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "new binary bytes") + writeFile(t, bin+".prev", "old binary bytes") + + res, err := Rollback(context.Background(), Options{Root: tr.install}) + if err != nil { + t.Fatalf("rollback failed: %v", err) + } + if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != strings.TrimSpace(before) { + t.Errorf("HEAD after rollback = %s, want %s", got, before) + } + if got := readFile(t, bin); !strings.Contains(got, "old binary") { + t.Errorf("binary after rollback = %q, want the kept previous one", got) + } + if res.ToCommit == "" { + t.Error("rollback must report where it went") + } + if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) { + t.Error("a completed rollback must clear the state file") + } +} + +func TestRollbackRefusesWhenHeadMoved(t *testing.T) { + requireGit(t) + tr := newTree(t) + tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"}) + if _, err := Apply(context.Background(), opts(tr.install), nil); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "new") + writeFile(t, bin+".prev", "old") + // Somebody else moved the tree after the update. + writeFile(t, filepath.Join(tr.install, "extra.go"), "package service\n") + mustGit(t, tr.install, "add", "extra.go") + mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "later work") + + _, err := Rollback(context.Background(), Options{Root: tr.install}) + ue, ok := err.(*Error) + if !ok || ue.Reason != "moved_since_update" { + t.Fatalf("rollback must refuse rather than discard work done after the update, got %v", err) + } + if _, err := os.Stat(bin + ".prev"); err != nil { + t.Error("a refused rollback must leave the .prev binary where it was") + } +} + +func TestProtectedCoversOperatorDataAndNotCode(t *testing.T) { + yes := []string{"roles/CTF.yaml", "skills/x/SKILL.md", "tools/semgrep.yaml", "agents/a.md", "bundles/pack/bundle.yaml", + "knowledge_base/note.md", "data/conversations.db", "log/server.log", "config.yaml", ".env", "venv/bin/python"} + no := []string{"internal/handler/plugin.go", "web/static/js/plugins.js", "cmd/server/main.go", "Makefile", "docs/zh-CN/c2.md"} + for _, p := range yes { + if !Protected(p) { + t.Errorf("%s must be protected from an update", p) + } + } + for _, p := range no { + if Protected(p) { + t.Errorf("%s is product source and must be updated", p) + } + } +} + +func TestValidNameRejectsArgumentInjection(t *testing.T) { + for _, bad := range []string{"", "-C", "--upload-pack=touch /tmp/x", "a b", "a;b", "a\nb", "-x/y", "/abs/path", "../evil"} { + if ValidName(bad) { + t.Errorf("%q must be rejected before it reaches git's argv", bad) + } + } + for _, good := range []string{"origin", "mine", "main", "feature/docker", "v1.2.3"} { + if !ValidName(good) { + t.Errorf("%q is a legitimate remote or branch name", good) + } + } +} + +func TestInstallBinaryKeepsPrevAndRestoresOnFailedSwap(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "cyberstrike-ai") + src := filepath.Join(dir, "new") + writeFile(t, dst, "old") + writeFile(t, src, "new") + + prev, err := installBinary(src, dst) + if err != nil { + t.Fatal(err) + } + if readFile(t, dst) != "new" || readFile(t, prev) != "old" { + t.Errorf("swap result: dst=%q prev=%q", readFile(t, dst), readFile(t, prev)) + } + if err := restorePrevBinary(dst); err != nil { + t.Fatal(err) + } + if readFile(t, dst) != "old" { + t.Errorf("rollback binary = %q, want old", readFile(t, dst)) + } + if fileExists(dst + ".prev") { + t.Error("restoring the previous binary must consume the .prev file") + } +} + +func TestTailKeepsTheEndOfABuildLog(t *testing.T) { + long := strings.Repeat("download\n", 500) + "vet: real error at the end" + got := tail(long, 200) + if !strings.Contains(got, "real error at the end") { + t.Error("tail must keep the compiler's actual error") + } + if len(got) > 260 { + t.Errorf("tail did not truncate: %d bytes", len(got)) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func mustGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} diff --git a/upgrade.sh b/upgrade.sh old mode 100644 new mode 100755 index 99cd3714..e44a9c8f --- a/upgrade.sh +++ b/upgrade.sh @@ -1,15 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -# CyberStrikeAI GitHub one-click upgrade script (Release/Tag) +# CyberStrikeAI upgrade entry point. +# +# Two installation kinds, decided at runtime from this directory itself: +# +# 1. git work tree (the normal case, and the only honest one for a fork): +# the update is delegated to the platform binary, `./cyberstrike-ai -update`, which +# fetches the remote *this directory already tracks*, fast-forwards, rebuilds with +# `go build` and swaps the binary in atomically. No repository name is hardcoded on +# this path, so "upgrade" can never overwrite your work with somebody else's code. +# Refusals (local source edits, a diverged branch) come from that same implementation +# and name the files/commits involved. +# +# 2. Plain directory (Release tarball install, no git): falls back to downloading a +# Release tarball and synchronizing it with `rsync --delete`. Here the source +# repository must be named, so it is taken from --repo, else the GITHUB_REPO +# environment variable, and only if neither is set from the built-in default - with a +# warning that says out loud which repository the code is coming from. # # Default preserves: # - config.yaml # - data/ # - venv/ (disabled with --no-venv) # - tools/ (user extensions; never overwritten by upgrade) -# - roles/ -# - skills/ +# - roles/, skills/, agents/ +# - the rollback points of an earlier one-click update: +# .update-backup/, .update-state.json, cyberstrike-ai.prev ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" @@ -23,32 +40,60 @@ KNOWLEDGE_BASE_DIR="$ROOT_DIR/knowledge_base" BACKUP_BASE_DIR="$ROOT_DIR/.upgrade-backup" UPGRADE_TMP_DIR="" -GITHUB_REPO="Ed1s0nZ/CyberStrikeAI" +# Built-in fallback, used ONLY when this directory is not a git work tree and neither +# --repo nor GITHUB_REPO was given. See resolve_repo(). +DEFAULT_GITHUB_REPO="AIPentest/CyberStrikeAI" +GITHUB_REPO="${GITHUB_REPO:-}" +REPO_ARG="" TAG="" PRESERVE_VENV=1 STOP_SERVICE=1 FORCE_STOP=0 YES=0 +CHECK_ONLY=0 + +# Set by resolve_install_kind(): "git" or "tarball". +INSTALL_KIND="" usage() { - cat <] + +What it does depends on the installation kind (detected automatically): + + git work tree Updates from the remote this directory already tracks, by calling + ./cyberstrike-ai -update (fetch, fast-forward, go build, swap binary). + --tag/--repo are ignored on this path. Your own roles/skills/tools/ + agents/data/config are put aside and restored by the platform; + local source edits or a diverged branch stop the update instead of + being overwritten. + plain directory (Release tarball install, no git) Downloads the Release tarball and + syncs it in with rsync --delete. --repo / GITHUB_REPO apply here. Options: - --tag Specify GitHub Release tag (e.g. v1.3.28). - If omitted, the script uses the latest release. - --no-venv Do not preserve venv/ (Python deps will be re-installed). - --no-stop Do not try to stop the running service. - --force-stop If no process matching current directory is found, also stop - any cyberstrike-ai processes (use with caution). - --yes Do not ask for confirmation. + --check Report only, change nothing: never stops the service, never writes. + git tree: ./cyberstrike-ai -check-update, or + go run ./cmd/server -check-update while there is no binary yet. + plain directory: prints the newest Release tag and the version + recorded in config.yaml. + --repo Release repository to read (tarball installs only). Takes precedence + over $GITHUB_REPO. Without either, the built-in default is used and + the script warns about it. + --tag Specify GitHub Release tag (e.g. v1.3.28). + If omitted, the script uses the latest release. + --no-venv Do not preserve venv/ (Python deps will be re-installed). + --no-stop Do not stop the running service, and do not start it afterwards + (use this under systemd or any supervisor; restart the unit yourself). + --force-stop If no process matching current directory is found, also stop + any cyberstrike-ai processes (use with caution). + --yes Do not ask for confirmation. -Description: - The script backs up config.yaml/data/tools/roles/skills/ (and optionally venv/) to - .upgrade-backup/ +Rollback: + ./cyberstrike-ai -update-rollback + Undoes the last one-click update (back to the commit and the binary kept before it). EOF } @@ -78,6 +123,76 @@ http_get() { fi } +physical_path() { + # $1: existing directory; symlinks resolved, so paths reported by git compare cleanly + # (macOS /tmp and /var are symlinks). + ( cd "$1" 2>/dev/null && pwd -P ) || true +} + +is_git_work_tree() { + # $1: directory + have_cmd git && git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +resolve_install_kind() { + if ! have_cmd git; then + INSTALL_KIND="tarball" + info "git is not installed: treating this directory as a tarball install (Release download)." + return 0 + fi + + if ! is_git_work_tree "$ROOT_DIR"; then + INSTALL_KIND="tarball" + info "This directory is not a git work tree: using the Release tarball path." + return 0 + fi + + local raw_top top_phys root_phys + raw_top="$(git -C "$ROOT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -n "$raw_top" ]]; then + top_phys="$(physical_path "$raw_top")" + root_phys="$(physical_path "$ROOT_DIR")" + if [[ -n "$top_phys" && "$top_phys" != "$root_phys" ]]; then + err "This script lives in ${ROOT_DIR}, which is a subdirectory of the repository root ${raw_top}." + err "One-click update only ever moves a repository root, so run the upgrade from ${raw_top}." + exit 1 + fi + fi + + INSTALL_KIND="git" +} + +show_git_source() { + # Say out loud where the code is coming from: that is the whole point of this path. + local branch + branch="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + info "Install directory: ${ROOT_DIR}" + info "Branch: ${branch:-unknown}" + + local remotes name url + remotes="$(git -C "$ROOT_DIR" remote 2>/dev/null || true)" + if [[ -z "$remotes" ]]; then + warn "This repository has no git remote, so there is nothing to fetch. Add one (git remote add ...) before updating." + return 0 + fi + info "Remotes this directory tracks (the update pulls from its own remote, never from a hardcoded third-party one):" + for name in $remotes; do + url="$(git -C "$ROOT_DIR" config --get "remote.${name}.url" 2>/dev/null || true)" + info " - ${name}: ${url:-unknown}" + done +} + +explain_no_go() { + err "No compiled ${BINARY_NAME} binary in ${ROOT_DIR} and no Go toolchain on PATH." + err "One of the two is required: an existing binary rebuilds itself, a source-only" + err "checkout needs Go to produce the first binary." + err "Install Go (this module requires Go 1.25 or newer):" + err " download: https://go.dev/dl/" + err " macOS: brew install go" + err " Debian/Ubuntu: sudo apt install golang-go (or the official tarball in /usr/local/go)" + err "Then re-run: ./upgrade.sh" +} + stop_service() { # Try to stop the service that is running from the current project directory. # If nothing is found and --force-stop is enabled, stop all cyberstrike-ai processes. @@ -129,6 +244,187 @@ stop_service() { return 0 } +start_service() { + if [[ "$STOP_SERVICE" -ne 1 ]]; then + info "--no-stop: the service was left untouched. Restart it yourself (for example: systemctl restart cyberstrikeai)." + return 0 + fi + if [[ ! -f "$ROOT_DIR/run.sh" ]]; then + warn "run.sh not found: starting the service is left to you." + return 0 + fi + info "Starting service..." + chmod +x ./run.sh + ./run.sh +} + +confirm_or_exit() { + if [[ "$YES" -eq 1 ]]; then + return 0 + fi + + if [[ ! -t 0 ]]; then + err "Non-interactive terminal detected. Please add --yes to continue." + exit 1 + fi + + if [[ "$INSTALL_KIND" == "git" ]]; then + warn "About to update this installation from the remote it already tracks:" + info " - git fetch + fast-forward of ${ROOT_DIR}" + info " (refused with file names if source files are modified locally, or if the" + info " branch has diverged - that is a merge decision, not a download)" + info " - go build, then the binary is swapped; the previous one is kept as ${BINARY_NAME}.prev" + info " - your content (roles/skills/tools/agents/knowledge_base/data/config.yaml)" + info " is put aside before the merge and restored after it; the result lists what was kept" + info " - rollback stays available: .update-backup// + .update-state.json" + info " - Stop service: ${STOP_SERVICE}" + else + warn "About to perform upgrade:" + info " - Source repository: ${GITHUB_REPO:-unset} (this directory is not a git work tree)" + info " - Preserve config.yaml: yes" + info " - Preserve data/: yes" + if [[ "$PRESERVE_VENV" -eq 1 ]]; then + info " - Preserve venv/: yes" + else + info " - Preserve venv/: no (will remove old venv and re-install deps)" + fi + info " - Preserve tools/: yes (always)" + info " - Preserve roles/skills/agents: yes (always)" + info " - Preserve rollback points (.update-backup/, .update-state.json, ${BINARY_NAME}.prev): yes" + info " - Stop service: ${STOP_SERVICE}" + fi + + echo "" + read -r -p "Continue? (y/N) " ans + if [[ "${ans:-N}" != "y" && "${ans:-N}" != "Y" ]]; then + err "Cancelled." + exit 1 + fi +} + +run_platform_update_cli() { + # $1: '-check-update' or '-update'. Same implementation the console page drives, so a + # keyboard session and the API cannot disagree about what an update means. + local flag="$1" + if [[ ! -f "$ROOT_DIR/$BINARY_NAME" ]]; then + err "Expected ${BINARY_NAME} in ${ROOT_DIR} but it is not there." + err "Run ./upgrade.sh again (it builds the binary first) or use --check." + return 1 + fi + if [[ ! -x "$ROOT_DIR/$BINARY_NAME" ]]; then + chmod +x "./$BINARY_NAME" + fi + info "Running: ./${BINARY_NAME} ${flag}" + local rc=0 + ./"$BINARY_NAME" "$flag" || rc=$? + return "$rc" +} + +report_update_refusal() { + # $1: exit code. The platform already printed the exact reason and the file names above; + # this only says what it means for the tree, because the two outcomes look the same. + warn "${BINARY_NAME} exited with code $1. Read the reason printed above." + info " - Local source edits / diverged branch: nothing was moved. Commit or restore your" + info " own source changes (or merge by hand), then run ./upgrade.sh again." + info " - No Go toolchain: the source DID move and your content was kept; the binary is" + info " still the previous one. Install Go and run ./upgrade.sh again to finish." + info " - Undo the last successful update: ./${BINARY_NAME} -update-rollback" +} + +build_first_binary() { + info "No ${BINARY_NAME} binary yet: building one before updating (./cmd/server, first run may download modules)." + if ! have_cmd go; then + explain_no_go + exit 1 + fi + if ! go build -o "$BINARY_NAME" ./cmd/server; then + err "The first build failed, so the platform has no binary to update itself with." + err "Fix the build (see the compiler output above) and run ./upgrade.sh again." + exit 1 + fi + if [[ ! -f "$BINARY_NAME" ]]; then + err "go build reported success but produced no ${BINARY_NAME} in ${ROOT_DIR}." + exit 1 + fi + chmod +x "./$BINARY_NAME" +} + +check_git_install() { + # Read-only path: never stops or starts the service. + if [[ -f "$ROOT_DIR/$BINARY_NAME" ]]; then + local rc=0 + run_platform_update_cli "-check-update" || rc=$? + if [[ "$rc" -ne 0 ]]; then + report_update_refusal "$rc" + fi + return "$rc" + fi + if have_cmd go; then + info "No ${BINARY_NAME} binary yet, so the check is asked of the source tree: go run compiles into the build cache and leaves this directory untouched." + local rc=0 + go run ./cmd/server -check-update || rc=$? + if [[ "$rc" -ne 0 ]]; then + report_update_refusal "$rc" + fi + return "$rc" + fi + explain_no_go + exit 1 +} + +update_git_install() { + confirm_or_exit + + stop_service + + if [[ ! -f "$ROOT_DIR/$BINARY_NAME" ]]; then + build_first_binary + fi + if ! have_cmd go; then + warn "No Go toolchain on PATH: the update will move the source and keep your content, but it cannot rebuild the binary." + warn "Install Go and run ./upgrade.sh again to swap in the new binary." + fi + + # A fast-forward may rewrite this script as well; harmless, the running copy is already + # open, and the next invocation reads the updated file. + local rc=0 + run_platform_update_cli "-update" || rc=$? + if [[ "$rc" -ne 0 ]]; then + report_update_refusal "$rc" + return "$rc" + fi + + info "Source and binary are in place (the running process is still the old one until it is restarted)." + start_service +} + +ensure_git_style_env() { + # No hard requirement; just a sanity check. + if [[ ! -f "$CONFIG_FILE" ]]; then + err "Could not find ${CONFIG_FILE}. Please verify you are in the correct project directory." + exit 1 + fi +} + +resolve_repo() { + # Tarball path only: without a repository of our own, the source has to be named, and + # naming it silently is how a fork gets overwritten. --repo beats GITHUB_REPO, and the + # built-in default is a last resort that says so. + if [[ -n "$REPO_ARG" ]]; then + GITHUB_REPO="$REPO_ARG" + fi + if [[ -z "${GITHUB_REPO:-}" ]]; then + GITHUB_REPO="$DEFAULT_GITHUB_REPO" + warn "Neither --repo nor GITHUB_REPO was given, so the built-in default repository is used." + fi + if [[ ! "$GITHUB_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + err "Invalid repository: ${GITHUB_REPO} (expected the form owner/name)." + exit 1 + fi + warn "Not a git work tree: code will be downloaded from https://github.com/${GITHUB_REPO} (a Release tarball), not from this directory's own remote." + warn "If this installation is meant to track your fork, make it a git clone; ./upgrade.sh then pulls from the remote it already has." +} + backup_dir_tgz() { # $1: label, $2: path local label="$1" @@ -145,43 +441,6 @@ backup_config() { fi } -ensure_git_style_env() { - # No hard requirement; just a sanity check. - if [[ ! -f "$CONFIG_FILE" ]]; then - err "Could not find ${CONFIG_FILE}. Please verify you are in the correct project directory." - exit 1 - fi -} - -confirm_or_exit() { - if [[ "$YES" -eq 1 ]]; then - return 0 - fi - - if [[ ! -t 0 ]]; then - err "Non-interactive terminal detected. Please add --yes to continue." - exit 1 - fi - - warn "About to perform upgrade:" - info " - Preserve config.yaml: yes" - info " - Preserve data/: yes" - if [[ "$PRESERVE_VENV" -eq 1 ]]; then - info " - Preserve venv/: yes" - else - info " - Preserve venv/: no (will remove old venv and re-install deps)" - fi - info " - Preserve tools/: yes (always)" - info " - Preserve roles/skills: yes (always)" - info " - Stop service: ${STOP_SERVICE}" - echo "" - read -r -p "Continue? (y/N) " ans - if [[ "${ans:-N}" != "y" && "${ans:-N}" != "Y" ]]; then - err "Cancelled." - exit 1 - fi -} - resolve_tag() { if [[ -n "$TAG" ]]; then info "Using specified tag: $TAG" @@ -234,6 +493,19 @@ PY info "Latest Release tag: $TAG" } +check_tarball_install() { + # Nothing to fast-forward without a repository of our own: report the newest published + # tag against the version recorded in config.yaml, and change no file. + resolve_tag + local current="" + if [[ -f "$CONFIG_FILE" ]]; then + current="$(grep -m1 -E '^[[:space:]]*version[[:space:]]*:' "$CONFIG_FILE" 2>/dev/null | cut -d: -f2- | tr -d ' "' || true)" + fi + info "Newest Release tag of ${GITHUB_REPO}: ${TAG}" + info "Version recorded in config.yaml: ${current:-unknown}" + info "Report only: nothing was stopped, downloaded or written. Run ./upgrade.sh without --check to apply." +} + update_config_version() { # Replace config.yaml's version: ... with the specified tag. local new_tag="$1" @@ -248,14 +520,14 @@ out=[] replaced=False for line in lines: if re.match(r'^\s*version\s*:', line): - out.append(f'version: "{tag}"\\n') + out.append(f'version: "{tag}"\n') replaced=True else: out.append(line) if not replaced: # If no version field is found, insert at the beginning (near the top). - out.insert(0, f'version: "{tag}"\\n') + out.insert(0, f'version: "{tag}"\n') with open(path, "w", encoding="utf-8") as f: f.writelines(out) @@ -293,6 +565,16 @@ sync_code() { rsync_excludes+=( "--exclude=tools/" ) rsync_excludes+=( "--exclude=roles/" ) rsync_excludes+=( "--exclude=skills/" ) + # Markdown agents are operator content too, and the platform's own update protects the + # same set. + rsync_excludes+=( "--exclude=agents/" ) + + # Rollback points written by a one-click update: an rsync --delete that ate them would + # leave the installation with no way back. + rsync_excludes+=( "--exclude=.update-backup/" ) + rsync_excludes+=( "--exclude=.update-staging/" ) + rsync_excludes+=( "--exclude=.update-state.json" ) + rsync_excludes+=( "--exclude=${BINARY_NAME}.prev" ) # Ensure this upgrade script itself is not deleted. rsync_excludes+=( "--exclude=upgrade.sh" ) @@ -304,43 +586,8 @@ sync_code() { "${new_src_dir}/" "${ROOT_DIR}/" } -main() { +update_tarball_install() { ensure_git_style_env - - while [[ $# -gt 0 ]]; do - case "$1" in - --tag) - TAG="${2:-}" - shift 2 - ;; - --no-venv) - PRESERVE_VENV=0 - shift 1 - ;; - --no-stop) - STOP_SERVICE=0 - shift 1 - ;; - --force-stop) - FORCE_STOP=1 - shift 1 - ;; - --yes) - YES=1 - shift 1 - ;; - -h|--help) - usage - exit 0 - ;; - *) - err "Unknown parameter: $1" - usage - exit 1 - ;; - esac - done - confirm_or_exit stop_service @@ -375,6 +622,9 @@ main() { if [[ -d "$ROOT_DIR/skills" ]]; then backup_dir_tgz "skills" "$ROOT_DIR/skills" fi + if [[ -d "$ROOT_DIR/agents" ]]; then + backup_dir_tgz "agents" "$ROOT_DIR/agents" + fi UPGRADE_TMP_DIR="$(mktemp -d)" trap 'rm -rf "${UPGRADE_TMP_DIR:-}" >/dev/null 2>&1 || true' EXIT @@ -403,9 +653,78 @@ main() { update_config_version "$TAG" fi - info "Upgrade complete. Starting service..." - chmod +x ./run.sh - ./run.sh + info "Upgrade complete." + start_service +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --check) + CHECK_ONLY=1 + shift 1 + ;; + --repo) + REPO_ARG="${2:-}" + shift 2 + ;; + --tag) + TAG="${2:-}" + shift 2 + ;; + --no-venv) + PRESERVE_VENV=0 + shift 1 + ;; + --no-stop) + STOP_SERVICE=0 + shift 1 + ;; + --force-stop) + FORCE_STOP=1 + shift 1 + ;; + --yes) + YES=1 + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + err "Unknown parameter: $1" + usage + exit 1 + ;; + esac + done + + resolve_install_kind + + if [[ "$INSTALL_KIND" == "git" ]]; then + if [[ -n "$TAG" ]]; then + warn "--tag names a GitHub Release and is ignored here: this directory fast-forwards to its own remote, not to a tag." + fi + if [[ -n "$REPO_ARG" ]]; then + warn "--repo names a GitHub Release repository and is ignored here: the remote is the one this directory already tracks." + fi + show_git_source + if [[ "$CHECK_ONLY" -eq 1 ]]; then + check_git_install + return 0 + fi + update_git_install + return 0 + fi + + # Tarball install: the source has to be named, and naming it out loud comes first. + resolve_repo + if [[ "$CHECK_ONLY" -eq 1 ]]; then + check_tarball_install + return 0 + fi + update_tarball_install } main "$@" diff --git a/web/static/css/style.css b/web/static/css/style.css index 1479f2da..adcdf321 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -46715,3 +46715,293 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } white-space: nowrap; line-height: 1.5; } + + +/* --------------------------------------------------------------------------- + 一键更新控制台(system-update):这里说的每一句状态都要能被眼睛分区—— + 本机事实、远端比较、能不能点、正在跑的进度、以及跑完的结论。 + ok/warn/danger 三色是给"说谎"留的对照组:查不了必须是 warn/danger,不能长得像 ok。 + --------------------------------------------------------------------------- */ +.update-console { + display: flex; + flex-direction: column; + gap: 20px; +} + +.update-intro { + color: var(--text-secondary); + margin: 0 0 12px; +} + +.update-section-title { + font-size: 15px; + margin: 0 0 10px; +} + +.update-sub-title { + font-size: 13px; + font-weight: 600; + margin: 14px 0 6px; + color: var(--text-secondary); +} + +.update-facts { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +.update-fact { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + min-width: 0; + max-width: 100%; +} + +.update-fact code { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 420px; +} + +.update-marks { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.update-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.update-chip-ok { + background: rgba(37, 168, 112, 0.14); + border-color: rgba(37, 168, 112, 0.4); + color: #1f8a5b; +} + +.update-chip-warn { + background: rgba(217, 130, 43, 0.14); + border-color: rgba(217, 130, 43, 0.4); + color: #b06a17; + cursor: help; +} + +.update-chip-danger { + background: rgba(192, 57, 43, 0.14); + border-color: rgba(192, 57, 43, 0.4); + color: var(--danger-color, #c0392b); +} + +.update-chip-running { + background: rgba(58, 110, 197, 0.14); + border-color: rgba(58, 110, 197, 0.4); + color: #2d5bb0; +} + +.update-ok { + color: #1f8a5b; + font-size: 13px; +} + +.update-behind { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.update-hint { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; + margin: 6px 0; +} + +.update-warn { + background: rgba(217, 130, 43, 0.12); + border: 1px solid rgba(217, 130, 43, 0.35); + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + color: #b06a17; + margin: 6px 0; +} + +.update-error { + background: rgba(192, 57, 43, 0.1); + border: 1px solid rgba(192, 57, 43, 0.32); + border-radius: 8px; + padding: 8px 12px; + color: var(--danger-color, #c0392b); + font-size: 13px; + margin: 6px 0; +} + +/* 「检查失败」那一行是这一页最不该被看漏的字,所以它比别的错误横幅再重一档。 */ +.update-check-failed { + font-weight: 600; +} + +/* 这三个按钮的名字既是样式钩子也是测试钩子:禁用态要看得出是禁用,而不是"点了没反应"。 */ +.update-apply-btn:disabled, +.update-check-btn:disabled, +.update-rollback-btn:disabled { + cursor: not-allowed; + opacity: .55; +} + +.update-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 10px 0; +} + +.update-restart-choice { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + margin: 4px 0; +} + +.update-restart-choice input { + margin-top: 2px; +} + +.update-restart-preview { + border-left: 3px solid var(--border-color); + padding-left: 10px; +} + +.update-blocker-list { + margin: 6px 0 0; + padding-left: 18px; + font-size: 13px; +} + +.update-commit-list, +.update-kept-list, +.update-item-list { + margin: 0; + padding-left: 18px; + font-size: 13px; + line-height: 1.8; +} + +.update-commit-list code, +.update-kept-list code, +.update-item-list code { + color: var(--text-primary); +} + +.update-commit-subject { + color: var(--text-secondary); +} + +.update-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.update-table th, +.update-table td { + text-align: left; + padding: 6px 8px; + border-bottom: 1px solid var(--border-color); + word-break: break-all; +} + +.update-row-blocking td { + background: rgba(192, 57, 43, 0.06); +} + +.update-row-kept td { + background: rgba(37, 168, 112, 0.06); +} + +.update-job-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-secondary); +} + +.update-job-time { + font-size: 12px; + color: var(--text-secondary); +} + +.update-step-list { + margin: 10px 0 0; + padding: 10px 12px; + list-style: decimal inside; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; +} + +.update-step { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 3px 0; + border-bottom: 1px dashed var(--border-color); +} + +.update-step:last-child { + border-bottom: none; +} + +.update-step-phase { + color: #2d5bb0; + flex: 0 0 auto; +} + +.update-step-message { + color: var(--text-primary); + flex: 1 1 320px; + white-space: pre-wrap; + word-break: break-word; +} + +.update-step-at { + color: var(--text-secondary); + flex: 0 0 auto; +} + +.update-result { + margin-top: 12px; +} + +.update-failure-title { + font-weight: 600; + margin-bottom: 4px; +} + +.update-failure-message { + white-space: pre-wrap; + word-break: break-word; +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 14a145cd..e1b0ebd1 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -79,7 +79,102 @@ "passwordPlaceholder": "Enter password", "submit": "Sign in" }, + "update": { + "title": "One-Click Update", + "intro": "This page changes the very installation that is answering your requests: it pulls the remote this tree already tracks, rebuilds the binary and swaps it in. The roles/skills/tools and configuration you maintain here are not overwritten.", + "loading": "Reading this installation's state", + "loadFailed": "Failed to read this installation's state: {{reason}}", + "installTitle": "This installation", + "remoteTitle": "Against the remote", + "applyTitle": "One-click update", + "jobTitle": "Update progress", + "rollbackTitle": "Rollback", + "root": "Install directory", + "branch": "Branch", + "remote": "Remote", + "commit": "Current commit", + "committedAt": "Committed at", + "subject": "Commit subject", + "gitTree": "git work tree", + "notGitTree": "Not a git work tree", + "toolchainFound": "Go toolchain present", + "toolchainMissing": "No Go toolchain", + "toolchainMissingHint": "No go command found: an update can only move the source, the build needs another machine", + "binaryPresent": "Binary present", + "binaryMissing": "Binary missing", + "rollbackPresent": "Rollback point exists", + "rollbackMissing": "No rollback point", + "notChecked": "The remote has not been consulted yet. Opening this page queues one automatic check (once per session); press Check for updates to ask again any time.", + "checkBtn": "Check for updates", + "checking": "Checking the remote...", + "checkFailed": "Check failed: {{reason}}", + "applyFailed": "Failed to start the update: {{reason}}", + "rollbackFailed": "Rollback failed: {{reason}}", + "behindCount": "{{n}} commits behind", + "aheadCount": "{{n}} commits ahead locally", + "upToDate": "Up to date: local {{commit}} matches the remote", + "remoteHead": "Remote head", + "remoteSubject": "Remote commit subject", + "incomingTitle": "Incoming commits", + "incomingTruncated": "{{total}} new commits in total; only the first {{shown}} are listed", + "applyBtn": "Update now", + "jobRunningHint": "An update is already running; its progress is below.", + "noToolchainWarn": "No Go toolchain here: the source will be updated but the binary will not be replaced; build it externally, then restart.", + "blockedNotGitTree": "This directory is not a git work tree, so it cannot be updated automatically.", + "blockedJobRunning": "An update is already running; wait for it to finish.", + "blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.", + "blockedDiverged": "Local history is {{ahead}} commits ahead and {{behind}} behind: that is a merge, not a download, so handle it by hand.", + "blockedNoUpdate": "The remote has nothing newer than this machine (press Check for updates if you have not looked yet).", + "restartChoiceLabel": "Exit the process after the update (only an external supervisor such as systemd or run.sh will bring it back; restart it yourself if nothing supervises it)", + "restartNotWired": "This start did not provide a restart hook: restart the service yourself after the update.", + "confirmRestartYes": "Selected: the process exits once the update succeeds, and only an external supervisor (systemd, run.sh, ...) will bring it back.", + "confirmRestartNo": "Not selected: the process keeps running the old binary until you restart it yourself.", + "confirmApply": "About to run: fast-forward {{remote}}/{{branch}} to {{target}} ({{n}} commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.", + "applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.", + "localChangesTitle": "Local changes", + "localChangesHint": "Files marked local content are kept exactly as they are; files marked product source block the automatic update.", + "noLocalChanges": "Working tree clean: no tracked file is modified or deleted.", + "colPath": "File", + "colChangeStatus": "State", + "colOwner": "Owner", + "keptFile": "Local content (kept by the update)", + "productSource": "Product source (blocks the update)", + "noJob": "No update has run yet", + "noSteps": "The job has not produced a progress line yet", + "pollingHint": "Progress is read every {{sec}} seconds", + "stateRunning": "Running", + "stateSucceeded": "Succeeded", + "stateFailed": "Failed", + "stateUnknown": "Unknown state", + "restartRequested": "Exit after completion requested", + "jobPollFailed": "Failed to read the progress: {{reason}}", + "jobDoneToast": "Update finished", + "jobFailedToast": "Update failed", + "needsRestartToast": "The binary was replaced; it takes effect after a restart", + "failureNoMessage": "The server gave no failure message", + "resultTitle": "Update result", + "resultFrom": "From", + "resultTo": "To", + "resultCommits": "Commits brought in", + "resultFiles": "Files touched", + "resultDuration": "Duration", + "resultBackup": "Backup directory", + "keptTitle": "Local content files that were kept", + "nothingKept": "No local content file had to be moved aside", + "binarySwapped": "Binary replaced", + "binaryNotSwapped": "Binary unchanged", + "restartNeeded": "A restart is needed to run the new version", + "restartNotNeeded": "No restart needed", + "failureTitle": "Why it failed", + "noRollback": "No rollback point: one exists only after an update has run", + "rollbackTarget": "Rollback target: {{commit}} (where this tree stood before the last update)", + "rollbackBtn": "Roll back the update", + "confirmRollback": "Roll back to {{commit}}? The source returns to before that update and the previous binary is restored.", + "rollbackDone": "Rolled back to {{commit}}", + "rollbackItems": "Files that were not rolled back" + }, "nav": { + "systemUpdate": "One-Click Update", "security": "Security", "toolGuard": "Call blocking", "dashboard": "Dashboard", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index d48b0930..bde1c7e0 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -79,7 +79,102 @@ "passwordPlaceholder": "Введите пароль", "submit": "Войти" }, + "update": { + "title": "Обновление в один клик", + "intro": "Эта страница изменяет саму установку, которая отвечает на ваши запросы: она подтягивает удалённый репозиторий, который уже отслеживает это дерево, пересобирает бинарный файл и подменяет его. Ваши roles/skills/tools и конфигурация не перезаписываются.", + "loading": "Чтение состояния этой установки", + "loadFailed": "Не удалось прочитать состояние установки: {{reason}}", + "installTitle": "Эта установка", + "remoteTitle": "Сравнение с удалённым репозиторием", + "applyTitle": "Обновление в один клик", + "jobTitle": "Ход обновления", + "rollbackTitle": "Откат", + "root": "Каталог установки", + "branch": "Ветка", + "remote": "Удалённый репозиторий", + "commit": "Текущий коммит", + "committedAt": "Дата коммита", + "subject": "Заголовок коммита", + "gitTree": "git-рабочее дерево", + "notGitTree": "Не git-рабочее дерево", + "toolchainFound": "Инструментарий Go на месте", + "toolchainMissing": "Нет инструментария Go", + "toolchainMissingHint": "Команда go не найдена: обновление сможет только перенести исходники, для сборки нужна другая машина", + "binaryPresent": "Бинарный файл на месте", + "binaryMissing": "Бинарный файл отсутствует", + "rollbackPresent": "Точка отката есть", + "rollbackMissing": "Точки отката нет", + "notChecked": "Удалённый репозиторий ещё не проверялся. При открытии страницы запускается одна автоматическая проверка (по одной за сессию); нажмите «Проверить обновления», чтобы спросить ещё раз.", + "checkBtn": "Проверить обновления", + "checking": "Проверка удалённого репозитория...", + "checkFailed": "Проверка не удалась: {{reason}}", + "applyFailed": "Не удалось запустить обновление: {{reason}}", + "rollbackFailed": "Откат не удался: {{reason}}", + "behindCount": "Отставание на {{n}} коммитов", + "aheadCount": "Локально на {{n}} коммитов впереди", + "upToDate": "Всё актуально: локальный {{commit}} совпадает с удалённым", + "remoteHead": "Вершина удалённого репозитория", + "remoteSubject": "Заголовок удалённого коммита", + "incomingTitle": "Входящие коммиты", + "incomingTruncated": "Всего новых коммитов: {{total}}; показаны только первые {{shown}}", + "applyBtn": "Обновить сейчас", + "jobRunningHint": "Обновление уже выполняется; его ход показан ниже.", + "noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.", + "blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.", + "blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.", + "blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.", + "blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.", + "blockedNoUpdate": "В удалённом репозитории нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).", + "restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)", + "restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.", + "confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).", + "confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.", + "confirmApply": "Сейчас будет выполнено: fast-forward {{remote}}/{{branch}} до {{target}} ({{n}} коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.", + "applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.", + "localChangesTitle": "Локальные изменения", + "localChangesHint": "Файлы, помеченные как локальное содержимое, сохраняются как есть; файлы, помеченные как исходный код продукта, блокируют автоматическое обновление.", + "noLocalChanges": "Рабочее дерево чистое: ни один отслеживаемый файл не изменён и не удалён.", + "colPath": "Файл", + "colChangeStatus": "Состояние", + "colOwner": "Владелец", + "keptFile": "Локальное содержимое (сохраняется при обновлении)", + "productSource": "Исходный код продукта (блокирует обновление)", + "noJob": "Обновление ещё не запускалось", + "noSteps": "Задача ещё не выдала ни одной строки о ходе выполнения", + "pollingHint": "Ход читается каждые {{sec}} секунд", + "stateRunning": "Выполняется", + "stateSucceeded": "Успешно", + "stateFailed": "Не удалось", + "stateUnknown": "Неизвестное состояние", + "restartRequested": "Запрошено завершение после выполнения", + "jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}", + "jobDoneToast": "Обновление завершено", + "jobFailedToast": "Обновление не удалось", + "needsRestartToast": "Бинарный файл заменён; он вступит в силу после перезапуска", + "failureNoMessage": "Сервер не передал сообщение об ошибке", + "resultTitle": "Результат обновления", + "resultFrom": "От", + "resultTo": "До", + "resultCommits": "Принято коммитов", + "resultFiles": "Затронуто файлов", + "resultDuration": "Длительность", + "resultBackup": "Каталог резервных копий", + "keptTitle": "Сохранённые файлы локального содержимого", + "nothingKept": "Ни один файл локального содержимого не пришлось откладывать", + "binarySwapped": "Бинарный файл заменён", + "binaryNotSwapped": "Бинарный файл не изменён", + "restartNeeded": "Для запуска новой версии нужен перезапуск", + "restartNotNeeded": "Перезапуск не требуется", + "failureTitle": "Почему не удалось", + "noRollback": "Точки отката нет: она появляется только после выполненного обновления", + "rollbackTarget": "Цель отката: {{commit}} (где это дерево было до последнего обновления)", + "rollbackBtn": "Откатить обновление", + "confirmRollback": "Откатиться до {{commit}}? Исходники вернутся к состоянию до того обновления, а прежний бинарный файл будет восстановлен.", + "rollbackDone": "Откат выполнен до {{commit}}", + "rollbackItems": "Файлы, которые не были откачены" + }, "nav": { + "systemUpdate": "Обновление в один клик", "security": "Безопасность", "toolGuard": "Блокировка вызовов", "dashboard": "Обзор", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index aa613ef5..6128c48a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -79,7 +79,102 @@ "passwordPlaceholder": "输入登录密码", "submit": "登录" }, + "update": { + "title": "一键更新", + "intro": "这一页改的就是当前正在应答请求的这套安装:拉取它自己跟踪的远端、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。", + "loading": "正在读取本机安装状态", + "loadFailed": "读取本机安装状态失败:{{reason}}", + "installTitle": "本机安装", + "remoteTitle": "与远端比较", + "applyTitle": "一键更新", + "jobTitle": "更新进度", + "rollbackTitle": "回滚", + "root": "安装目录", + "branch": "分支", + "remote": "远端", + "commit": "当前提交", + "committedAt": "提交时间", + "subject": "提交说明", + "gitTree": "git 工作树", + "notGitTree": "不是 git 工作树", + "toolchainFound": "有 Go 工具链", + "toolchainMissing": "没有 Go 工具链", + "toolchainMissingHint": "未检测到 go 命令:更新只能挪源码,编译要另找机器", + "binaryPresent": "本机有二进制", + "binaryMissing": "本机没有二进制", + "rollbackPresent": "有可回滚点", + "rollbackMissing": "无可回滚点", + "notChecked": "还没查过远端。进入本页后会自动查一次(每个会话只查一次);也可以随时点「检查更新」。", + "checkBtn": "检查更新", + "checking": "正在检查远端…", + "checkFailed": "检查失败:{{reason}}", + "applyFailed": "启动更新失败:{{reason}}", + "rollbackFailed": "回滚失败:{{reason}}", + "behindCount": "落后 {{n}} 个提交", + "aheadCount": "本地领先 {{n}} 个提交", + "upToDate": "已是最新:本机 {{commit}} 已与远端一致", + "remoteHead": "远端最新提交", + "remoteSubject": "远端提交说明", + "incomingTitle": "待并入的提交", + "incomingTruncated": "共 {{total}} 个新提交,这里只列出前 {{shown}} 个", + "applyBtn": "一键更新", + "jobRunningHint": "已有一次更新在进行中,下面是它的进度。", + "noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。", + "blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。", + "blockedJobRunning": "已有更新在进行中,请等它结束。", + "blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。", + "blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。", + "blockedNoUpdate": "远端没有比本机更新的提交(还没查过就先点「检查更新」)。", + "restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)", + "restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。", + "confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。", + "confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。", + "confirmApply": "将要执行:把 {{remote}}/{{branch}} 快进到 {{target}}({{n}} 个提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。", + "applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。", + "localChangesTitle": "本机改动", + "localChangesHint": "标为「本机内容」的文件更新后原样保留;标为「产品源码」的文件会阻止自动更新。", + "noLocalChanges": "工作树干净:没有被修改或被删除的受跟踪文件。", + "colPath": "文件", + "colChangeStatus": "状态", + "colOwner": "归属", + "keptFile": "本机内容(更新保留)", + "productSource": "产品源码(会阻止更新)", + "noJob": "还没有更新记录", + "noSteps": "任务刚起来,还没有进度输出", + "pollingHint": "每 {{sec}} 秒读取一次进度", + "stateRunning": "进行中", + "stateSucceeded": "已完成", + "stateFailed": "失败", + "stateUnknown": "未知状态", + "restartRequested": "已要求在完成后退出", + "jobPollFailed": "进度读取失败:{{reason}}", + "jobDoneToast": "更新完成", + "jobFailedToast": "更新失败", + "needsRestartToast": "二进制已换新,重启后才会生效", + "failureNoMessage": "服务端没有给出失败说明", + "resultTitle": "更新结果", + "resultFrom": "从", + "resultTo": "到", + "resultCommits": "并入提交数", + "resultFiles": "改动文件数", + "resultDuration": "用时", + "resultBackup": "备份目录", + "keptTitle": "保留下来的本机内容文件", + "nothingKept": "没有本机内容文件被挪动过", + "binarySwapped": "二进制已换新", + "binaryNotSwapped": "二进制未更换", + "restartNeeded": "需要重启才会运行新版本", + "restartNotNeeded": "不需要重启", + "failureTitle": "失败原因", + "noRollback": "没有可回滚点:更新过一次之后才会有", + "rollbackTarget": "回滚目标:{{commit}}(上一次更新之前的提交)", + "rollbackBtn": "回滚到更新前", + "confirmRollback": "确定回滚到 {{commit}} 吗?源码回到那次更新之前,二进制用回备份的那一份。", + "rollbackDone": "已回滚到 {{commit}}", + "rollbackItems": "未能回滚的文件" + }, "nav": { + "systemUpdate": "一键更新", "security": "安全防护", "toolGuard": "调用拦截", "dashboard": "仪表盘", diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 5298232c..229a8b6e 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -443,6 +443,7 @@ const PAGE_PERMISSION_MAP = { roles: 'roles:read', 'roles-management': 'roles:read', 'platform-rbac': 'rbac:read', + 'system-update': 'update:read', settings: 'config:read', }; @@ -519,11 +520,18 @@ function applyPermissionElement(el) { const allowed = permissionAllowedForElement(el); el.hidden = !allowed; el.classList.toggle('rbac-permission-denied', !allowed); + // Permission decides whether this user may ever use the control; it does not decide + // whether the control is ready. A page that disables one for a reason of its own marks + // it with data-state-disabled, because writing el.disabled = !allowed here otherwise + // re-enables it for exactly the users who are allowed - and a button whose preconditions + // are unmet is then pressed, and fails. + const blockedByState = el.dataset && el.dataset.stateDisabled === 'true'; + const disabled = !allowed || blockedByState; if ('disabled' in el) { - el.disabled = !allowed; + el.disabled = disabled; } el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); - el.setAttribute('aria-disabled', allowed ? 'false' : 'true'); + el.setAttribute('aria-disabled', disabled ? 'true' : 'false'); } let permissionClickGuardInstalled = false; diff --git a/web/static/js/permission-gate-state.test.cjs b/web/static/js/permission-gate-state.test.cjs new file mode 100644 index 00000000..8e72d998 --- /dev/null +++ b/web/static/js/permission-gate-state.test.cjs @@ -0,0 +1,133 @@ +// The platform's permission gate must not re-enable a control a page disabled for a reason +// of its own. +// +// How the bug looked in a real browser: the 一键更新 page rendered an *enabled* apply button +// directly under the text "有 53 个产品源码文件被本地改过,自动更新不会覆盖它们". Nothing in +// the page's own tests could see it, because the page really does write `disabled` - the +// gate then runs over every [data-require-permission] element afterwards and writes +// `el.disabled = !allowed`, which for an administrator is `false`. Permission says whether a +// user may ever use a control; it does not say whether the control is ready. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const AUTH = fs.readFileSync(path.join(__dirname, 'auth.js'), 'utf8'); +const UPDATE = fs.readFileSync(path.join(__dirname, 'update.js'), 'utf8'); + +function sourceOf(name, src) { + const at = src.indexOf('function ' + name + '('); + assert.ok(at >= 0, `${name} is not in auth.js any more - this test is where the gate lives`); + let depth = 0; + for (let i = src.indexOf('{', at); i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) return src.slice(at, i + 1); + } + } + assert.fail(`${name}: unbalanced`); +} + +function element(attrs = {}) { + const el = { + hidden: false, + disabled: false, + dataset: {}, + _attrs: { ...attrs }, + classList: { classes: new Set(), toggle(c, on) { on ? this.classes.add(c) : this.classes.delete(c); } }, + getAttribute(k) { return this._attrs[k] ?? null; }, + setAttribute(k, v) { this._attrs[k] = String(v); }, + }; + if (attrs['data-state-disabled']) el.dataset.stateDisabled = attrs['data-state-disabled']; + // Buttons in a DOM have a `disabled` property; a plain object only gets one if the page + // asked for it, and the gate's `if ('disabled' in el)` is what decides whether state + // survives - so it is the part worth keeping honest. + return el; +} + +function makeGate(granted) { + const sandbox = { + hasPermission: (p) => granted.has(p), + hasAnyPermission: (list) => list.some((p) => granted.has(p)), + console, + }; + vm.createContext(sandbox); + vm.runInContext(sourceOf('permissionAllowedForElement', AUTH) + '\n' + sourceOf('applyPermissionElement', AUTH), sandbox); + return sandbox.applyPermissionElement; +} + +test('a state-disabled control stays disabled for a user who holds the permission', () => { + const gate = makeGate(new Set(['update:apply'])); + const el = element({ 'data-require-permission': 'update:apply', 'data-state-disabled': 'true' }); + el.disabled = true; + gate(el); + assert.strictEqual(el.disabled, true, 'permission granted must not re-enable a control the page blocked'); + assert.strictEqual(el.getAttribute('aria-disabled'), 'true', 'the assistive state must agree with the real one'); + assert.strictEqual(el.hidden, false); +}); + +test('a permitted and ready control is enabled, and an unpermitted one is not', () => { + const gate = makeGate(new Set(['update:apply'])); + const ready = element({ 'data-require-permission': 'update:apply' }); + gate(ready); + assert.strictEqual(ready.disabled, false); + assert.strictEqual(ready.getAttribute('aria-disabled'), 'false'); + + // applyPermissionElement returns nothing, so the assertions read the element itself. + const deniedEl = element({ 'data-require-permission': 'update:apply' }); + makeGate(new Set())(deniedEl); + assert.strictEqual(deniedEl.hidden, true); + assert.strictEqual(deniedEl.disabled, true); + assert.strictEqual(deniedEl.classList.classes.has('rbac-permission-denied'), true); +}); + +test('every control the update page disables by state carries the marker the gate honors', () => { + // The other half of the pair: a page that writes `disabled` without the marker silently + // loses it the moment the gate runs. Asserted over the rendered strings rather than by + // reading the source, so a refactor that drops the attribute is what fails. + const sandbox = { + console, + escapeHtml: (v) => String(v), + updateT: (key) => key, + renderUpdateRestartChoice: () => '', + renderUpdateChangesTable: () => '
', + isUpdateJobRunning: () => true, + updateApplyBlockers: (status) => (status.blockingChanges || []).length ? ['blocked'] : [], + updateChecking: true, + }; + vm.createContext(sandbox); + for (const fn of ['renderUpdateApplyBody', 'renderUpdateRollbackBody']) { + vm.runInContext(sourceOf(fn, UPDATE), sandbox); + } + const withBlocker = sandbox.renderUpdateApplyBody( + { installed: true, canBuild: true, blockingChanges: [{ path: 'a.go' }], updateAvailable: true, diverged: false }, null); + const noBlocker = sandbox.renderUpdateApplyBody( + { installed: true, canBuild: true, blockingChanges: [], updateAvailable: true, diverged: false }, null); + const rollbackRunning = sandbox.renderUpdateRollbackBody({ hasRollback: true, rollbackTo: 'abc' }); + + for (const [label, html] of [['apply(blocked)', withBlocker], ['rollback(running)', rollbackRunning]]) { + const buttons = html.match(/]*>/g) || []; + assert.ok(buttons.length > 0, `${label}: rendered no button`); + const disabledTags = buttons.filter((t) => / disabled/.test(t)); + assert.ok(disabledTags.length > 0, `${label}: expected a disabled button`); + for (const tag of disabledTags) { + assert.match(tag, /data-state-disabled="true"/, `${label}: state-disabled must carry the marker the gate reads: ${tag}`); + assert.match(tag, /data-require-permission=/, `${label}: the marker only matters on a permission-gated control: ${tag}`); + } + } + + // And the *apply* control of an unblocked install must not advertise itself as blocked, + // or the gate would keep it dead for someone who can legitimately press it. Other buttons + // in the same render may still be state-disabled (checking is one), which is why this + // asserts about the apply button by name. + const applyTag = (noBlocker.match(/]*update-apply-btn[^>]*>/) || [])[0]; + assert.ok(applyTag, 'no apply button rendered when nothing blocks it'); + assert.ok(!/disabled/.test(applyTag), `a ready apply control must not be disabled: ${applyTag}`); + for (const tag of (noBlocker.match(/]*>/g) || [])) { + if (/ disabled/.test(tag)) { + assert.match(tag, /data-state-disabled="true"/, `every state-disabled control needs the marker: ${tag}`); + } + } +}); diff --git a/web/static/js/router-hash-form.test.cjs b/web/static/js/router-hash-form.test.cjs new file mode 100644 index 00000000..3e69ff29 --- /dev/null +++ b/web/static/js/router-hash-form.test.cjs @@ -0,0 +1,48 @@ +// The console's own hash form is `#system-update`, not `#/system-update` - and before this, +// typing or sharing the slashed form silently did nothing: the id missed the whitelist and +// the router stayed on whatever page had loaded, with no message anywhere. Since every bit of +// documentation (and every human habit) writes the slashed form, the router now normalizes it +// instead of rejecting it. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROUTER = fs.readFileSync(path.join(__dirname, 'router.js'), 'utf8'); + +test('both hash entry points strip a leading slash before the whitelist', () => { + // Two parsers exist (initial load and hash change); fixing only one is how this class of + // bug survives a "works for me" check. + const sites = [...ROUTER.matchAll(/let pageId = hashParts\[0\]([^\n]*)/g)]; + assert.ok(sites.length >= 2, `expected two hash parsers, found ${sites.length}`); + for (const s of sites) { + assert.match(s[1], /replace\(\/\^#\?\\\/\//, 'this parser does not normalize the slashed form: ' + s[0]); + } +}); + +test('the slashed form resolves to the same page id as the plain one', () => { + // Mirrors the router exactly: it takes location.hash.slice(1), so the '#' is already gone + // before the split. A helper here that expected the '#' would "pass" the wrong rule. + const normalize = (fullHash) => { + const raw = fullHash.startsWith('#') ? fullHash.slice(1) : fullHash; + return raw.split('?')[0].replace(/^#?\//, ''); + }; + for (const page of ['system-update', 'plugins-management', 'dashboard', 'chat']) { + assert.strictEqual(normalize('#' + page), page); + assert.strictEqual(normalize('#/' + page), page); + } + // Query-carrying hashes must keep working the same way they did. + assert.strictEqual(normalize('#chat?conversation=abc'), 'chat'); +}); + +test('the docs use a form the router actually accepts', () => { + const docs = ['../../../docs/zh-CN/developer-guide.md', '../../../docs/en-US/developer-guide.md']; + for (const rel of docs) { + const p = path.join(__dirname, rel); + const text = fs.readFileSync(p, 'utf8'); + // Not a style preference: `#/x` was a dead link when it was written, so a doc that + // tells an operator to open it is telling them to open nothing. + assert.ok(!/#\/system-update/.test(text), `${rel} still advertises the dead #/system-update form`); + assert.match(text, /#system-update/, `${rel} must name the form that works`); + } +}); diff --git a/web/static/js/router.js b/web/static/js/router.js index ed796f9f..3aeb92aa 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -108,9 +108,9 @@ function initRouter() { const hash = window.location.hash.slice(1); if (hash) { const hashParts = hash.split('?'); - let pageId = hashParts[0]; + let pageId = hashParts[0].replace(/^#?\//, ''); if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'system-update', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(0); @@ -524,6 +524,9 @@ async function initPage(pageId) { initChatFilesPage(); } break; + case 'system-update': + if (typeof loadUpdateConsole === 'function') loadUpdateConsole(); + break; case 'settings': // 初始化设置页面(不需要加载工具列表) if (typeof loadConfig === 'function') { @@ -605,6 +608,10 @@ async function initPage(pageId) { if (pageId !== 'tasks' && typeof cleanupTasksPage === 'function') { cleanupTasksPage(); } + // 一键更新的进度轮询同理:离开那一页就没必要每 1.5 秒读一次任务了。 + if (pageId !== 'system-update' && typeof stopUpdatePolling === 'function') { + stopUpdatePolling(); + } } // 页面加载完成后初始化路由 @@ -618,10 +625,10 @@ document.addEventListener('DOMContentLoaded', function() { const hash = window.location.hash.slice(1); // 处理带参数的hash(如 chat?conversation=xxx) const hashParts = hash.split('?'); - let pageId = hashParts[0]; + let pageId = hashParts[0].replace(/^#?\//, ''); if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'system-update', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(0); diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs new file mode 100644 index 00000000..172046c4 --- /dev/null +++ b/web/static/js/update-ui.test.cjs @@ -0,0 +1,984 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const source = fs.readFileSync('web/static/js/update.js', 'utf8'); +const template = fs.readFileSync('web/templates/index.html', 'utf8'); +const router = fs.readFileSync('web/static/js/router.js', 'utf8'); +const sheet = fs.readFileSync('web/static/css/style.css', 'utf8'); +const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8')); +const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8')); +const ru = JSON.parse(fs.readFileSync('web/static/i18n/ru-RU.json', 'utf8')); + +// The only handlers this console is allowed to wire into markup. +const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', + 'updateRestartChoiceChanged', 'loadUpdateConsole']; + +function flatKeys(obj, prefix, out) { + Object.keys(obj).forEach(k => { + const v = obj[k]; + const path = prefix ? prefix + '.' + k : k; + if (v && typeof v === 'object') flatKeys(v, path, out); + else out.add(path); + }); + return out; +} + +function resolve(dict, dotted) { + let node = dict; + for (const part of dotted.split('.')) { + if (!node || typeof node !== 'object') return undefined; + node = node[part]; + } + return typeof node === 'string' ? node : undefined; +} + +const baseStatus = { + root: '/srv/csai', + installed: true, + branch: 'main', + remote: 'mine', + commit: 'a1b2c3d', + subject: 'fix: 修好一个东西', + committedAt: '2026-09-30 18:04:11 +0800', + behind: 3, + ahead: 0, + diverged: false, + updateAvailable: true, + remoteCommit: 'f9e8d7c', + remoteSubject: 'feat: 新东西', + incoming: [{ commit: 'f9e8d7c', subject: 'feat: 新东西' }], + incomingTotal: 3, + localChanges: [], + blockingChanges: [], + goToolchain: 'go1.23.4', + canBuild: true, + hasBinary: true, + hasRollback: true, + rollbackTo: 'a1b2c3d', + checkError: '', +}; + +function statusOf(patch) { + return Object.assign({}, baseStatus, patch || {}); +} + +// The shape is the endpoint's own: {status, job, canRestart}. job is null until an update ran. +function envelope(status, job, canRestart) { + return { status: status || statusOf(), job: job === undefined ? null : job, canRestart: canRestart !== false }; +} + +const runningJob = { + id: 'upd-1', + state: 'running', + started: '2026-10-01T04:00:00+08:00', + steps: [{ phase: 'fetch', message: '开始拉取 mine/main', at: '2026-10-01T04:00:01+08:00' }], + restartRequested: false, +}; + +const succeededJob = { + id: 'upd-1', + state: 'succeeded', + started: '2026-10-01T04:00:00+08:00', + finished: '2026-10-01T04:03:12+08:00', + steps: [ + { phase: 'fetch', message: 'mine/main 有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' }, + { phase: 'protect', message: '已暂存 2 个本地内容文件', at: '04:00:05' }, + { phase: 'build', message: '开始编译二进制', at: '04:00:09' }, + { phase: 'done', message: '已更新 3 个提交并换好二进制', at: '04:03:10' }, + ], + result: { + fromCommit: 'a1b2c3d', + toCommit: 'f9e8d7c', + commits: 3, + filesTouched: 27, + keptContent: ['roles/我的角色.md', 'tools/semgrep.yaml'], + binaryPath: '/srv/csai/cyberstrike-ai', + binaryBuilt: true, + prevBinary: '/srv/csai/cyberstrike-ai.prev', + backupDir: '/srv/csai/.update-backup/20261001_040005', + needsRestart: true, + duration: '3m12s', + }, + restartRequested: true, +}; + +const failedJob = { + id: 'upd-2', + state: 'failed', + started: '2026-10-01T05:00:00+08:00', + finished: '2026-10-01T05:00:40+08:00', + steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '05:00:20' }], + failure: { + reason: 'build_failed', + message: '编译失败,二进制保持原版本:\ninternal/foo.go:12: undefined: bar', + items: ['roles/我的角色.md', 'internal/foo.go'], + }, + restartRequested: false, +}; + +// harness() loads the page for real (loadUpdateConsole) before handing it back, so every test +// starts from the markup that would be on screen and can execute the handlers that markup emits. +function harness(options) { + const opts = options || {}; + const nodes = new Map(); + const calls = []; + const toasts = []; + const confirms = []; + const timers = []; + let active = true; + let confirmAnswer = opts.confirm === false ? false : true; + let nextTimerId = 1; + + function makeEl(id) { + const el = { + id, + innerHTML: '', + checked: false, + insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; }, + }; + el.classList = { + _set: new Set(id === 'page-system-update' && active ? ['page', 'active'] : ['page']), + contains(c) { return this._set.has(c); }, + add(c) { this._set.add(c); }, + remove(c) { this._set.delete(c); }, + }; + return el; + } + + const responses = Object.assign({ + 'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart) }], + 'POST /api/system/update/check': [{ status: 200, body: { status: statusOf(opts.checkedStatus) } }], + 'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }], + 'GET /api/system/update/job': [{ status: 200, body: { job: opts.job || runningJob } }], + 'POST /api/system/update/rollback': [{ status: 200, body: { result: { fromCommit: 'f9e8d7c', toCommit: 'a1b2c3d' } } }], + }, opts.responses || {}); + + const sandbox = { + document: { + getElementById(id) { + if (!nodes.has(id)) nodes.set(id, makeEl(id)); + return nodes.get(id); + }, + createElement() { + const el = { textContent: '', innerHTML: '' }; + Object.defineProperty(el, 'textContent', { + set(v) { + el._t = String(v == null ? '' : v); + // The browser's textContent -> innerHTML leaves quotes alone; a harness that + // escaped them would make the attribute-injection assertions test nothing. + el.innerHTML = el._t.replace(/&/g, '&').replace(//g, '>'); + }, + get() { return el._t; }, + }); + return el; + }, + querySelectorAll() { return []; }, + }, + console, + Promise, + JSON, + Set, + Object, + String, + Date, + Math, + escapeHtml(text) { + const div = sandbox.document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + escapeAttr(text) { + return sandbox.escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); + }, + showNotification(msg, type) { toasts.push({ msg, type }); }, + setInterval(fn, ms) { + const t = { id: nextTimerId++, fn, ms, cleared: false }; + timers.push(t); + return t.id; + }, + clearInterval(id) { + const t = timers.find(x => x.id === id); + if (t) t.cleared = true; + }, + // The page schedules one deferred remote check; a harness without setTimeout would make + // that code path untestable rather than absent, so the browser global is provided and the + // timer is recorded instead of fired. + setTimeout(fn, ms) { + const t = { id: nextTimerId++, fn, ms, cleared: false, deferred: true }; + timers.push(t); + return t.id; + }, + clearTimeout(id) { + const t = timers.find(x => x.id === id); + if (t) t.cleared = true; + }, + window: { + // Resolved against the same zh-CN.json the browser loads, with {{var}} interpolated the + // way i18next does. A key the dictionary does not carry comes back as the raw key, so a + // missing translation fails the copy assertions instead of quietly painting a key name. + t(key, o) { + const found = resolve(zh, key); + if (found === undefined) return key; + return found.replace(/\{\{(\w+)\}\}/g, (m, name) => (o && name in o ? String(o[name]) : m)); + }, + showNotification(msg, type) { toasts.push({ msg, type }); }, + confirm(message) { confirms.push(message); return confirmAnswer; }, + applyRBACToUI() {}, + }, + apiFetch(url, reqOpts = {}) { + const method = reqOpts.method || 'GET'; + calls.push({ url, method, body: reqOpts.body }); + if ((opts.rejects || []).includes(method + ' ' + url)) { + // Not a status code: the request itself dies (401 handling, a dropped socket), + // which is the path that leaves a page stuck on a spinner if it is not unwound. + return Promise.reject(new Error('network unreachable')); + } + const queue = responses[method + ' ' + url]; + const entry = queue && queue.length ? (queue.length > 1 ? queue.shift() : queue[0]) : null; + if (!entry) { + return Promise.resolve({ + ok: false, status: 500, + json: () => Promise.resolve({ error: 'no fixture for ' + method + ' ' + url }), + }); + } + const status = entry.status === undefined ? 200 : entry.status; + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(entry.body), + }); + }, + }; + sandbox.window.document = sandbox.document; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + vm.runInContext(` + this.api = { + loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate, + updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling + }; + this.vars = { + setBusy(v) { updateBusy = v; }, + getRestartChoice() { return updateRestartChoice; }, + getPollTimer() { return updatePollTimer; }, + getCheckDone() { return updateCheck.done; }, + autoCheckDone() { return updateAutoCheckDone; } + }; + `, sandbox); + + const flush = async () => { for (let i = 0; i < 14; i++) await new Promise(r => setImmediate(r)); }; + + // Running the attribute text inside the page's own context is the point of this harness: a + // handler wired to the wrong function, or reading the wrong element, still looks perfectly + // fine when the attribute is only ever inspected as a string. + function runHandler(text, thisArg) { + const fn = vm.runInContext('(function(){' + text + '})', sandbox); + return fn.call(thisArg || {}); + } + + function extractHandlers(html) { + const found = { onclick: [], onchange: [] }; + const re = /on(click|change)="([^"]*)"/g; + let m; + while ((m = re.exec(html)) !== null) found['on' + m[1]].push(m[2]); + return found; + } + + const h = { + sandbox, nodes, calls, toasts, confirms, timers, flush, runHandler, extractHandlers, responses, + api: sandbox.api, + vars: sandbox.vars, + // The deferred auto-check timer is inspected from this side: `timers` is the harness's own + // array, not something the page's context can see. + deferredTimers() { return timers.filter(t => t.deferred && !t.cleared).map(t => t.ms); }, + fireDeferred() { timers.filter(t => t.deferred && !t.cleared).forEach(t => { t.cleared = true; t.fn(); }); }, + html() { return nodes.get('update-console').innerHTML; }, + fire(name, thisArg) { + const all = extractHandlers(h.html()); + const text = all.onclick.concat(all.onchange).find(v => v.startsWith(name + '(')); + if (!text) throw new Error('handler ' + name + ' is not present in the rendered markup'); + return runHandler(text, thisArg); + }, + // Flipping the router's page-active class is all that leaving a page means for this console. + setActive(v) { + active = v; + const node = nodes.get('page-system-update'); + if (v) node.classList.add('active'); else node.classList.remove('active'); + }, + setConfirm(v) { confirmAnswer = v; }, + liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); }, + tick() { + const live = h.liveTimers(); + if (!live.length) throw new Error('no poll timer is armed'); + return live[live.length - 1].fn(); + }, + }; + + return Promise.resolve(sandbox.api.loadUpdateConsole()).then(flush).then(() => h); +} + +test('the page is wired in everywhere a page has to be registered', () => { + assert.match(template, /