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.
This commit is contained in:
Sycun committed 2026-10-07 12:07:26 +08:00
1 parent d2084a9481
commit cb8bc6b824
34 files changed
+5225 -112

No files matched your search

+6
View File
@@ -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
+19 -5
View File
@@ -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`.
+14 -4
View File
@@ -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`。
+7
View File
@@ -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")
+106
View File
@@ -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)
}
}
+56
View File
@@ -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/<timestamp>/`.
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 |
+76
View File
@@ -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/<timestamp>/` 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`
+7
View File
@@ -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.
---
+27 -2
View File
@@ -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`。
+60
View File
@@ -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。新增表或字段时:
+4
View File
@@ -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`)。
- 部分全局监控统计。
---
+18
View File
@@ -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)
+104
View File
@@ -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": "未授权",
},
},
},
},
},
}
+327
View File
@@ -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
}
+168
View File
@@ -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"])
}
}
+2
View File
@@ -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",
+12
View File
@@ -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
+27
View File
@@ -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)
}
}
}
+385
View File
@@ -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
}
+147
View File
@@ -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)
}
+398
View File
@@ -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
}
+409
View File
@@ -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))
}
Regular → Executable
+414 -95
View File
@@ -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 <<EOF
cat <<'EOF'
Usage:
./upgrade.sh [--tag vX.Y.Z] [--no-venv] [--no-stop]
[--force-stop] [--yes]
./upgrade.sh [--check] [--yes] [--no-venv] [--no-stop] [--force-stop]
[--tag vX.Y.Z] [--repo <owner/name>]
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 <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 <owner/name> 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 <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/<timestamp>/ + .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 "$@"
+290
View File
@@ -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;
}
+95
View File
@@ -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",
+95
View File
@@ -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": "Обзор",
+95
View File
@@ -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": "仪表盘",
+10 -2
View File
@@ -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;
@@ -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: () => '<div class="update-changes"></div>',
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(/<button[^>]*>/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(/<button[^>]*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(/<button[^>]*>/g) || [])) {
if (/ disabled/.test(tag)) {
assert.match(tag, /data-state-disabled="true"/, `every state-disabled control needs the marker: ${tag}`);
}
}
});
+48
View File
@@ -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`);
}
});
+11 -4
View File
@@ -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);
+984
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
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, '&quot;').replace(/'/g, '&#39;');
},
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, /<div class="nav-item" data-page="system-update">/);
assert.match(template, /<div id="page-system-update" class="page">/);
assert.match(template, /id="update-console"/);
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}"><\/script>/);
const group = template.indexOf('<span data-i18n="navGroups.administration">平台管理</span>');
const rbac = template.indexOf('<div class="nav-item" data-page="platform-rbac">');
const update = template.indexOf('<div class="nav-item" data-page="system-update">');
const settings = template.indexOf('<div class="nav-item" data-page="settings">');
assert.ok(group > -1 && rbac > group && update > rbac && settings > update,
'the nav item must sit in 平台管理, after 平台权限 and before 系统设置');
assert.match(template, /data-page="system-update"[\s\S]{0,400}?onclick="switchPage\('system-update'\)"/);
assert.match(template, /<div class="nav-item-content" data-title="一键更新" onclick="switchPage\('system-update'\)" data-i18n="nav\.systemUpdate" data-i18n-attr="data-title"/);
assert.match(template, /<div class="page-header">\s*\n\s*<h2 data-i18n="update\.title">/);
// Registered in the router twice on purpose: the hash whitelist on boot, and the one used when
// a page builds its own hash. A page missing from either is silently unreachable.
const lists = router.split('\n').filter(l => l.includes("['dashboard'") && l.includes("'system-update'"));
assert.equal(lists.length, 2, 'system-update must appear in both router page lists');
assert.match(router, /case 'system-update':\s*\n\s*if \(typeof loadUpdateConsole === 'function'\) loadUpdateConsole\(\);/);
// And the leave-page cleanup: the router only calls initPage on entry, so the progress poller
// has to be stopped from the same tail that already does this for the tasks page.
assert.match(router, /if \(pageId !== 'system-update' && typeof stopUpdatePolling === 'function'\)/);
});
test('the update console has its own stylesheet section', () => {
const header = sheet.indexOf('一键更新控制台(system-update)');
assert.ok(header > -1, 'style.css must carry the update console section header');
const block = sheet.slice(header);
assert.ok(block.includes('.update-console'), 'the .update-* section must follow its own section header');
['.update-console', '.update-section-title', '.update-facts', '.update-chip-ok', '.update-chip-warn',
'.update-chip-danger', '.update-error', '.update-check-failed', '.update-step-list', '.update-table',
'.update-restart-choice', '.update-blocker-list', '.update-apply-btn:disabled'].forEach(sel => {
assert.ok(sheet.includes(sel), sel + ' must be defined');
});
// Every class the console emits has to be styled or be an existing shared one: a hook name
// that appears only in JS is a button nobody can see the state of.
const emitted = [...new Set(source.match(/update-[a-z][a-z-]*/g) || [])]
.filter(t => /^update-[a-z-]+$/.test(t));
emitted.forEach(cls => {
assert.ok(sheet.includes('.' + cls), cls + ' is emitted by the console but style.css never mentions it');
});
assert.ok(emitted.length >= 25, 'the class scan found too little to be a gate: ' + emitted.length);
});
test('all locales carry the same update keys, and everything the page asks for exists', () => {
const zhKeys = flatKeys(zh.update, '', new Set());
const enKeys = flatKeys(en.update, '', new Set());
const ruKeys = flatKeys(ru.update, '', new Set());
assert.deepEqual([...zhKeys].filter(k => !enKeys.has(k)), [], 'keys missing from en-US');
assert.deepEqual([...enKeys].filter(k => !zhKeys.has(k)), [], 'keys missing from zh-CN');
assert.deepEqual([...enKeys].filter(k => !ruKeys.has(k)), [], 'keys missing from ru-RU');
assert.deepEqual([...ruKeys].filter(k => !enKeys.has(k)), [], 'ru-RU has keys other locales do not');
assert.ok(zhKeys.size >= 60, 'the update namespace has gone thin: ' + zhKeys.size);
assert.ok(zh.nav.systemUpdate && en.nav.systemUpdate && ru.nav.systemUpdate, 'nav.systemUpdate must exist in every locale');
assert.equal(zh.nav.systemUpdate, '一键更新');
assert.equal(en.nav.systemUpdate, 'One-Click Update');
assert.equal(ru.nav.systemUpdate, 'Обновление в один клик');
// The non-Chinese dictionaries must not be holding Chinese text.
[...enKeys].forEach(k => {
const value = resolve(en.update, k) || '';
assert.ok(!/[一-龥]/.test(value), 'en-US still has Chinese in update.' + k + ': ' + value);
assert.ok(value !== zh.update[k], 'en-US still has the Chinese string for update.' + k);
});
[...ruKeys].forEach(k => {
const value = resolve(ru.update, k) || '';
assert.ok(!/[一-龥]/.test(value), 'ru-RU still has Chinese in update.' + k + ': ' + value);
assert.ok(value !== zh.update[k], 'ru-RU still has the Chinese string for update.' + k);
});
const htmlKeys = [...template.matchAll(/data-i18n="([^"]+)"/g)].map(m => m[1])
.filter(k => k.startsWith('update.') || k === 'nav.systemUpdate');
assert.ok(htmlKeys.length >= 3, 'the page must carry its own translated titles: ' + htmlKeys);
htmlKeys.forEach(k => {
assert.ok(resolve(zh, k), 'zh-CN missing ' + k);
assert.ok(resolve(en, k), 'en-US missing ' + k);
assert.ok(resolve(ru, k), 'ru-RU missing ' + k);
});
const jsKeys = [...source.matchAll(/updateT\('([^']+)'/g)].map(m => m[1]);
assert.ok(jsKeys.length >= 40, 'the console should be fully translated, got ' + jsKeys.length);
jsKeys.forEach(k => {
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
});
// withUpdateBusy picks its error prefix out of a map, so those keys are referenced dynamically
// and a typo would survive as a painted key name instead of failing here.
const mapBody = (source.match(/const UPDATE_FAILURE_KEYS = \{([\s\S]*?)\n\};/) || [])[1] || '';
const mapped = [...mapBody.matchAll(/'([a-zA-Z]+)'/g)].map(m => m[1]);
assert.ok(mapped.length >= 4, 'the failure-key map was not found in update.js');
mapped.forEach(k => {
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
});
// No key may be dead weight: every entry has to be reachable from the page or the console.
[...zhKeys].forEach(k => {
const used = source.includes("'" + k.split('.').pop() + "'") || template.includes('update.' + k);
assert.ok(used, 'update.' + k + ' is defined but nothing asks for it');
});
});
test('opening the page reads the install tree and claims nothing about the remote', async () => {
const h = await harness({ status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }) });
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update'],
'opening the page must not fetch the remote and must not start polling');
const html = h.html();
assert.match(html, /\/srv\/csai/);
assert.match(html, /main/);
assert.match(html, /a1b2c3d/);
assert.match(html, /2026-09-30 18:04:11/);
assert.match(html, /git 工作树/);
assert.match(html, /go1\.23\.4/);
assert.match(html, /有可回滚点/);
assert.match(html, /待并入|本机安装/, 'the console must be laid out in labelled sections');
// behind is 0 only because nobody looked yet: that is "not checked", never "up to date".
assert.match(html, /还没查过远端/);
assert.doesNotMatch(html, /已是最新/, 'a page that did not check must not say 已是最新');
assert.doesNotMatch(html, /update\.[a-zA-Z]/, 'a raw i18n key leaked into the markup');
assert.doesNotMatch(html, /\{\{/, 'an un-interpolated placeholder leaked into the markup');
});
test('a check that could not run is reported as a failure, never as "already up to date"', async () => {
const h = await harness({
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
responses: {
'POST /api/system/update/check': [{
status: 200,
body: {
status: statusOf({
checkError: 'fatal: unable to access github: Could not resolve host',
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
}),
},
}],
},
});
h.calls.length = 0;
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/check'],
'a check must not re-read the tree and must not start a poller');
const html = h.html();
assert.match(html, /检查失败:/, 'the reason must be stated as a check failure');
assert.match(html, /Could not resolve host/);
assert.doesNotMatch(html, /已是最新/, 'a failed check must never be painted as up to date');
assert.equal(h.toasts.length, 1);
assert.match(h.toasts[0].msg, /检查失败:/);
assert.equal(h.toasts[0].type, 'error', 'a check that failed must not toast success');
assert.equal(h.liveTimers().length, 0, 'a check is not a job: nothing may be polling');
});
test('a transport failure of the check endpoint is still "查不了"', async () => {
const h = await harness({
responses: { 'POST /api/system/update/check': [{ status: 500, body: { error: 'git exited 128' } }] },
});
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /检查失败:/);
assert.match(html, /git exited 128/);
assert.doesNotMatch(html, /已是最新/);
assert.match(h.toasts[h.toasts.length - 1].msg, /检查失败:git exited 128/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
});
test('a check whose request dies mid-flight releases the button and says so', async () => {
const h = await harness({ rejects: ['POST /api/system/update/check'] });
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /检查失败:/, 'a dead request is a failure, and it has to be called one');
assert.match(html, /network unreachable/);
assert.doesNotMatch(html, /正在检查远端/, 'the button must not stay frozen on "checking"');
assert.doesNotMatch(html, /update-check-btn" disabled/, 'and it must be clickable again');
assert.doesNotMatch(html, /已是最新/, 'a request that went nowhere is not an answer about the remote');
assert.match(html, /还没查过远端/, 'the page falls back to the honest "nobody looked yet"');
assert.equal(h.toasts.length, 1);
assert.equal(h.toasts[0].type, 'error');
assert.equal(h.liveTimers().length, 0);
});
test('behind is counted from incomingTotal and a truncated list says so', async () => {
const many = [];
for (let i = 0; i < 20; i++) many.push({ commit: 'c' + i, subject: '提交 ' + i });
const h = await harness({
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
responses: {
'POST /api/system/update/check': [{
status: 200,
body: { status: statusOf({ updateAvailable: true, behind: 42, incomingTotal: 42, incoming: many }) },
}],
},
});
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /落后 42 个提交/, 'the headline number is the total, not the length of the page');
assert.match(html, /共 42 个新提交/);
assert.match(html, /只列出前 20 个/);
assert.match(html, /提交 19/);
});
test('local edits, a diverged tree and a clean-but-unchecked tree each block the button', async () => {
const cases = [
{
patch: { blockingChanges: [{ path: 'internal/foo.go', status: 'modified', protected: false }] },
copy: /有 1 个产品源码文件被本地改过/,
path: /internal\/foo\.go/,
},
{
patch: { diverged: true, ahead: 4, behind: 2, blockingChanges: [] },
copy: /本地领先 4 个提交/,
path: null,
},
{
patch: { updateAvailable: false, behind: 0, blockingChanges: [] },
copy: /远端没有比本机更新的提交/,
path: null,
},
];
for (const c of cases) {
const h = await harness({ status: statusOf(c.patch) });
const html = h.html();
assert.match(html, /<button class="btn-primary update-apply-btn" disabled[^>]*onclick="startUpdateApply\(\)">/,
'the apply button must be disabled for: ' + c.copy);
assert.match(html, c.copy, 'a disabled button must say why');
assert.match(html, /data-require-permission="update:apply"/);
if (c.path) assert.match(html, c.path, 'the blocking file names must be listed, not just counted');
// Disabled in the markup and refused in code: the two must agree.
h.calls.length = 0;
h.toasts.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.deepEqual(h.calls, [], 'a blocked apply must never reach the server: ' + c.copy);
assert.equal(h.toasts.length, 1, 'the refusal must be said out loud instead of failing silent');
assert.equal(h.toasts[0].type, 'error');
}
const busy = await harness({ status: statusOf(), job: runningJob });
assert.match(busy.html(), /<button class="btn-primary update-apply-btn" disabled/);
assert.match(busy.html(), /已有更新在进行中/);
assert.match(busy.html(), /<button class="btn-secondary update-rollback-btn" disabled/);
assert.deepEqual(busy.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update']);
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job resumes the progress read');
});
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
const h = await harness({
responses: {
'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }],
'GET /api/system/update/job': [
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: succeededJob } },
],
'GET /api/system/update': [
{ status: 200, body: envelope(statusOf(), null, true) },
{
status: 200,
body: envelope(statusOf({
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
commit: 'f9e8d7c',
}), succeededJob, true),
},
],
},
});
assert.match(h.html(), /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/,
'a clean tree that is behind must offer the button, undimmed');
h.calls.length = 0;
h.fire('startUpdateApply');
await h.flush();
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
assert.ok(apply, 'no apply was posted: ' + h.calls.map(c => c.method + ' ' + c.url));
assert.deepEqual(JSON.parse(apply.body), { restart: false },
'the body is one object carrying a restart boolean, not a positional bag');
assert.equal(h.liveTimers().length, 1, 'exactly one poller: ' + JSON.stringify(h.timers.map(t => t.ms)));
assert.equal(h.liveTimers()[0].ms, 1500, 'progress must be read every 1.5 seconds');
// In the browser the poller runs off the timer; here the test drives the armed callback so the
// whole progress path is exercised without waiting on wall-clock time.
h.calls.length = 0;
await h.tick();
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update/job']);
assert.match(h.html(), /开始拉取 mine\/main/, 'each step line must be rendered as it arrives');
assert.match(h.html(), /进行中/);
assert.equal(h.liveTimers().length, 1);
h.calls.length = 0;
await h.tick();
await h.flush();
assert.equal(h.liveTimers().length, 0, 'the timer must be cleared once the job reaches a final state');
assert.equal(h.vars.getPollTimer(), null);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
['GET /api/system/update/job', 'GET /api/system/update'],
'once the job ends the install tree is re-read: the binary on disk has changed');
const html = h.html();
assert.match(html, /a1b2c3d/);
assert.match(html, /f9e8d7c/);
assert.match(html, /并入提交数[\s\S]{0,160}?>3</);
assert.match(html, /改动文件数[\s\S]{0,160}?>27</);
assert.match(html, /roles\/我的角色\.md/, 'keptContent must be listed by name');
assert.match(html, /tools\/semgrep\.yaml/);
assert.match(html, /二进制已换新/);
assert.match(html, /需要重启才会运行新版本/);
assert.match(html, /3m12s/);
assert.match(html, /已要求在完成后退出/);
const success = h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)).pop();
assert.ok(success, 'a finished update must be announced: ' + JSON.stringify(h.toasts));
assert.match(success.msg, /f9e8d7c/, 'the toast names the commit the tree is on now');
});
test('a failing job shows the server message with its item list, and never claims success', async () => {
const h = await harness({
responses: {
'GET /api/system/update/job': [{ status: 200, body: { job: failedJob } }],
// GET /api/system/update also carries h.latest(), so the re-read after the job ends
// still has the finished job in it: the console must not lose the failure.
'GET /api/system/update': [
{ status: 200, body: envelope(statusOf(), null, true) },
{ status: 200, body: envelope(statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }), failedJob, true) },
],
},
});
await h.api.startUpdateApply();
await h.flush();
const html = h.html();
assert.match(html, /编译失败,二进制保持原版本/);
assert.match(html, /build_failed/);
assert.match(html, /internal\/foo\.go/, 'the failure item list must be shown');
assert.equal(h.liveTimers().length, 0, 'a finished job must not keep polling');
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)), [],
'a failed update must not be announced as finished: ' + JSON.stringify(h.toasts));
assert.match(h.toasts[h.toasts.length - 1].msg, /编译失败/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
});
test('the restart tick only appears when the process can actually be restarted', async () => {
const off = await harness({ canRestart: false });
assert.doesNotMatch(off.html(), /id="update-restart-choice"/,
'offering a promise the platform cannot keep is a lie');
assert.match(off.html(), /本次启动没有提供重启钩子/);
const on = await harness({
canRestart: true,
// No job to adopt: a running job would (correctly) block the second click, and this test
// is about what the checkbox does to the body, not about the block.
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
});
assert.match(on.html(), /<input type="checkbox" id="update-restart-choice"[^>]*onchange="updateRestartChoiceChanged\(this\.checked\)">/);
assert.ok(!/id="update-restart-choice" checked/.test(on.html()), 'the restart tick must default to off');
on.calls.length = 0;
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: false });
// Tick it through the rendered onchange, exactly as a browser does (the element is already
// checked when change fires), then apply again.
on.vars.setBusy(false);
on.nodes.get('update-restart-choice').checked = true;
on.fire('updateRestartChoiceChanged', { checked: true });
assert.equal(on.vars.getRestartChoice(), true);
assert.match(on.html(), /id="update-restart-choice" checked/, 'the tick must survive the repaint');
on.calls.length = 0;
on.vars.setBusy(false);
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true },
'the restart flag that goes out is the checkbox, not a guess');
});
test('the confirm dialog says what the update will do, and cancelling posts nothing', async () => {
const h = await harness();
h.setConfirm(false);
h.calls.length = 0;
h.toasts.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.equal(h.confirms.length, 1, 'the operator must be asked before the tree moves');
assert.match(h.confirms[0], /重新编译/, 'the confirm must say the binary gets rebuilt: ' + h.confirms[0]);
assert.match(h.confirms[0], /二进制/);
assert.match(h.confirms[0], /mine\/main/, 'the confirm must name the remote and branch being merged');
assert.match(h.confirms[0], /a1b2c3d/, 'the confirm must name where this machine is now');
assert.doesNotMatch(h.confirms[0], /\{\{/, 'the confirm went out un-interpolated: ' + h.confirms[0]);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), [], 'a cancelled update must not POST');
assert.deepEqual(h.toasts, [], 'a cancelled update must not be reported as anything');
});
test('a second apply is refused with 409 and the running job is adopted', async () => {
const h = await harness({
responses: {
'POST /api/system/update/apply': [{
status: 409,
body: {
error: '已有一次更新在进行中',
job: { id: 'upd-7', state: 'running', started: 'now', steps: [{ phase: 'build', message: '正在编译二进制', at: 'now' }] },
},
}],
},
});
await h.api.startUpdateApply();
await h.flush();
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
assert.equal(apply.method, 'POST');
assert.deepEqual(JSON.parse(apply.body), { restart: false });
assert.match(h.html(), /正在编译二进制/, 'the job the server named must be shown, not swallowed');
assert.match(h.html(), /upd-7/);
assert.match(h.toasts[h.toasts.length - 1].msg, /已有一次更新在进行中/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [], 'a refusal must not toast success');
assert.equal(h.liveTimers().length, 1, 'the adopted job must be watched');
});
test('rollback only shows with a rollback point, asks first, and re-reads the tree', async () => {
const none = await harness({ status: statusOf({ hasRollback: false, rollbackTo: '' }) });
assert.doesNotMatch(none.html(), /update-rollback-btn/);
assert.match(none.html(), /没有可回滚点/);
const h = await harness({ status: statusOf() });
assert.match(h.html(), /<button class="btn-secondary update-rollback-btn" data-require-permission="update:apply" onclick="rollbackUpdate\(\)">/);
assert.match(h.html(), /回滚目标:a1b2c3d/);
h.calls.length = 0;
h.toasts.length = 0;
h.setConfirm(false);
h.fire('rollbackUpdate');
await h.flush();
assert.deepEqual(h.calls, [], 'a cancelled rollback must not POST');
assert.equal(h.confirms.length, 1);
assert.match(h.confirms[0], /a1b2c3d/);
h.setConfirm(true);
h.calls.length = 0;
h.vars.setBusy(false);
h.fire('rollbackUpdate');
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
['POST /api/system/update/rollback', 'GET /api/system/update'],
'a rollback must be followed by a fresh read of the install tree');
assert.equal(h.calls[0].body, undefined, 'rollback takes no body');
assert.match(h.toasts[h.toasts.length - 1].msg, /已回滚到 a1b2c3d/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'success');
assert.doesNotMatch(h.html(), /已是最新/, 'after a rollback the page has not looked at the remote');
});
test('a refused rollback repeats the server reason and its file list', async () => {
const h = await harness({
responses: {
'POST /api/system/update/rollback': [{
status: 409,
body: { error: 'HEAD 已经移动,不是那次更新留下的位置', reason: 'moved_since_update', items: ['roles/我的角色.md'] },
}],
},
});
await h.api.rollbackUpdate();
await h.flush();
assert.match(h.html(), /回滚失败/);
assert.match(h.html(), /HEAD 已经移动/);
assert.match(h.html(), /roles\/我的角色\.md/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.match(h.toasts[h.toasts.length - 1].msg, /HEAD 已经移动/);
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), []);
});
test('a 202 that carries an error is not mistaken for success', async () => {
// The handler answers "the source went back but the binary could not be rebuilt" with HTTP
// 202 plus an error body (statusForError maps no_toolchain onto 202). Trusting resp.ok alone
// toasts 已回滚 over a rollback that only half happened.
const h = await harness({
responses: {
'POST /api/system/update/rollback': [{
status: 202,
body: { error: '源码已回到更新前,但本机没有 Go 工具链,二进制没换', reason: 'no_toolchain' },
}],
},
});
h.toasts.length = 0;
h.calls.length = 0;
await h.api.rollbackUpdate();
await h.flush();
assert.match(h.html(), /回滚失败/);
assert.match(h.html(), /二进制没换/);
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [],
'a 2xx carrying an error must never be toasted as success: ' + JSON.stringify(h.toasts));
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.match(h.toasts[h.toasts.length - 1].msg, /二进制没换/);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/rollback'],
'a half-finished rollback must not be followed by a victory lap through the status endpoint');
});
test('leaving the page kills the poller, and reopening arms exactly one', async () => {
const h = await harness();
h.fire('startUpdateApply');
await h.flush();
assert.equal(h.liveTimers().length, 1);
h.calls.length = 0;
h.setActive(false);
await h.tick();
await h.flush();
assert.equal(h.liveTimers().length, 0, 'switching pages must clear the interval');
assert.deepEqual(h.calls, [], 'a page that is not on screen must not keep asking');
assert.equal(h.vars.getPollTimer(), null);
h.setActive(true);
await h.api.loadUpdateConsole();
assert.equal(h.liveTimers().length, 0, 'a clean open arms nothing');
const busy = await harness({ job: { id: 'upd-3', state: 'running', started: 'x', steps: [] } });
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job arms the poller');
await busy.api.loadUpdateConsole();
assert.equal(busy.liveTimers().length, 1, 'reopening must not stack a second poller');
assert.equal(busy.timers.length, 2, 'the old timer was cleared rather than leaked: ' + JSON.stringify(busy.timers.map(t => t.cleared)));
});
test('nothing invented: the only toast channel this page may use is showNotification', () => {
assert.doesNotMatch(source, /showToast/, 'window.showToast does not exist on this page');
assert.match(source, /typeof showNotification === 'function'/);
assert.match(source, /window\.applyRBACToUI === 'function'/,
'optional globals must be probed before they are called');
});
test('server strings cannot become markup, in text or in an attribute', async () => {
const hostile = '危险 <script>alert(1)</script> "quoted" &more';
const h = await harness({
status: statusOf({
root: '/srv/"><img src=x onerror=alert(1)>/x',
subject: hostile,
remoteSubject: hostile,
blockingChanges: [{ path: 'a"><script>steal()</script>.go', status: 'modified "m"', protected: false }],
localChanges: [{ path: 'roles/"><svg onload=alert(1)>.md', status: 'modified', protected: true }],
incoming: [{ commit: 'c"><i>', subject: hostile }],
incomingTotal: 1,
checkError: 'fetch failed on <b>remote</b> "origin"',
}),
});
const html = h.html();
assert.doesNotMatch(html, /<script/, 'a commit subject became a script tag');
assert.doesNotMatch(html, /<img\b|<svg\b|<b>/, 'markup from server data reached the DOM');
assert.match(html, /危险 &lt;script&gt;alert\(1\)&lt;\/script&gt; "quoted"/,
'escapeHtml leaves quotes alone inside text, which is correct and must not be double escaped');
assert.match(html, /&amp;more/, 'the ampersand must be escaped exactly once');
// Attribute integrity: one raw double quote inside a value would close it early and let the
// rest of the string become attributes of the element.
['title', 'class', 'onclick', 'onchange', 'data-require-permission'].forEach(attr => {
const opened = (html.match(new RegExp(attr + '="', 'g')) || []).length;
const closed = (html.match(new RegExp(attr + '="[^"]*"', 'g')) || []).length;
assert.equal(opened, closed, 'a ' + attr + ' attribute was closed by an unescaped quote');
});
// Counting matched attribute pairs is not enough on its own: a value that ends early still
// leaves the open and close counts equal. Two hostile strings have to appear fully escaped
// inside their title attribute - that is the proof escapeAttr, not just escapeHtml, guarded it.
assert.match(html, /title="a&quot;&gt;&lt;script&gt;steal\(\)&lt;\/script&gt;\.go"/,
'the blocking file path went into an attribute without quote escaping');
assert.match(html, /title="\/srv\/&quot;&gt;&lt;img src=x onerror=alert\(1\)&gt;\/x"/,
'the install root went into an attribute without quote escaping');
assert.doesNotMatch(html, /title="[^"]*"[^ >]/, 'a title attribute was closed early by server data');
const titles = html.match(/title="[^"]*"/g) || [];
assert.ok(titles.length >= 8, 'these assertions rely on title attributes being emitted: ' + titles.length);
titles.forEach(t => assert.ok(!/<|>/.test(t), 'a title value still contains markup: ' + t));
// Every emitted handler must still be executable as written: an injection into an attribute
// would break the statement instead.
const found = h.extractHandlers(html);
assert.ok(found.onclick.length && found.onchange.length, 'handlers must still render under hostile data');
found.onclick.concat(found.onchange).forEach(text => {
assert.doesNotMatch(text, /"/, 'handler text carries a raw quote: ' + text);
assert.equal(typeof vm.runInContext('(function(){' + text + '})', h.sandbox), 'function',
'the emitted handler is not valid JS: ' + text);
});
['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'updateRestartChoiceChanged']
.forEach(name => assert.ok(found.onclick.concat(found.onchange).some(t => t.startsWith(name + '(')),
name + ' must stay wired'));
});
test('the rendered handlers are executed, and they only hit the documented endpoints', async () => {
const h = await harness();
const found = h.extractHandlers(h.html());
const texts = found.onchange.concat(found.onclick);
assert.ok(texts.length >= 4, 'expected the console to emit handlers, got ' + texts.length);
texts.forEach(t => assert.ok(HANDLES.includes(t.split('(')[0]), 'unknown handler wired into the page: ' + t));
h.calls.length = 0;
h.fire('checkForUpdates');
await h.flush();
assert.equal(h.calls[0].method, 'POST');
assert.equal(h.calls[0].url, '/api/system/update/check');
assert.equal(h.calls[0].body, undefined, 'the check endpoint takes no body');
h.vars.setBusy(false);
h.calls.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.deepEqual(h.calls.map(c => c.url),
['/api/system/update/apply', '/api/system/update/job'],
'apply posts once, then the progress read goes to the job endpoint');
assert.equal(h.calls[0].method, 'POST');
assert.equal(h.calls[1].method, 'GET');
});
// The page now asks the remote by itself, once, so the operator arrives at an answer rather
// than at a button. These pin the politeness of that: one check per session, only for a tree
// that can be updated, never on top of a running job - and the copy must describe what the
// code actually does.
test('opening the page schedules exactly one deferred remote check', async () => {
const h = await harness({
status: statusOf({ installed: true }),
checkedStatus: statusOf({ behind: 3, incomingTotal: 3, updateAvailable: true }),
});
assert.deepStrictEqual(h.deferredTimers(), [1200], 'one deferred check must be queued');
h.fireDeferred();
await h.flush();
const checks = h.calls.filter(c => c.method === 'POST' && c.url === '/api/system/update/check');
assert.strictEqual(checks.length, 1, 'the deferred timer must ask the remote exactly once');
assert.match(h.html(), /3/, 'the answer must be on screen without another click');
// Re-entering the page in the same session does not go back to the remote again.
h.api.loadUpdateConsole();
await h.flush();
assert.deepStrictEqual(h.deferredTimers(), [], 'the auto check is once per session, not per render');
assert.strictEqual(h.vars.autoCheckDone(), true);
});
test('a tree that cannot be updated is never phoned home for', async () => {
const h = await harness({ status: statusOf({ installed: false }) });
assert.deepStrictEqual(h.deferredTimers(), [], 'no auto check when this is not a git installation');
assert.strictEqual(h.calls.filter(c => c.url === '/api/system/update/check').length, 0);
});
test('an update already running is not disturbed by an auto check', async () => {
const h = await harness({ status: statusOf({ installed: true }), job: runningJob });
assert.deepStrictEqual(h.deferredTimers(), [], 'a running job is already the story on screen');
assert.strictEqual(h.vars.autoCheckDone(), false, 'a skipped check must not spend the session quota');
});
test('the hint describes the automatic check instead of denying any network use', () => {
for (const [label, dict] of [['zh-CN', zh], ['en-US', en], ['ru-RU', ru]]) {
const text = dict.update.notChecked;
assert.ok(text, label + ' lost the notChecked copy');
assert.doesNotMatch(text, /不会联网|makes no network call|no network call|не выходит в сеть|без обращения к сети/,
label + ' still promises the page never touches the network');
assert.match(text, /自动|automatic|automatically|автоматическ/, label + ' must mention the automatic check');
}
});
+648
View File
@@ -0,0 +1,648 @@
// 控制台「一键更新」:这一页动的就是当前正在应答请求的那套安装树本身。
// 三条硬规矩,每一条都是这个仓库已经踩过一次的坑:
// 1) 查不了必须说查不了。checkError 非空时页面写出原因,绝不渲染成「已是最新」——
// 把"没查成"画成"没更新",操作员要等到下一次故障才发现树早就没动了。
// 2) 提示只念真实 HTTP 结果,而且只用平台确实存在的 showNotification;没有就什么都不做。
// 3) 服务端来的字符串进 HTML 一律 escapeHtml,进属性一律 escapeAttr。
const UPDATE_POLL_INTERVAL_MS = 1500;
// withUpdateBusy 按动作挑错误文案的前缀,四个 key 都必须在三份语言里存在。
const UPDATE_FAILURE_KEYS = {
check: 'checkFailed',
apply: 'applyFailed',
rollback: 'rollbackFailed',
load: 'loadFailed',
};
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart}
let updateCheck = { done: false, error: '' };
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚)
let updateChecking = false; // 只影响「检查更新」按钮的措辞
let updatePollTimer = null;
let updateRestartChoice = false; // 勾选框要在轮询重绘之后仍然是勾着的
let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰
function updateT(key, opts) {
const k = 'update.' + key;
return typeof window.t === 'function' ? window.t(k, opts) : k;
}
// scheduleUpdateAutoCheck answers the question the page exists to answer, without making
// somebody press a button to find out whether the button would do anything.
//
// The constraints are what keep it polite: one check per session, only for a tree that is
// actually a git installation, never while a job is already running, and always after the
// first paint - the page's own claim has to reach the screen before the network does. A
// failure is not retried here: the page says so plainly, and the operator presses 检查更新
// when they want to ask again.
function scheduleUpdateAutoCheck() {
if (updateAutoCheckDone) return false;
const status = updateConsoleState && updateConsoleState.status;
if (!status || !status.installed || isUpdateJobRunning()) return false;
updateAutoCheckDone = true;
setTimeout(() => {
if (!isUpdateConsoleActive()) return;
checkForUpdates();
}, 1200);
return true;
}
function isUpdateConsoleActive() {
const page = document.getElementById('page-system-update');
return !!(page && page.classList.contains('active'));
}
function updateStatusOf() {
return (updateConsoleState && updateConsoleState.status) || {};
}
function updateJobOf() {
const job = updateConsoleState && updateConsoleState.job;
return job && typeof job === 'object' ? job : null;
}
function updateCanRestart() {
return !!(updateConsoleState && updateConsoleState.canRestart);
}
function isUpdateJobRunning() {
const job = updateJobOf();
return !!(job && job.state === 'running');
}
function resetUpdateCheck() {
// 一次 GET 拿不到 behind/updateAvailable(它不联网),留着上一次的 done=true
// 就会把"没查"画成"已是最新"。
updateCheck = { done: false, error: '' };
}
// ---------------------------------------------------------------------------
// 请求层
// ---------------------------------------------------------------------------
// 每个调用都要拿到状态码和解析后的体:apply/rollback 的 409 里带着那个正在跑的任务,
// 直接 throw 掉就等于把"已经有一次在跑了,这是它的进度"扔了。
// ok 不等于状态码:rollback 在"源码回了、二进制没回"这种半途情况下是按 202 回答并带着
// error 字段的(handler 里的 statusForError 把 no_toolchain 归到 202),只信 resp.ok
// 就会对着一次没做完的回滚播报成功。
async function runUpdateRequest(method, url, body) {
const resp = await apiFetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
const refused = !!(data && typeof data.error === 'string' && data.error);
return {
ok: !!resp.ok && !refused,
status: resp.status,
data: data || {},
error: refused ? data.error : ('HTTP ' + resp.status),
};
}
async function fetchUpdateStatus() {
const r = await runUpdateRequest('GET', '/api/system/update');
if (!r.ok) throw new Error(r.error);
updateConsoleState = {
status: r.data.status || {},
job: r.data.job || null,
canRestart: !!r.data.canRestart,
};
return updateConsoleState;
}
// notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因:
// 请求已经成功了,为了播报一句去调一个页面上不存在的函数,会把成功炸成失败。
function notify(message, type) {
if (typeof showNotification === 'function') {
try {
showNotification(message, type);
} catch (e) {
console.warn('notify', e);
}
}
}
async function withUpdateBusy(fn, label) {
if (updateBusy) return;
updateBusy = true;
try {
await fn();
} catch (err) {
const reason = err && err.message ? err.message : String(err);
const text = updateT(UPDATE_FAILURE_KEYS[label] || 'loadFailed', { reason: reason });
const el = document.getElementById('update-console');
if (el && isUpdateConsoleActive()) {
// 先按当前状态重绘再挂横幅:请求炸在半路上时,画面还停在"正在检查远端…"这种
// 已经不再成立的状态上,光加一条红字是盖不住它的。
renderUpdateConsole();
el.insertAdjacentHTML('afterbegin', '<div class="update-error">' + escapeHtml(text) + '</div>');
}
// 内联横幅会被下一次轮询重绘冲掉,toast 才是留得住的那一份。
notify(text, 'error');
} finally {
updateBusy = false;
}
}
// ---------------------------------------------------------------------------
// 入口与轮询
// ---------------------------------------------------------------------------
async function loadUpdateConsole() {
stopUpdatePolling();
resetUpdateCheck();
updateRestartChoice = false;
const el = document.getElementById('update-console');
if (!el) return;
el.innerHTML = '<div class="empty-state">' + escapeHtml(updateT('loading')) + '</div>';
try {
await fetchUpdateStatus();
} catch (err) {
el.innerHTML = '<div class="empty-state">' +
escapeHtml(updateT(UPDATE_FAILURE_KEYS.load, { reason: err.message })) + '</div>';
return;
}
renderUpdateConsole();
// 开页就有一次在跑的更新(比如另开一个标签页点的),接着看它,别让人以为没在动。
if (isUpdateJobRunning()) startUpdatePolling();
scheduleUpdateAutoCheck();
}
// 只有一个定时器:不清就重新 arm,是"页面开两次之后两条轮询往同一份日志里写"的来路。
function startUpdatePolling() {
stopUpdatePolling();
updatePollTimer = setInterval(pollUpdateJob, UPDATE_POLL_INTERVAL_MS);
}
function stopUpdatePolling() {
if (updatePollTimer) clearInterval(updatePollTimer);
updatePollTimer = null;
}
async function pollUpdateJob() {
if (!isUpdateConsoleActive()) {
// 页面已经切走了。router.js 只在进入时调 initPage,离开时没人喊,
// 所以这里自己停,不留野定时器。
stopUpdatePolling();
return;
}
const r = await runUpdateRequest('GET', '/api/system/update/job');
if (!r.ok) {
stopUpdatePolling();
notify(updateT('jobPollFailed', { reason: r.error }), 'error');
return;
}
const job = r.data && r.data.job ? r.data.job : null;
if (updateConsoleState) updateConsoleState.job = job;
renderUpdateConsole();
if (job && job.state !== 'running') {
stopUpdatePolling();
reportFinishedJob(job);
}
}
// 播报只念服务端写下来的结论:成功念 result 的两个提交号,失败念 failure.message,
// 页面自己不在这里编"更新完成"。
function reportFinishedJob(job) {
const result = job.result || null;
if (job.state === 'succeeded') {
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
notify(updateT('jobDoneToast') + tail, 'success');
if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
} else {
const reason = (job.failure && job.failure.message) ? job.failure.message : updateT('failureNoMessage');
notify(updateT('jobFailedToast') + ': ' + reason, 'error');
}
resetUpdateCheck();
// 二进制换过了,本机状态得重读一遍;读不到就照实说读不到。
fetchUpdateStatus().then(renderUpdateConsole).catch(err => {
notify(updateT(UPDATE_FAILURE_KEYS.load, { reason: err.message }), 'error');
});
}
// ---------------------------------------------------------------------------
// 动作
// ---------------------------------------------------------------------------
async function checkForUpdates() {
await withUpdateBusy(async () => {
updateChecking = true;
renderUpdateConsole();
let r;
try {
r = await runUpdateRequest('POST', '/api/system/update/check');
} finally {
// 请求本身炸掉(401、网络断了)也必须把"正在检查"放下来,否则按钮永远灰着。
updateChecking = false;
}
if (!r.ok) {
// HTTP 层的失败同样是"查不了",不能留下一个看起来像"没有更新"的页面。
updateCheck = { done: false, error: r.error };
renderUpdateConsole();
notify(updateT(UPDATE_FAILURE_KEYS.check, { reason: r.error }), 'error');
return;
}
const status = r.data.status || {};
if (updateConsoleState) updateConsoleState.status = status;
updateCheck = { done: true, error: status.checkError || '' };
renderUpdateConsole();
if (updateCheck.error) {
notify(updateT(UPDATE_FAILURE_KEYS.check, { reason: updateCheck.error }), 'error');
} else if (status.updateAvailable) {
notify(updateT('behindCount', { n: updateBehindCount(status) }), 'info');
} else {
notify(updateT('upToDate', { commit: status.commit || '-' }), 'success');
}
}, 'check');
}
function updateBehindCount(status) {
const listed = (status.incoming || []).length;
const total = status.incomingTotal || status.behind || 0;
return total > 0 ? total : listed;
}
// 按钮为什么点不动,得说得出原因;"灰着"不算解释。
function updateApplyBlockers(status, job) {
const blockers = [];
if (!status.installed) blockers.push(updateT('blockedNotGitTree'));
if (job && job.state === 'running') blockers.push(updateT('blockedJobRunning'));
if ((status.blockingChanges || []).length) {
blockers.push(updateT('blockedLocalEdits', { n: status.blockingChanges.length }));
}
if (status.diverged) {
blockers.push(updateT('blockedDiverged', {
ahead: status.ahead || 0,
behind: status.behind || 0,
}));
}
if (!status.updateAvailable) blockers.push(updateT('blockedNoUpdate'));
return blockers;
}
function updateRestartChoiceChanged(checked) {
updateRestartChoice = !!checked;
renderUpdateConsole();
}
// 勾选框是唯一的真相来源;模块里那份只是给轮询重绘用的记忆。
function updateRestartRequested() {
const box = document.getElementById('update-restart-choice');
if (box && typeof box.checked === 'boolean') return box.checked;
return updateRestartChoice;
}
function updateConfirmText(status, restart) {
const base = updateT('confirmApply', {
remote: status.remote || '-',
branch: status.branch || '-',
commit: status.commit || '-',
target: status.remoteCommit || '-',
n: updateBehindCount(status),
});
return base + ' ' + (restart ? updateT('confirmRestartYes') : updateT('confirmRestartNo'));
}
async function startUpdateApply() {
if (updateBusy) return;
const status = updateStatusOf();
const blockers = updateApplyBlockers(status, updateJobOf());
if (blockers.length) {
// 浏览器不会把 disabled 按钮的点击送到这里,走到这儿说明状态已经和画面不一致了,
// 那就照实说为什么不能点,一声不吭更像坏了。
notify(blockers[0], 'error');
return;
}
const restart = updateRestartRequested();
if (!window.confirm(updateConfirmText(status, restart))) return;
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/apply', { restart: restart });
if (r.status === 409 && r.data.job) {
// 另一次更新真的在跑:把它的进度接下来并接着看,这才是"当前状态"。
if (updateConsoleState) updateConsoleState.job = r.data.job;
renderUpdateConsole();
startUpdatePolling();
}
if (!r.ok) throw new Error(r.error);
resetUpdateCheck();
startUpdatePolling();
notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
}, 'apply');
}
async function rollbackUpdate() {
if (updateBusy) return;
const status = updateStatusOf();
if (!status.hasRollback) return;
if (isUpdateJobRunning()) {
notify(updateT('blockedJobRunning'), 'error');
return;
}
if (!window.confirm(updateT('confirmRollback', { commit: status.rollbackTo || '-' }))) return;
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/rollback');
if (!r.ok) {
// 服务端连"哪些文件没回滚"都给了,不能只报一句失败。
const items = r.data && r.data.items && r.data.items.length
? ' · ' + updateT('rollbackItems') + ': ' + r.data.items.join(', ')
: '';
throw new Error(r.error + items);
}
const to = (r.data.result && r.data.result.toCommit) || status.rollbackTo || '-';
resetUpdateCheck();
updateRestartChoice = false;
await fetchUpdateStatus();
renderUpdateConsole();
notify(updateT('rollbackDone', { commit: to }), 'success');
}, 'rollback');
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
function updateSection(title, body) {
return '<section class="update-section"><h3 class="update-section-title">' +
escapeHtml(title) + '</h3>' + body + '</section>';
}
// 值既进文本也进 title(长路径要靠 title 看全),所以属性那一份必须走 escapeAttr。
function updateFact(label, value) {
const text = value == null || value === '' ? '-' : String(value);
return '<div class="update-fact"><span>' + escapeHtml(label) + '</span>' +
'<code title="' + escapeAttr(text) + '">' + escapeHtml(text) + '</code></div>';
}
function updateMark(ok, yesKey, noKey, reason) {
const cls = ok ? 'update-chip update-chip-ok' : 'update-chip update-chip-warn';
if (!reason) {
return '<span class="' + cls + '">' + escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + '</span>';
}
return '<span class="' + cls + '" title="' + escapeAttr(String(reason)) + '">' +
escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + '</span>';
}
function renderUpdateInstallBody(status) {
const facts = [
updateFact(updateT('root'), status.root),
updateFact(updateT('branch'), status.branch),
updateFact(updateT('remote'), status.remote),
updateFact(updateT('commit'), status.commit),
updateFact(updateT('committedAt'), status.committedAt),
updateFact(updateT('subject'), status.subject),
].join('');
const marks = [
updateMark(!!status.installed, 'gitTree', 'notGitTree'),
updateMark(!!status.canBuild, 'toolchainFound', 'toolchainMissing',
status.canBuild ? (status.goToolchain || '') : (status.goToolchain || updateT('toolchainMissingHint'))),
updateMark(!!status.hasBinary, 'binaryPresent', 'binaryMissing'),
updateMark(!!status.hasRollback, 'rollbackPresent', 'rollbackMissing'),
].join('');
return '<div class="update-facts">' + facts + '</div><div class="update-marks">' + marks + '</div>';
}
function renderUpdateRemoteBody(status) {
const failure = updateCheck.error || status.checkError || '';
const parts = [];
if (failure) {
// 这条分支就是"不许说谎"的落点:有原因就只写原因,不提"已是最新"。
parts.push('<div class="update-error update-check-failed">' +
escapeHtml(updateT('checkFailed', { reason: failure })) + '</div>');
} else if (!updateCheck.done) {
parts.push('<div class="update-hint">' + escapeHtml(updateT('notChecked')) + '</div>');
} else if (status.updateAvailable) {
parts.push('<div class="update-behind">' +
escapeHtml(updateT('behindCount', { n: updateBehindCount(status) })) + '</div>');
} else {
parts.push('<div class="update-ok">' +
escapeHtml(updateT('upToDate', { commit: status.commit || '-' })) + '</div>');
}
if (status.ahead > 0) {
parts.push('<div class="update-warn">' + escapeHtml(updateT('aheadCount', { n: status.ahead })) + '</div>');
}
if (status.remoteCommit) {
parts.push('<div class="update-facts">' +
updateFact(updateT('remoteHead'), status.remoteCommit) +
updateFact(updateT('remoteSubject'), status.remoteSubject) + '</div>');
}
const incoming = status.incoming || [];
if (incoming.length) {
// incoming 是被截断过的清单,incomingTotal 才是真实数量,两个都得说。
const total = status.incomingTotal || incoming.length;
parts.push('<h4 class="update-sub-title">' + escapeHtml(updateT('incomingTitle')) + '</h4>');
if (total > incoming.length) {
parts.push('<div class="update-hint">' +
escapeHtml(updateT('incomingTruncated', { total: total, shown: incoming.length })) + '</div>');
}
parts.push('<ul class="update-commit-list">' + incoming.map(c =>
'<li><code title="' + escapeAttr(String(c.commit || '-')) + '">' + escapeHtml(String(c.commit || '-')) +
'</code> <span class="update-commit-subject">' + escapeHtml(String(c.subject || '')) + '</span></li>'
).join('') + '</ul>');
}
return parts.join('');
}
function renderUpdateChangesTable(list) {
// 行底色就是"这个文件会不会被更新覆盖"的分区:本机内容绿、产品源码红。
const rows = (list || []).map(c => '<tr class="' + (c.protected ? 'update-row-kept' : 'update-row-blocking') + '">' +
'<td><code title="' + escapeAttr(String(c.path || '')) + '">' + escapeHtml(String(c.path || '-')) + '</code></td>' +
'<td>' + escapeHtml(String(c.status || '-')) + '</td>' +
'<td>' + escapeHtml(c.protected ? updateT('keptFile') : updateT('productSource')) + '</td></tr>').join('');
return '<table class="update-table"><thead><tr>' +
'<th>' + escapeHtml(updateT('colPath')) + '</th>' +
'<th>' + escapeHtml(updateT('colChangeStatus')) + '</th>' +
'<th>' + escapeHtml(updateT('colOwner')) + '</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
function renderUpdateRestartChoice() {
if (!updateCanRestart()) {
// 后端没给重启钩子,勾选框就不该出现:勾一个不会被兑现的选项是骗人。
return '<div class="update-hint">' + escapeHtml(updateT('restartNotWired')) + '</div>';
}
const preview = updateRestartChoice ? updateT('confirmRestartYes') : updateT('confirmRestartNo');
return '<label class="update-restart-choice">' +
'<input type="checkbox" id="update-restart-choice"' +
(updateRestartChoice ? ' checked' : '') +
' onchange="updateRestartChoiceChanged(this.checked)">' +
'<span>' + escapeHtml(updateT('restartChoiceLabel')) + '</span></label>' +
'<div class="update-hint update-restart-preview">' + escapeHtml(preview) + '</div>';
}
function renderUpdateApplyBody(status, job) {
const blockers = updateApplyBlockers(status, job);
const blocked = blockers.length > 0;
const parts = [];
if (job && job.state === 'running') {
parts.push('<div class="update-warn">' + escapeHtml(updateT('jobRunningHint')) + '</div>');
}
if (!status.canBuild && status.installed) {
parts.push('<div class="update-warn">' + escapeHtml(updateT('noToolchainWarn')) + '</div>');
}
// Both halves matter: `disabled` for the browser, `data-state-disabled` so the platform's
// permission gate (which writes el.disabled for every [data-require-permission] element)
// does not re-enable the button for a user who holds update:apply. In a real browser that
// write produced an enabled 一键更新 directly under "有 53 个产品源码文件被本地改过".
const stateDisabled = blocked ? ' disabled data-state-disabled="true"' : '';
const checkingDisabled = updateChecking ? ' disabled data-state-disabled="true"' : '';
parts.push('<div class="update-actions">' +
'<button class="btn-primary update-apply-btn"' + stateDisabled +
' data-require-permission="update:apply" onclick="startUpdateApply()">' +
escapeHtml(updateT('applyBtn')) + '</button>' +
'<button class="btn-secondary update-check-btn"' + checkingDisabled +
' data-require-permission="update:apply" onclick="checkForUpdates()">' +
escapeHtml(updateChecking ? updateT('checking') : updateT('checkBtn')) + '</button>' +
'</div>');
parts.push(renderUpdateRestartChoice());
if (blocked) {
parts.push('<ul class="update-blocker-list">' + blockers.map(b =>
'<li class="update-error">' + escapeHtml(b) + '</li>').join('') + '</ul>');
}
const local = status.localChanges || [];
const blocking = status.blockingChanges || [];
// localChanges 通常是 blockingChanges 的超集,但两者万一不一致,阻塞更新的文件绝不能因此
// 不列出来:上面那句"有 N 个文件被本地改过"点名的就是这些得人去处理的文件名。
const seenPaths = Object.create(null);
const listed = local.concat(blocking).filter(c => {
const p = String((c && c.path) || '');
if (!p || seenPaths[p]) return false;
seenPaths[p] = true;
return true;
});
if (listed.length) {
parts.push('<h4 class="update-sub-title">' + escapeHtml(updateT('localChangesTitle')) + '</h4>');
parts.push('<div class="update-hint">' + escapeHtml(updateT('localChangesHint')) + '</div>');
parts.push(renderUpdateChangesTable(listed));
} else {
parts.push('<div class="update-hint">' + escapeHtml(updateT('noLocalChanges')) + '</div>');
}
return parts.join('');
}
function updateStateChip(state) {
const known = { running: 'update-chip-running', succeeded: 'update-chip-ok', failed: 'update-chip-danger' };
const cls = known[state] || 'update-chip';
const label = state === 'running' ? updateT('stateRunning')
: state === 'succeeded' ? updateT('stateSucceeded')
: state === 'failed' ? updateT('stateFailed') : updateT('stateUnknown');
return '<span class="' + cls + '">' + escapeHtml(label) + '</span>';
}
function renderUpdateStep(step) {
return '<li class="update-step">' +
'<code class="update-step-phase">' + escapeHtml(String(step.phase || '-')) + '</code>' +
'<span class="update-step-message">' + escapeHtml(String(step.message || '')) + '</span>' +
'<span class="update-step-at">' + escapeHtml(String(step.at || '')) + '</span></li>';
}
function renderUpdateResult(result) {
if (!result) return '';
const kept = result.keptContent || [];
const facts = [
updateFact(updateT('resultFrom'), result.fromCommit),
updateFact(updateT('resultTo'), result.toCommit),
updateFact(updateT('resultCommits'), result.commits),
updateFact(updateT('resultFiles'), result.filesTouched),
updateFact(updateT('resultDuration'), result.duration),
updateFact(updateT('resultBackup'), result.backupDir),
].join('');
const marks = [
updateMark(!!result.binaryBuilt, 'binarySwapped', 'binaryNotSwapped', result.binaryPath || ''),
updateMark(!!result.needsRestart, 'restartNeeded', 'restartNotNeeded'),
].join('');
const keptList = kept.length
? '<ul class="update-kept-list">' + kept.map(p =>
'<li><code title="' + escapeAttr(String(p)) + '">' + escapeHtml(String(p)) + '</code></li>').join('') + '</ul>'
: '<div class="update-hint">' + escapeHtml(updateT('nothingKept')) + '</div>';
return '<div class="update-result">' +
'<h4 class="update-sub-title">' + escapeHtml(updateT('resultTitle')) + '</h4>' +
'<div class="update-facts">' + facts + '</div><div class="update-marks">' + marks + '</div>' +
'<h4 class="update-sub-title">' + escapeHtml(updateT('keptTitle')) + '</h4>' + keptList +
'</div>';
}
function renderUpdateFailure(failure) {
if (!failure) return '';
const items = failure.items || [];
const reason = failure.reason ? '(' + escapeHtml(String(failure.reason)) + ')' : '';
return '<div class="update-error update-failure">' +
'<div class="update-failure-title">' + escapeHtml(updateT('failureTitle')) + reason + '</div>' +
'<div class="update-failure-message">' +
escapeHtml(String(failure.message || updateT('failureNoMessage'))) + '</div>' +
(items.length
? '<ul class="update-item-list">' + items.map(i =>
'<li><code title="' + escapeAttr(String(i)) + '">' + escapeHtml(String(i)) + '</code></li>').join('') + '</ul>'
: '') +
'</div>';
}
function renderUpdateJobBody(job) {
if (!job) return '<div class="empty-state">' + escapeHtml(updateT('noJob')) + '</div>';
const parts = ['<div class="update-job-head">' + updateStateChip(job.state) +
'<code title="' + escapeAttr(String(job.id || '-')) + '">' + escapeHtml(String(job.id || '-')) + '</code>' +
(job.started ? '<span class="update-job-time">' + escapeHtml(String(job.started)) + '</span>' : '') +
(job.restartRequested ? '<span class="update-chip">' + escapeHtml(updateT('restartRequested')) + '</span>' : '') +
'</div>'];
const steps = job.steps || [];
parts.push(steps.length
? '<ol class="update-step-list">' + steps.map(renderUpdateStep).join('') + '</ol>'
: '<div class="empty-state">' + escapeHtml(updateT('noSteps')) + '</div>');
if (job.state === 'running') {
parts.push('<div class="update-hint">' +
escapeHtml(updateT('pollingHint', { sec: UPDATE_POLL_INTERVAL_MS / 1000 })) + '</div>');
}
if (job.result) parts.push(renderUpdateResult(job.result));
if (job.failure) parts.push(renderUpdateFailure(job.failure));
return parts.join('');
}
function renderUpdateRollbackBody(status) {
if (!status.hasRollback) {
return '<div class="empty-state">' + escapeHtml(updateT('noRollback')) + '</div>';
}
const running = isUpdateJobRunning();
return '<p class="update-hint">' +
escapeHtml(updateT('rollbackTarget', { commit: status.rollbackTo || '-' })) + '</p>' +
'<div class="update-actions"><button class="btn-secondary update-rollback-btn"' +
(running ? ' disabled data-state-disabled="true"' : '') + ' data-require-permission="update:apply" ' +
'onclick="rollbackUpdate()">' + escapeHtml(updateT('rollbackBtn')) + '</button></div>';
}
function renderUpdateConsole() {
const el = document.getElementById('update-console');
if (!el || !updateConsoleState) return;
const status = updateStatusOf();
const job = updateJobOf();
const parts = [];
parts.push(updateSection(updateT('installTitle'), renderUpdateInstallBody(status)));
parts.push(updateSection(updateT('remoteTitle'), renderUpdateRemoteBody(status)));
parts.push(updateSection(updateT('applyTitle'), renderUpdateApplyBody(status, job)));
parts.push(updateSection(updateT('jobTitle'), renderUpdateJobBody(job)));
parts.push(updateSection(updateT('rollbackTitle'), renderUpdateRollbackBody(status)));
el.innerHTML = parts.join('');
if (typeof window.applyRBACToUI === 'function') {
window.applyRBACToUI(el);
}
}
window.loadUpdateConsole = loadUpdateConsole;
window.checkForUpdates = checkForUpdates;
window.startUpdateApply = startUpdateApply;
window.rollbackUpdate = rollbackUpdate;
window.updateRestartChoiceChanged = updateRestartChoiceChanged;
window.stopUpdatePolling = stopUpdatePolling;
window.renderUpdateConsole = renderUpdateConsole;
+23
View File
@@ -445,6 +445,16 @@
<span data-i18n="nav.platformRbac">平台权限</span>
</div>
</div>
<div class="nav-item" data-page="system-update">
<div class="nav-item-content" data-title="一键更新" onclick="switchPage('system-update')" data-i18n="nav.systemUpdate" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="M12 12v9"></path>
<path d="m8 17 4 4 4-4"></path>
</svg>
<span data-i18n="nav.systemUpdate">一键更新</span>
</div>
</div>
<div class="nav-item" data-page="settings">
<div class="nav-item-content" data-title="系统设置" onclick="switchPage('settings')" data-i18n="nav.settings" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -3572,6 +3582,18 @@
</div>
</div>
<div id="page-system-update" class="page">
<div class="page-header">
<h2 data-i18n="update.title">一键更新</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="loadUpdateConsole()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
<p class="update-intro" data-i18n="update.intro">这一页改的就是当前正在应答请求的这套安装:拉取它自己跟踪的远端、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。</p>
<div id="update-console" class="update-console"></div>
</div>
</div>
<!-- 系统设置页面 -->
<div id="page-settings" class="page">
<div class="page-header">
@@ -6945,6 +6967,7 @@
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>
<script src="/static/js/workflows.js"></script>
<script src="/static/js/update.js?v={{.Version}}"></script>
<script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script>