mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
fix: preserve rollback binaries during ambiguous recovery and YAML settings
This commit is contained in:
1 parent
36e1d9680b
commit
54d8f1187d
10 files changed
+238
-22
No files matched your search
@@ -77,6 +77,8 @@ func TestUpgradeScriptUpdateConfigVersionPreservesDocumentAndMode(t *testing.T)
|
||||
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) {
|
||||
|
||||
@@ -1840,11 +1840,18 @@ func WriteVersion(path, version string) (bool, error) {
|
||||
}
|
||||
if !replaced {
|
||||
versionLine := `version: "` + version + `"`
|
||||
if len(lines) > 0 && yamlDocumentStart(lines[0]) {
|
||||
lines = append([]string{lines[0], versionLine}, lines[1:]...)
|
||||
} else {
|
||||
lines = append([]string{versionLine}, lines...)
|
||||
insertAt := 0
|
||||
for i, line := range lines {
|
||||
if yamlDocumentStart(line) {
|
||||
insertAt = i + 1
|
||||
break
|
||||
}
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\uFEFF"))
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(line, "%") {
|
||||
break
|
||||
}
|
||||
}
|
||||
lines = append(lines[:insertAt], append([]string{versionLine}, lines[insertAt:]...)...)
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if out == string(data) {
|
||||
|
||||
@@ -532,3 +532,25 @@ func readTestFile(t *testing.T, path string) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestWriteVersionAfterCommentedDocumentStart(t *testing.T) {
|
||||
for _, prefix := range []string{"# local settings\n---\n", "\n# local settings\n--- # config\n", "%YAML 1.1\n---\n"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
original := prefix + "server:\n port: 8088\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := WriteVersion(path, "v1.2.3"); err != nil || !changed {
|
||||
t.Fatalf("write: %v %v", changed, err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil || cfg.Server.Port != 8088 {
|
||||
t.Fatalf("settings lost after version write: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
if got := readTestFile(t, path); got != prefix+"version: \"v1.2.3\"\nserver:\n port: 8088\n" {
|
||||
t.Fatalf("unexpected rewrite: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,25 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res *
|
||||
pending.BackupDir = state.BackupDir
|
||||
pending.VersionBefore = state.VersionBefore
|
||||
pending.VersionAfter = state.VersionAfter
|
||||
pending.PreviousBinarySHA256 = state.PreviousBinarySHA256
|
||||
if state.BinaryCommit != "" {
|
||||
binaryCommit, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", state.BinaryCommit)
|
||||
previous, previousErr := gitCmd(ctx, snap.Root, "rev-parse", state.PreviousCommit)
|
||||
if resolveErr != nil || previousErr != nil || (binaryCommit != updated && binaryCommit != previous) {
|
||||
return &Error{Reason: "bad_state", Message: "旧恢复记录的二进制提交不属于这次更新,已保留现场"}
|
||||
}
|
||||
if state.BinarySHA256 != "" {
|
||||
currentHash, hashErr := fileSHA256(bin)
|
||||
if hashErr != nil || currentHash != state.BinarySHA256 {
|
||||
return &Error{Reason: "binary_changed", Message: "当前二进制与旧恢复记录不一致,已保留现场"}
|
||||
}
|
||||
}
|
||||
if binaryCommit == updated {
|
||||
pending.BinarySHA256 = state.BinarySHA256
|
||||
} else {
|
||||
pending.PreviousBinarySHA256 = state.BinarySHA256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pending.Commit == "" {
|
||||
@@ -534,6 +553,9 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s
|
||||
currentHash, currentErr := fileSHA256(bin)
|
||||
previousHash, previousErr := fileSHA256(bin + ".prev")
|
||||
if currentErr == nil && previousErr == nil && currentHash == newHash {
|
||||
if pending.PreviousBinarySHA256 != "" && previousHash != pending.PreviousBinarySHA256 {
|
||||
return &Error{Reason: "binary_changed", Message: "旧二进制备份与恢复记录不一致,已保留现场,拒绝改写回滚记录"}
|
||||
}
|
||||
res.BinaryBuilt = true
|
||||
res.PrevBinary = bin + ".prev"
|
||||
res.NeedsRestart = true
|
||||
@@ -551,6 +573,21 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// A legacy marker without hashes is ambiguous when a rebuild differs from the
|
||||
// installed binary. Only a clean VCS stamp for the previous commit can prove
|
||||
// that another swap will preserve the right rollback binary.
|
||||
if (pending.Legacy || pending.PreviousCommit != "") && pending.PreviousBinarySHA256 == "" {
|
||||
revision := binaryRevision(bin)
|
||||
previous, resolveErr := gitCmd(ctx, root, "rev-parse", pending.PreviousCommit)
|
||||
if revision == "" || resolveErr != nil || revision != previous {
|
||||
return &Error{Reason: "binary_state_unknown", Message: "旧恢复记录缺少二进制哈希,无法确认当前二进制属于更新前版本;已保留当前二进制和 .prev,请人工核对后恢复"}
|
||||
}
|
||||
hash, err := fileSHA256(bin)
|
||||
if err != nil {
|
||||
return &Error{Reason: "binary_state_unknown", Message: "无法校验更新前二进制:" + err.Error()}
|
||||
}
|
||||
pending.PreviousBinarySHA256 = hash
|
||||
}
|
||||
if pending.PreviousBinarySHA256 != "" && fileExists(bin) {
|
||||
currentHash, hashErr := fileSHA256(bin)
|
||||
if hashErr != nil || currentHash != pending.PreviousBinarySHA256 {
|
||||
|
||||
@@ -66,6 +66,10 @@ func build(ctx context.Context, root, outPath string) error {
|
||||
// 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) {
|
||||
// Validate the staged file before moving the live executable aside.
|
||||
if err := os.Chmod(src, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prev := ""
|
||||
if fileExists(dst) {
|
||||
prev = dst + ".prev"
|
||||
@@ -76,9 +80,6 @@ func installBinary(src, dst string) (string, error) {
|
||||
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.
|
||||
@@ -127,12 +128,16 @@ func binaryRevision(path string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var revision string
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.modified" && setting.Value == "true" {
|
||||
return ""
|
||||
}
|
||||
if setting.Key == "vcs.revision" {
|
||||
return strings.TrimSpace(setting.Value)
|
||||
revision = strings.TrimSpace(setting.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return revision
|
||||
}
|
||||
|
||||
// tail keeps the last bytes of a compiler's output: the end of a build log is where the
|
||||
|
||||
@@ -586,3 +586,139 @@ func TestRetryAfterMergeRestoresOnlyUntouchedProtectedContent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy records cannot tell an installed rebuild from a binary still owed.
|
||||
// A different build of the same source must never be promoted into .prev.
|
||||
func TestLegacyPendingWithUnprovableBinaryPreservesRollback(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
scriptFakeGo(t)
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "original binary")
|
||||
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := readState(tr.install)
|
||||
st.BinaryCommit, st.BinarySHA256, st.PreviousBinarySHA256 = "", "", ""
|
||||
if err := writeState(tr.install, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, bin, "different build of the updated commit")
|
||||
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
|
||||
head := mustGit(t, tr.install, "rev-parse", "HEAD")
|
||||
_, err := Apply(context.Background(), buildOpts(tr), nil)
|
||||
if ue, ok := err.(*Error); !ok || ue.Reason != "binary_state_unknown" {
|
||||
t.Fatalf("want binary_state_unknown, got %v", err)
|
||||
}
|
||||
if got := readFile(t, bin+".prev"); got != "original binary" {
|
||||
t.Fatalf("rollback binary overwritten: %q", got)
|
||||
}
|
||||
if got := readFile(t, bin); got != "different build of the updated commit" {
|
||||
t.Fatalf("live binary overwritten: %q", got)
|
||||
}
|
||||
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head {
|
||||
t.Fatal("refusal moved HEAD")
|
||||
}
|
||||
if !buildPending(tr.install) {
|
||||
t.Fatal("refusal cleared recovery marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMarkerUsesRecordedHashForADifferentInstalledBuild(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
scriptFakeGo(t)
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "original binary")
|
||||
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, bin, "another valid build recorded by the updater")
|
||||
st, _ := readState(tr.install)
|
||||
var err error
|
||||
st.BinarySHA256, err = fileSHA256(bin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeState(tr.install, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, bin+".prev"); got != "original binary" {
|
||||
t.Fatalf("rollback binary overwritten: %q", got)
|
||||
}
|
||||
if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, bin); got != "original binary" {
|
||||
t.Fatalf("rollback binary = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBinaryMissingStagingKeepsLiveAndPrevious(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, Binary)
|
||||
writeFile(t, bin, "live binary")
|
||||
writeFile(t, bin+".prev", "rollback binary")
|
||||
if _, err := installBinary(filepath.Join(dir, "missing-staging"), bin); err == nil {
|
||||
t.Fatal("missing staging must fail")
|
||||
}
|
||||
if got := readFile(t, bin); got != "live binary" {
|
||||
t.Fatalf("live binary lost: %q", got)
|
||||
}
|
||||
if got := readFile(t, bin+".prev"); got != "rollback binary" {
|
||||
t.Fatalf("previous binary lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMarkerRefusesContradictoryState(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, reason string
|
||||
badCommit bool
|
||||
}{
|
||||
{name: "unrelated binary commit", reason: "bad_state", badCommit: true},
|
||||
{name: "changed binary hash", reason: "binary_changed"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
scriptFakeGo(t)
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "original binary")
|
||||
tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"})
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := readState(tr.install)
|
||||
if tc.badCommit {
|
||||
st.BinaryCommit = "not-a-commit"
|
||||
} else {
|
||||
st.BinarySHA256 = strings.Repeat("0", 64)
|
||||
}
|
||||
if err := writeState(tr.install, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n")
|
||||
live := readFile(t, bin)
|
||||
stateBytes := readFile(t, statePath(tr.install))
|
||||
_, err := Apply(context.Background(), buildOpts(tr), nil)
|
||||
if ue, ok := err.(*Error); !ok || ue.Reason != tc.reason {
|
||||
t.Fatalf("want %s, got %v", tc.reason, err)
|
||||
}
|
||||
if got := readFile(t, bin); got != live {
|
||||
t.Fatal("refusal changed live binary")
|
||||
}
|
||||
if got := readFile(t, bin+".prev"); got != "original binary" {
|
||||
t.Fatal("refusal changed rollback binary")
|
||||
}
|
||||
if got := readFile(t, statePath(tr.install)); got != stateBytes {
|
||||
t.Fatal("refusal rewrote state")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -545,16 +545,17 @@ for i, line in enumerate(lines):
|
||||
if not refused:
|
||||
if not replaced:
|
||||
insert_at = 0
|
||||
if lines:
|
||||
first = lines[0].rstrip("\r\n")
|
||||
if first.startswith("\ufeff"):
|
||||
first = first[1:]
|
||||
for i, line in enumerate(lines):
|
||||
first = line.rstrip("\r\n").lstrip("\ufeff")
|
||||
rest = first[3:] if first.startswith("---") else None
|
||||
if rest is not None and (not rest.strip(" \t") or rest[:1] in (" ", "\t") and rest.lstrip(" \t").startswith("#")):
|
||||
insert_at = 1
|
||||
if not lines[0].endswith(("\n", "\r")):
|
||||
lines[0] += "\n"
|
||||
out[0] = lines[0]
|
||||
insert_at = i + 1
|
||||
if not line.endswith(("\n", "\r")):
|
||||
out[i] += "\n"
|
||||
break
|
||||
trimmed = first.strip()
|
||||
if trimmed and not trimmed.startswith("#") and not first.startswith("%"):
|
||||
break
|
||||
out.insert(insert_at, 'version: "%s"\n' % tag)
|
||||
|
||||
original_mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
|
||||
@@ -158,7 +158,7 @@ test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
|
||||
assert.notEqual(conversationsIndex, -1);
|
||||
assert.ok(projectRetryIndex > conversationsIndex);
|
||||
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=20260907-blocked-1/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=[A-Za-z0-9._-]+/);
|
||||
});
|
||||
|
||||
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
|
||||
@@ -533,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
|
||||
assert.match(html, /style\.css\?v=20260907-blocked-1/);
|
||||
assert.match(html, /style\.css\?v=[A-Za-z0-9._-]+/);
|
||||
});
|
||||
@@ -41,7 +41,9 @@ test('刷新恢复会话时先完成权威审批配置同步再允许发送', ()
|
||||
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
|
||||
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
|
||||
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
|
||||
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
assert.match(hitlPage, /window\.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer\(\)/);
|
||||
assert.match(hitlPage, /window\.csaiHitlDefaultReviewerReady = window\.csaiHitlDefaultConfigReady/);
|
||||
});
|
||||
|
||||
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
|
||||
@@ -294,7 +296,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
|
||||
assert.match(template, /chat\.js\?v=20260907-blocked-1/);
|
||||
assert.match(template, /style\.css\?v=20260907-blocked-1/);
|
||||
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
|
||||
});
|
||||
|
||||
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
|
||||
|
||||
@@ -25,8 +25,12 @@ test('已有会话缺少本地配置时不会继承其他会话的最近审批
|
||||
test('服务端默认审批人只更新默认值,不覆盖最近会话选择', () => {
|
||||
const source = functionSource(hitl, 'applyHitlDefaultReviewerFromServer', 'fetchHitlDefaultReviewer');
|
||||
|
||||
assert.match(source, /window\.csaiHitlDefaultReviewer = v/);
|
||||
assert.match(source, /return applyHitlDefaultConfigFromServer\(\{ defaultReviewer: reviewer \}\)/);
|
||||
const configSource = functionSource(hitl, 'applyHitlDefaultConfigFromServer', 'fetchHitlDefaultConfig');
|
||||
assert.match(configSource, /const reviewer = hitlReviewerNormalize\(src\.defaultReviewer \|\| src\.reviewer \|\| 'human'\)/);
|
||||
assert.match(configSource, /window\.csaiHitlDefaultReviewer = reviewer/);
|
||||
assert.doesNotMatch(source, /saveHitlLastGlobalConfig/);
|
||||
assert.doesNotMatch(configSource, /saveHitlLastGlobalConfig|saveHitlConversationConfig/);
|
||||
});
|
||||
|
||||
test('恢复会话审批配置时保留该会话自己的审批人', () => {
|
||||
|
||||
Reference in new issue
Block a user