From cb8bc6b82431a755c15612ae904c2d592adaeb74 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:07:26 +0800 Subject: [PATCH] feat: update this installation's own source in one action An operator running a fork has no honest way to upgrade: upgrade.sh downloads the Release tarball of one hard-coded third-party repository and rsyncs it over the tree, so "upgrade" means "overwrite yourself with somebody else's code", and the safe alternative is to pull by hand and remember the build command. internal/update drives the remote the directory already tracks: fetch, fast-forward, go build into .update-staging, atomic rename of the binary (the previous one kept as cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is not guessed at. Refusals are the point - local source edits and a diverged branch stop with the file names and commit counts in the error rather than being reset to match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put aside before the merge and restored over it, with what was kept listed in keptContent instead of quietly dropped. Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations are process-global, so they need a global-scope session); a console page at #system-update with an apply button that names why it is blocked, a job polled from the server and a rollback control; the same three actions as -check-update / -update / -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission gating no longer re-enables a control a page disabled for a reason of its own (data-state-disabled), which every state-disabled, permission-gated button in the console was subject to. upgrade.sh becomes a thin shell over the same implementation and only falls back to the tarball path for installs that are not git work trees. Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against real temporary git repositories; the HTTP layer, RBAC and the console page each carry their own contract tests. --- .gitignore | 6 + README.md | 24 +- README_CN.md | 18 +- cmd/server/main.go | 7 + cmd/server/update_cli.go | 106 ++ docs/en-US/deployment.md | 56 ++ docs/en-US/developer-guide.md | 76 ++ docs/en-US/rbac.md | 7 + docs/zh-CN/deployment.md | 29 +- docs/zh-CN/developer-guide.md | 60 ++ docs/zh-CN/rbac.md | 4 + internal/app/app.go | 18 + internal/handler/openapi.go | 104 ++ internal/handler/update.go | 327 ++++++ internal/handler/update_http_test.go | 168 ++++ internal/security/rbac.go | 2 + internal/security/rbac_middleware.go | 12 + internal/security/rbac_middleware_test.go | 27 + internal/update/apply.go | 385 ++++++++ internal/update/build.go | 147 +++ internal/update/update.go | 398 ++++++++ internal/update/update_test.go | 409 ++++++++ upgrade.sh | 509 ++++++++-- web/static/css/style.css | 290 ++++++ web/static/i18n/en-US.json | 95 ++ web/static/i18n/ru-RU.json | 95 ++ web/static/i18n/zh-CN.json | 95 ++ web/static/js/auth.js | 12 +- web/static/js/permission-gate-state.test.cjs | 133 +++ web/static/js/router-hash-form.test.cjs | 48 + web/static/js/router.js | 15 +- web/static/js/update-ui.test.cjs | 984 +++++++++++++++++++ web/static/js/update.js | 648 ++++++++++++ web/templates/index.html | 23 + 34 files changed, 5225 insertions(+), 112 deletions(-) create mode 100644 cmd/server/update_cli.go create mode 100644 internal/handler/update.go create mode 100644 internal/handler/update_http_test.go create mode 100644 internal/update/apply.go create mode 100644 internal/update/build.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go mode change 100644 => 100755 upgrade.sh create mode 100644 web/static/js/permission-gate-state.test.cjs create mode 100644 web/static/js/router-hash-form.test.cjs create mode 100644 web/static/js/update-ui.test.cjs create mode 100644 web/static/js/update.js 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, /