Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cb8bc6b
feat: update this installation's own source in one action
Sycun Oct 7, 2026
c0f2e25
chore: keep upgrade.sh at the shipped 100644 mode
Sycun Oct 7, 2026
dc5c4d6
fix: build the server through the package, not one file
Sycun Oct 7, 2026
acd84cd
feat: let an installation choose its update source, and adopt a plain…
Sycun Oct 7, 2026
27c1ff6
feat: give the update source a page, and the adopt flow its preview
Sycun Oct 7, 2026
cbd8073
feat: move the one-click update into the system settings menu
Sycun Oct 7, 2026
e80668b
feat: make a restart part of the one-click update, and recover the pa…
Sycun Oct 7, 2026
94a1826
feat: make the update source one repository address, the official rep…
Sycun Oct 8, 2026
0d93156
feat: carry the release version across an update, so the header match…
Sycun Oct 8, 2026
069abc0
Merge remote-tracking branch 'origin/main' into feat/self-update
Sycun Oct 8, 2026
22a0698
fix: stop a finished check from freezing the update console's source …
Sycun Oct 8, 2026
9c69280
fix: a rollback keeps the operator's edits, and a retry really compiles
Sycun Oct 8, 2026
9a68a17
fix: the password-reset entry builds the cmd/server package, not one …
Sycun Oct 8, 2026
81bd5f1
fix: the version writer recognizes every spelling its reader accepts
Sycun Oct 8, 2026
e8df681
test: keep the scripted-toolchain scenarios off windows
Sycun Oct 8, 2026
b645914
fix: a rollback keeps a recreated role, and an owed build is finished…
Sycun Oct 8, 2026
36e1d96
fix: a version write keeps the document it lands in and the mode it had
Sycun Oct 8, 2026
54d8f11
fix: preserve rollback binaries during ambiguous recovery and YAML se…
Sycun Oct 9, 2026
e8a5392
fix: serialize installation updates and recover interrupted adoption …
Sycun Oct 9, 2026
b841296
fix: snapshot MCP configuration and make tool deadline failures consi…
Sycun Oct 9, 2026
07b493b
ci: gate self-update and configuration changes on three platforms
Sycun Oct 9, 2026
98de085
fix: reconcile same-timestamp messages and close test databases
Sycun Oct 9, 2026
a63070d
docs: explain update locking and interrupted rollback recovery
Sycun Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/self-update.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Self-update
on:
push:
paths: ['internal/update/**', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
pull_request:
paths: ['internal/update/**', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
permissions:
contents: read
jobs:
update:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Keep source line endings consistent
run: git config --global core.autocrlf false
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: go test -race ./internal/update ./internal/config ./internal/handler ./cmd/server ./internal/workflow
- run: go build ./...
- name: Frontend regression tests
shell: bash
run: node --test web/static/js/*.test.cjs
- name: Shell syntax
shell: bash
run: bash -n run.sh upgrade.sh
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ target/
coverage.out
coverage.html

# One-click update artifacts (internal/update): machine-local, written at the install root
.update-lock
.update-rollback-pending.json
.update-rollback-pending.json.tmp
.update-staging/
.update-backup/
.update-state.json
.update-state.json.tmp
.update-build-pending
.update-build-pending.tmp
cyberstrike-ai.prev

# Logs and temporary files
/log/
*.log
Expand Down
35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,10 @@ The `run.sh` script will automatically:
**Alternative Launch Methods:**
```bash
# Direct Go run (set up env yourself); add --https to match run.sh defaults
go run cmd/server/main.go --https
go run ./cmd/server --https

# Manual build
go build -o cyberstrike-ai cmd/server/main.go
go build -o cyberstrike-ai ./cmd/server
./cyberstrike-ai --https
```

Expand All @@ -269,17 +269,38 @@ 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 System settings -> One-click update, or
`./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls the newest commit of the
**update source repository**'s default branch (the official repository by default; one setting points
it at your own fork or a mirror), 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; only
`config.yaml`'s `version` field is synced to the new code, so the header badge matches it), and
refuses - rather than overwrites - when source is modified locally or the local history 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).

The update source is one setting, a repository address in the `update` section of `config.yaml` or
right on the console page - the official repository, your own fork, or somebody else's fork. An
installation unpacked from a Release archive (no git) can be previewed and connected from the same
page and updates in one click from then on.

**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`.
Expand Down
28 changes: 22 additions & 6 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,10 @@ chmod +x run.sh && ./run.sh
**其他启动方式:**
```bash
# 直接运行(需自行配环境);与 run.sh 默认一致可加 --https
go run cmd/server/main.go --https
go run ./cmd/server --https

# 手动编译
go build -o cyberstrike-ai cmd/server/main.go
go build -o cyberstrike-ai ./cmd/server
./cyberstrike-ai --https
```

Expand All @@ -268,16 +268,32 @@ go build -o cyberstrike-ai cmd/server/main.go

### 版本升级与兼容性

**优先用平台自带的「一键更新」**(控制台「系统设置 → 一键更新」,或 `./cyberstrike-ai -update` /
`-check-update` / `-update-rollback`):它从**更新源仓库**(默认官方仓库,可一处配置改成自己的二开或镜像)
拉取默认分支的最新代码,快进后重编译并原子换二进制,你的 `roles/skills/tools/agents/data/config.yaml`
会被原样保留(结果里逐个点名保留了什么,只有 `config.yaml` 的 `version` 字段会随新代码同步,让页头版本号与代码对上),
本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。
详见 [部署指南](docs/zh-CN/deployment.md)。

`upgrade.sh` 仍然可用:本目录是 git 工作树时它就是上面那条命令的薄壳;不是 git 工作树(tarball 安装)时
才回落到"下载 GitHub Release 包 + rsync"的老路径,此时源码仓库由 `--repo owner/name` 或 `GITHUB_REPO`
决定(都不给才用内置默认值,并会警告说明代码来自哪个仓库)。

更新源是 `config.yaml` `update` 段里的一个仓库地址(`repo`),也可以在控制台「一键更新」页填写保存
(跟随官方仓库、自己的二开、别人的二开或镜像都只是这一处;不写就是官方仓库);解压安装(目录里没有 git)
也能在同一页「预览并接入」后进入一键更新。

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`。
Expand Down
223 changes: 223 additions & 0 deletions cmd/server/build_scripts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

// Every `go build` in this repository's scripts has to target the cmd/server package.
// Building the single file cmd/server/main.go was tried once and broke the password reset
// entry: the file-based form silently leaves out sibling files of package main (it was
// update_cli.go that disappeared), and only `run.sh --reset-admin-password` used that
// build path, so nothing noticed until an operator needed it in an emergency. Compiling
// the package can never have that failure mode.
func TestScriptsBuildThePackageNotASingleFile(t *testing.T) {
root := filepath.Join("..", "..")
scripts := []string{filepath.Join(root, "run.sh"), filepath.Join(root, "upgrade.sh")}
more, err := filepath.Glob(filepath.Join(root, "scripts", "*.sh"))
if err != nil {
t.Fatal(err)
}
scripts = append(scripts, more...)

found := 0
for _, script := range scripts {
data, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read %s: %v", script, err)
}
for _, line := range strings.Split(string(data), "\n") {
if !strings.Contains(line, "go build") {
continue
}
found++
if strings.Contains(line, ".go") {
t.Errorf("%s builds a single .go file; build ./cmd/server instead:\n %s",
script, strings.TrimSpace(line))
}
}
}
// A scan that finds no build command at all is looking in the wrong place, and a test
// that cannot see the thing it guards passes for the wrong reason.
if found == 0 {
t.Fatal("no `go build` command found in the scripts")
}
}

func TestUpgradeScriptUpdateConfigVersionPreservesDocumentAndMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("upgrade.sh requires a POSIX shell")
}
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available")
}
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 is not available")
}

root := filepath.Join("..", "..")
scriptPath := filepath.Join(root, "upgrade.sh")
script, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("read %s: %v", scriptPath, err)
}
function := extractShellFunction(t, string(script), "update_config_version")
command := "umask 022\n" + function + "\nCONFIG_FILE=$1\nupdate_config_version \"$2\"\n"

for _, tc := range []struct {
name string
original string
wantDocumentStart bool
}{
{name: "document start", original: "---\nserver:\n port: 8088\n", wantDocumentStart: true},
{name: "commented document start", original: "# local settings\n---\nserver:\n port: 8088\n"},
{name: "directive document start", original: "%YAML 1.1\n---\nserver:\n port: 8088\n"},
{name: "private mode", original: "version: \"v1.0.0\"\nserver:\n port: 8088\n"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(configPath, []byte(tc.original), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(configPath, 0o600); err != nil {
t.Fatal(err)
}

cmd := exec.Command(bash, "-c", command, "upgrade-version-test", configPath, "v1.2.3")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("update_config_version failed: %v\n%s", err, output)
}
got, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if tc.wantDocumentStart && !strings.HasPrefix(string(got), "---\n") {
t.Errorf("YAML document start must remain first: %q", got)
}
var parsed struct {
Version string `yaml:"version"`
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
}
if err := yaml.Unmarshal(got, &parsed); err != nil {
t.Fatalf("updated config is not valid YAML: %v\n%s", err, got)
}
if parsed.Version != "v1.2.3" {
t.Errorf("version = %q, want v1.2.3", parsed.Version)
}
if parsed.Server.Port != 8088 {
t.Errorf("server.port = %d, want 8088", parsed.Server.Port)
}
info, err := os.Stat(configPath)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %v, want 0600 preserved", info.Mode().Perm())
}
})
}
}

func TestUpgradeScriptVersionRefusesUnsupportedYAML(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("upgrade.sh requires a POSIX shell")
}
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
script, err := os.ReadFile(filepath.Join("..", "..", "upgrade.sh"))
if err != nil {
t.Fatal(err)
}
function := extractShellFunction(t, string(script), "update_config_version")
for _, original := range []string{
"--- {server: {port: 8088}}\n",
"{server: {port: 8088}}\n",
"version: \"old\n version\"\nserver:\n port: 8088\n",
} {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
command := function + "\nCONFIG_FILE=$1\nupdate_config_version v1.2.3\n"
if output, err := exec.Command("bash", "-c", command, "test", path).CombinedOutput(); err != nil {
t.Fatalf("writer failed: %v %s", err, output)
}
if got, err := os.ReadFile(path); err != nil || string(got) != original {
t.Fatalf("unsupported YAML was changed: %q %v", got, err)
}
}
}

// The tarball path syncs with `rsync --delete`, so every artifact a one-click update writes
// at the install root has to be on its keep list. .gitignore is exactly that list of
// machine-local artifacts, so the assertion is derived from it: a new marker cannot be
// forgotten here and silently deleted by the next package upgrade.
func TestUpgradeScriptKeepsEveryOneClickUpdateArtifact(t *testing.T) {
root := filepath.Join("..", "..")
ignore, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
script, err := os.ReadFile(filepath.Join(root, "upgrade.sh"))
if err != nil {
t.Fatal(err)
}

var artifacts []string
inSection := false
for _, line := range strings.Split(string(ignore), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# One-click update artifacts") {
inSection = true
continue
}
if !inSection {
continue
}
if strings.HasPrefix(line, "#") {
break
}
if line != "" {
artifacts = append(artifacts, line)
}
}
if len(artifacts) == 0 {
t.Fatal("no update artifacts found in .gitignore: the section this test reads has moved")
}

// The script names the binary through a variable; compare against the literal name.
keeps := strings.ReplaceAll(string(script), "${BINARY_NAME}", "cyberstrike-ai")
for _, artifact := range artifacts {
if !strings.Contains(keeps, "--exclude="+artifact) {
t.Errorf("upgrade.sh syncs with rsync --delete but does not keep %s", artifact)
}
}
}

func extractShellFunction(t *testing.T, script, name string) string {
t.Helper()
startMarker := name + "() {"
start := strings.Index(script, startMarker)
if start < 0 {
t.Fatalf("function %s not found", name)
}
lines := strings.Split(script[start:], "\n")
for i := 1; i < len(lines); i++ {
if lines[i] == "}" {
return strings.Join(lines[:i+1], "\n")
}
}
t.Fatalf("function %s has no closing brace", name)
return ""
}
7 changes: 7 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading