Repository navigation
feat: one-click update with a configurable source, restart, and self-recovery - #346
Conversation
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 / 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.
…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.
# Conflicts: # config.example.yaml
…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.
|
四条阻塞问题已全部修复并推送到最新提交: 1. 回滚丢失自定义内容 → 2. 构建失败后重试误报成功 → 3. 密码重置入口构建失败 → 4. 版本同步可能写坏配置 → 两点背景:
|
… 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.
增加控制台和 CLI 的一键更新:
config.yaml的update.repo可指向官方仓库、镜像或 fork,跟随该仓库的默认分支;检查更新、快进源码、编译、换入二进制和按需重启共用同一实现。普通发布包目录可先预览再接入更新源。本地源码修改、分叉历史和路径冲突会明确拒绝;角色、技能、工具、配置、数据库和上传内容按运维者内容保留。
本轮复查补齐的异常路径:
独立提交修复全量检查发现的已有问题:MCP 参数/环境变量展开使用独立快照,避免后台连接的数据竞争;工作流按实际截止时间判定超时,错误工具输出统一标为失败。三平台检查还修复了相同时间戳消息的重启恢复判断,并补齐 SQLite 测试资源关闭及 Windows 文件权限断言。
验证:
go test ./...、go build ./...、go vet ./...通过。go test -race通过。node --test web/static/js/*.test.cjs:277/277 通过。a63070db的 Self-update 和 Process isolation 共 6 项检查全部通过。