Skip to content

feat: one-click update with a configurable source, restart, and self-recovery - #346

Merged
Ed1s0nZ merged 23 commits into
AIPentest:mainfrom
Sycun:feat/self-update
Oct 9, 2026
Merged

Ed1s0nZ merged 23 commits into
AIPentest:mainfrom
Sycun:feat/self-update

Conversation

@Sycun

@Sycun Sycun commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

增加控制台和 CLI 的一键更新:config.yaml 的 update.repo 可指向官方仓库、镜像或 fork,跟随该仓库的默认分支;检查更新、快进源码、编译、换入二进制和按需重启共用同一实现。普通发布包目录可先预览再接入更新源。

本地源码修改、分叉历史和路径冲突会明确拒绝;角色、技能、工具、配置、数据库和上传内容按运维者内容保留。

本轮复查补齐的异常路径:

  • CLI、网页检查/更新/接入/回滚和重启共享安装锁;进程被终止后由系统释放,避免并发改写同一安装。
  • checkout/reset 前记录目标、备份与二进制哈希;接入和回滚中断后可继续。旧记录无法证明二进制版本时保留现场;编译期间源码变动也拒绝换入。
  • 保护被 Git 忽略的配置/数据、本地删除、私有文件权限,以及文件/目录和符号链接冲突;发布包升级保留相同的运行内容,备份目录不会因同秒操作覆盖。
  • 版本写入校验整个原配置,复杂 YAML 安全拒写;保留配置符号链接和权限。原先没有版本字段的配置,回滚后移除新增字段。
  • 保存更新源失败时保留原源;CLI 检查失败返回非零退出码;未完成回滚时页面提供恢复入口并禁止继续更新。

独立提交修复全量检查发现的已有问题:MCP 参数/环境变量展开使用独立快照,避免后台连接的数据竞争;工作流按实际截止时间判定超时,错误工具输出统一标为失败。三平台检查还修复了相同时间戳消息的重启恢复判断,并补齐 SQLite 测试资源关闭及 Windows 文件权限断言。

验证:

  • go test ./...、go build ./...、go vet ./... 通过。
  • 更新、配置、HTTP 接口、CLI 和工作流的 go test -race 通过。
  • 前端 node --test web/static/js/*.test.cjs:277/277 通过。
  • 超时回归连续 20 次通过,MCP 配置相关竞态回归连续 5 次通过,同时间戳恢复回归连续 10 次通过。
  • macOS 临时完整副本实测:真实服务二进制执行 CLI 更新、Go 编译、二进制换入与 CLI 回滚;源码、配置、更新后编辑的角色、私有权限和原二进制哈希全部恢复。
  • 使用真实临时 Git 仓库验证目录接入、受保护内容、忽略文件、路径冲突、进程退出后的锁释放,以及 reset/二进制换入前后中断的恢复。
  • 新增 Self-update CI,在 Linux、macOS、Windows 上运行相关 Go 测试、构建和前端回归;最新提交 a63070db 的 Self-update 和 Process isolation 共 6 项检查全部通过。

Sycun added 7 commits October 7, 2026 12:07
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.
The script is still enabled with chmod +x by whoever wants to run it (the README
documents that), so flipping the executable bit is a needless diff.
The CLI now lives in two files (main.go and update_cli.go), so
`go build cmd/server/main.go` compiles only main.go and leaves
updateCommandIfNeeded undefined - which is exactly how run.sh (twice) and both
READMEs built. `go build ./cmd/server` compiles the whole package; run.sh's
rebuild check already scans internal/ and cmd/ for newer sources, so nothing
else needed adjusting.
… directory

config.yaml gains an optional update section (remote / remote_url / branch). The
resolver keeps its default - whatever remote this tree already tracks - and the
explicit choice wins when set, which is also what lets a tarball installation
that never saw git get on the update rails at all.

- RemoteURL is fetched straight into a ref namespace of its own, so a URL source
  cannot be shadowed by a same-named remote's stale refs. ValidRemoteURL allowlists
  https/http/ssh/git/file and local absolute paths, and rejects git's ext::
  transport (which executes commands) before anything reaches argv.
- Adopt connects a plain directory to the source. Preview is side-effect free (the
  fetch happens in a throwaway bare repository) and lists every local file whose
  content the target would replace, plus the operator content that is put aside.
  The confirmed run inits, adds origin, stashes operator content, keeps every
  replaced file under .update-backup/<ts>/overwritten/ so "the update ate my edit"
  has an address, lands the target content, sets upstream, rebuilds the binary.
  A failure removes only the .git it created itself; once the content has landed
  the tree stays adopted even if the build fails.
- Console/API: the status envelope carries the configured source;
  POST /system/update/source writes it through the same config path every other
  setting uses (validated, audited); POST /system/update/adopt is preview-then-job,
  with the job's kind recorded. OpenAPI updated; RBAC rides the existing
  update:apply prefix and the route inventory stays green.

Tests: URL-mode check/apply against real repositories (including the address
beating a remote name), URL validation table, and the full adoption story -
preview touches nothing, operator content survives, the replaced edit is in the
backup, and the adopted tree updates and rolls back like any other installation.
The console page grows an update-source section: what is in effect and where it
comes from (config.yaml or the tree's own tracking), an edit form for the three
fields, and - for a directory that is not a git installation - the adopt entry.
Adopt is preview-first: the plan lists what would be replaced and what would be
kept, the confirm dialog carries the counts, and the restart tick is the same one
an update uses. A refused save shows the server's own sentence (a remote name and
an address together, an ext:: address) instead of a generic failure, and the typed
values survive the failed save and the polling redraws.

zh/EN/RU dictionaries carry the 27 new keys; the console page's contract tests
grow to 29 (source rendering and save body, the refusal path, adopt gating by
installation kind, preview-then-confirm); docs/README describe choosing a source
and connecting an unpacked installation.
The update console stops being a page of its own and becomes the last section
of 系统设置 (data-section="update", gated by update:read): the sidebar entry
and page-system-update are gone, the console markup lives in
settings-section-update, and the settings menu is where an operator now finds
it. #system-update keeps working as an alias - both hash parsers normalize it
to settings + the update section - so existing deep links and docs keep landing.

Polling follows the new home: leaving the settings page (router) or switching
to another section (settings.js) stops it, selecting the section loads the
console, and returning to the settings page with the section still selected
re-reads the install tree and re-arms the poller (a frozen progress read is
the "nothing is happening" picture an operator must never be shown).

The guard suite follows the move: the old nav/page assertions become "must not
exist", the new ones pin the section, the alias, the poller handoff and the
retired nav.systemUpdate key (settings.nav.update replaces it in zh/EN/RU).
README and both developer/deployment guides say 系统设置 → 一键更新.
…ge by itself

The console now knows what it is running: the status endpoint compares the binary
identity captured at startup (size + nanosecond mtime) with the file on disk, so
an update, a rollback or a CLI run that swapped the binary without a restart is
reported as needsRestart (with the build time) - a state that used to live only in
one job's result and vanished with the process. That state grows a persistent
banner and POST /api/system/update/restart (409 when there is nothing to activate
or a job owns the tree, 400 without a hook), so a forgotten tick is one click
instead of a shell login.

The restart tick defaults to on exactly where it is safe: launchd/systemd leave
markers in the environment (XPC_SERVICE_NAME, INVOCATION_ID/JOURNAL_STREAM) and
the box follows them; an operator's own choice is never second-guessed afterwards.

While the process is down the console becomes a recovery view that probes the
status endpoint every 2 seconds: 200 means the old process still answers, a dead
socket means it is on its way out, and a 401 - only a new process can fail to know
the session - reloads the page back to #system-update. A job poll that dies during
a requested restart is the restart in flight, not a failure to toast.

Also fixed: updateT now asks i18next not to escape interpolated values, because
the console escapes at render time - the apply confirm dialog has been showing
branch names with &#x2F; ever since they contained a slash, and the new banner
showed dates the same way.

Tests: 7 new console contract tests (36 total: banner, tick default, restart
confirm/refusal, watchdog 200-vs-401, timeout copy, the in-flight poll death) plus
handler tests for the stamp and the endpoint, all mutation-probed; docs describe
the pending-restart state and the self-recovery.
@Sycun Sycun changed the title feat: one-click update that follows a configurable source feat: one-click update with a configurable source, restart, and self-recovery Oct 7, 2026
Sycun added 8 commits October 8, 2026 13:46
…o by default

The update source used to be three things - a remote name or an address, plus an
optional branch - and unset it followed whatever this directory's remotes and
upstream happened to say. That asked people to understand git topology before
they could update, and the person it was built for could not tell what "follow
the remote this tree tracks" meant. It is now one field: config.yaml update.repo,
an address, empty meaning the official repository
(https://github.com/AIPentest/CyberStrikeAI.git). Point it at a mirror (GitHub is
hard to reach in some places) or at your own fork and that is the whole
configuration.

The branch nothing is any more: an update follows the source repository's own
default branch, resolved from the source itself (ls-remote --symref HEAD), so a
fork whose default is not "main" still works and nobody has to name a branch.
Remote names, the mine/origin/upstream ranking, @{upstream} tracking and the
adopt flow's branch argument are all gone with it.

The console page loses two input fields: the source section is one address, a
hint that empty means the official repository, and a save button; clearing the
address is the way back to the default. The install facts drop the remote name,
the check section shows the source's default branch once the network answer
arrives, and the adopt entry no longer requires saving an address first - the
official default makes connecting a tarball install a zero-configuration action.

The CLI now reads the same update.repo field the page saves (LoadUpdateRepo reads
just that field, without validating the rest of the file - a broken unrelated
section must not block the path that fixes the tree), so the keyboard and the
button cannot update from different repositories. -check-update prints the source
it is about to use.

Tests: the source suite now pins the default (official), the single-address save
(including the empty-string reset), and that the followed branch comes from the
source's own default branch rather than a hardcoded "main"; the console suite
posts the one field and renders the default hint; docs, config.example.yaml and
upgrade.sh describe the one-field story.
…es the code

The repository's config.example.yaml ships the release version this code was cut
from (upstream bumps it in the release commit: v1.7.21's config.example says
v1.7.21), while the live config.yaml is operator data that no merge ever touches.
So after an update the header badge - and the ?v= on every static asset - kept
saying the version the installation was born with while the tree already ran
newer code.

An update, an adoption, or a rollback now keeps the two together: after the merge
the update reads the version the new code carries (config.example.yaml) and writes
it into the live config's version field - that one line, temp file plus atomic
rename, everything else byte-for-byte preserved, and only when the value actually
differs. The job log records the change (the page shows the step), and the state
file records both numbers so a rollback puts the old version back with the old
code; a number hand-edited after the update is the operator's and is left alone.
The side effect worth naming: the version bump also changes the ?v= query on
static assets, so a browser stops serving the pre-update JavaScript.

Trees without a live config or without a shipped version are left untouched -
a source checkout has nothing to sync, and nothing is ever guessed.

internal/config gains the two focused helpers (FileVersion / WriteVersion, line
level like the tarball upgrade path always did); tests pin the replace, the
insert-when-absent, byte preservation, the mode, the malformed-value refusal, and
the update/adopt/rollback/leave-alone behaviours end to end.
…form

Reported from the live installation: after opening 系统设置 → 一键更新 (the
automatic check runs) the 保存更新源 button silently stopped working — a click
produced no request at all and the server log never saw a POST.

Root cause: the busy flag is a render input — it stamps `disabled` +
`data-state-disabled` on the action buttons — but the console only ever
re-rendered *while* busy. checkForUpdates (including the automatic one) renders
on entry and again when the response lands, and withUpdateBusy never rendered
once the flag cleared, so the last render left the source-save button disabled
for good. The carrier makes it permanent: data-state-disabled tells the
platform's permission gate not to re-enable the element either. Measured in a
browser after the auto-check: the DOM carried `disabled
data-state-disabled="true"` while updateBusy was already false.

Fix: withUpdateBusy now renders symmetrically — once on entry (the form visibly
locks while an action is in flight) and once after the flag clears (usable
again, button re-enabled) — skipping the exit render only when the restart
watchdog owns the view. The transient error banner stops being an injected div
and becomes state (updateActionError) rendered by renderUpdateConsole, so the
exit render cannot wipe it; it clears when a new action starts or the console is
re-entered.

Tests: the regression follows the operator's exact chain — deferred automatic
check → the form must be enabled again → clear the address → save must reach the
server — plus a second-save check; the first fails on the old code on the
disabled markup. Verified end to end in a real browser against a scratch
installation: the render trace reads entry(busy)→response(busy)→exit(idle), and
both saves (set and clear) hit the API and land in config.yaml.
The review found two failure paths that reported success for work the engine had
not done.

Rollback: it refused only when *non-protected* source files were modified, and
then ran `git reset --hard` to the previous commit - which discards every local
change to tracked files, not just the update's. Edits to roles/skills/tools (the
update itself keeps those via its own stash) and edits made after the update
were silently wiped, while Result said nothing: deployment.md's promise that
"work done afterwards is not swept away" did not hold. Rollback now copies every
protected file that differs right now into `.update-backup/rollback_<ts>/`
before the reset, sets the copies back after it, re-applies hand deletions, and
names what it kept in Result.KeptContent like an update does.

Retry: Apply merges and writes the state file before it builds, so a failed (or
toolchain-less) build leaves HEAD on the new commit with the old binary still in
place. The retry then took the `!snap.UpdateAvailable` no-op branch, aligned the
version and returned err=nil - the job was recorded as succeeded, the page said
"update finished", the CLI printed that source and binary were in place, and the
old executable kept running. The tree now carries a `.update-build-pending`
marker, written the moment the merge lands (and when an adoption commits to its
work tree) and cleared only after the new binary is in place. The no-op branch
reads it: with a toolchain it compiles and reports the build (commits=0,
binaryBuilt=true); without one it keeps refusing exactly like the first attempt,
so "install go and click again" is now a real instruction. The console stops
treating "nothing to pull" as "nothing to do" (the retry button stays live, the
confirm dialog and the done toast say the build is what will happen), and the
CLI's printResult no longer swallows the build-only result.

Both paths are covered by regression tests that fail on the pre-fix tree: two
rollback scenarios driven through real git (edit before and after the update, a
hand deletion), and four retry scenarios driven through a scripted go on PATH
(failed build, state-write failure, an adoption's failed build retried by the
next update, and no toolchain then toolchain).
…file

`./run.sh --reset-admin-password` rebuilds the binary whenever it is missing or
older than the sources, through build_go_project_quiet - the one build path left
that compiled the single file `cmd/server/main.go`. A file-based go build only
compiles the file it names, so it dropped every sibling of package main that
this PR added; measured on the parent commit:

    cmd/server/main.go:35:22: undefined: updateCommandIfNeeded

(the symbol lives in cmd/server/update_cli.go), which made the documented
password-reset entry exactly the path that could not build itself. The normal
start path and upgrade.sh already build `./cmd/server`; the quiet path now does
too, and a test scans run.sh, upgrade.sh and scripts/*.sh so a file-based
go build cannot come back unnoticed - it fails on the pre-fix tree with the
command named.
FileVersion reads the live config with the YAML parser, which understands
`"version":` and `'version':` exactly like the bare key. WriteVersion matched
lines by the literal prefix `version`, so a legal quoted-key config was not
recognized as already carrying the field at all: instead of replacing the line,
the writer prepended a second `version:` key. The file then failed to load
("mapping key \"version\" already defined"), i.e. the version sync could leave
an installation that no longer starts - while reporting success through the
real entry point (before=v1.0.0 -> after=v1.7.22, err=nil).

The writer now matches all three spellings, top-level only, first occurrence
only, and before writing it re-reads the new text with the same YAML parser: if
the result does not parse, or the top-level version does not read back as the
target, the write is refused and the original file is left byte-for-byte alone
(the update reports the skip and stands - a stale version number is a display
problem, a config that does not load is an installation that does not start).
Forms a line-based writer cannot rewrite, like JSON one-liners and block
scalars, are refused rather than corrupted.

The tarball path's writer in upgrade.sh gets the same rule set: top-level
three-spelling matching, first match only, a refusal for block/multi-line
scalars and for duplicate keys, plus the value validation the Go side applies -
a tag with a quote or newline in it can no longer reach the live config.

Regression tests: the config package covers the three quoted spellings and the
three refused forms (JSON one-liner, block scalar, duplicate key - file must
stay untouched), and the update package drives a quoted-key live config through
a real Apply and Rollback; the latter fails on the pre-fix tree with the
duplicate key it used to produce.
The four retry scenarios are driven through a scripted `go` (a POSIX shell script
placed first on PATH) and a PATH with a symlinked git. On Windows neither
harness resolves: the file would not be found through LookPath and would not
execute, and the symlink needs privileges - the tests would either fail or
silently assert nothing. They now skip there with the reason named, instead of
reporting a platform they cannot speak for. `GOOS=windows go vet ./internal/update`
compiles clean; macOS runs them for real.
@Sycun

Sycun commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

四条阻塞问题已全部修复并推送到最新提交:9c692807(问题 1+2)、9a68a17c(问题 3)、81bd5f1a(问题 4)、e8df681d(脚本化测试在 Windows 上跳过,与功能无关)。CI lifecycle 三平台全绿。逐条说明与证据:

1. 回滚丢失自定义内容 → 9c692807
Rollback 现在在 git reset --hard 前把受保护文件上现存的本地改动(更新前改的、更新后改的都算)复制到 .update-backup/rollback_<ts>/,重置后原样放回;本地手删的保持删除(reset 后重新移除);Result.KeptContent 逐个点名。
证据:internal/update/rollback_test.go 两条在父提交上实测失败——编辑过的 roles/shipped.yaml 被抹回旧提交版本;更新后才改的文件被 reset 直接删掉。

2. 构建失败后重试误报成功 → 9c692807
新增 .update-build-pending 标记:写在 merge 落地那一刻(接入则是确认接管那一刻)、换好二进制后才清——位置在版本同步与 state 落盘之前,所以 state 写失败(磁盘满等)也带不走它。no-op 分支读标记:有工具链就真编译(commits=0, binaryBuilt=true),没有就照旧 no_toolchain 明拒,不再返回成功。连带:页面不再把「没有新提交」当成「没事可做」(补编译提示、确认文案、「补编译完成」toast),CLI printResult 不再吞补编结果。
证据:pending_build_test.go 四条(失败后重试真编译 / state 写失败后重试仍补编 / 接入的构建失败由下一次更新补上 / 无工具链→装好后真编译);前两条在父提交上实测红——重试返回 err=nil, {from==to, commits:0, built:false}、旧二进制原样。

3. 密码重置入口构建失败 → 9a68a17c
run.sh 的 build_go_project_quiet 单文件构建改成 ./cmd/server(全仓最后一处)。新增门禁 cmd/server/build_scripts_test.go:扫 run.sh / upgrade.sh / scripts/*.sh 的每条 go build,出现 .go 即失败、零命中也算失败;在父提交上实测红并点名 run.sh 那一行。

4. 版本同步可能写坏配置 → 81bd5f1a
WriteVersion 的行匹配放宽到 version / "version" / 'version' 三种顶格写法(与 FileVersion 的 YAML 读取同口径);写盘前用同一解析器回验新文本,解析失败或顶层 version 读不回目标值就拒绝写入、原文件不动(更新照常完成,进度里给一行提示)。JSON 单行、块标量这类行级替换够不着的写法从此安全拒写而不是写坏。tarball 路径的 upgrade.sh 同类写入器同样对齐(三种写法 + 首个匹配 + 块/多行标量拒绝),并补上版本号合法性校验(带引号/换行的 tag 再也进不了 live config)。
证据:config_test.go(三种引号写法就地替换且只剩一个键;JSON 单行 / 块标量 / 重复键必须拒写且文件字节不变);update 包端到端用例让引号键 live config 走完真实 Apply+Rollback——父提交上实测产出重复键、Load 失败。

两点背景:

  • CI 的 lifecycle 是路径过滤的,只覆盖 processguard / runlease / security / handler / mcp,不含 internal/update 与 internal/config——这正是「CI 全绿仍挡不住这四条」的原因;上述回归测试挂在 go test ./... 上。
  • 全量验证:go build ./...、go test ./... 34 包全过;前端 node --test 276 项,5 项红与本 PR 修复前实测完全一致(既存 HITL 用例,与本次改动无关)。

Sycun added 8 commits October 8, 2026 22:46
… first

Three ways the installation could still end up with its source, its binary and
the operator's content describing different things.

Rollback copied aside only *tracked* protected files, so a role upstream had
deleted and the operator had recreated at the same path was untracked - and
`git reset --hard` wrote the old commit's copy over it. The stash now also
covers untracked protected paths the target commit writes to, and names them in
keptContent like any other file it saved.

The pending-build marker said only "a compile is owed". An update that found
new commits therefore merged them and built the *new* tree, leaving `.prev`
holding a binary for a commit the state file no longer named - a rollback then
paired old source with the original binary. And a marker left behind after a
successful swap rebuilt and overwrote that same `.prev`. The marker now names
its target commit and the hashes on both sides of the swap, the state records
which commit the live binary came from, an owed build is finished before the
tree moves again, and a retry that finds the swap already done clears the
marker instead of repeating it. Rollback verifies that pairing before touching
anything and refuses (binary_changed) rather than reset the source beside a
binary it cannot account for; an update whose build failed never swapped one,
so its rollback moves the source only and no longer demands a `.prev`.

The version pair is recorded before the config is written, so a crash in
between still leaves a rollback that can put the old number back.
Inserting `version` above a leading `---` put it in a document of its own, so
every setting after the marker parsed as a second document and was silently
ignored: the installation came up on defaults with a config that looked intact.
Both writers - the Go one and upgrade.sh's - now insert after the document
start, and the parse-back check still refuses anything a line rewrite cannot do
safely.

upgrade.sh rewrote config.yaml through a temp file created with the process
umask, so a 0600 config came back 0644 on every package upgrade - the file
holding the API keys became group/world readable. It now creates the temp file
privately and restores the original mode before the atomic replace.

A package upgrade also deleted `.update-build-pending`, because rsync --delete
only keeps what the exclude list names: the installation forgot a compile it
still owed and the next click reported "already up to date". Every one-click
update artifact is on the keep list now, asserted against .gitignore so the
next one cannot be forgotten the same way.
@Ed1s0nZ
Ed1s0nZ merged commit 72cd8d6 into AIPentest:main Oct 9, 2026
6 checks passed
@Sycun
Sycun deleted the feat/self-update branch October 9, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants