diff --git a/cmd/server/build_scripts_test.go b/cmd/server/build_scripts_test.go index 41e52d10..bdd7de94 100644 --- a/cmd/server/build_scripts_test.go +++ b/cmd/server/build_scripts_test.go @@ -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) { diff --git a/internal/config/config.go b/internal/config/config.go index 3a0509c5..968e29f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 30fc6544..5ba181b3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/update/apply.go b/internal/update/apply.go index 618783b8..f0ffc352 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -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 { diff --git a/internal/update/build.go b/internal/update/build.go index 10fa074c..f828112f 100644 --- a/internal/update/build.go +++ b/internal/update/build.go @@ -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 diff --git a/internal/update/pending_build_test.go b/internal/update/pending_build_test.go index 068a6caf..db4742ce 100644 --- a/internal/update/pending_build_test.go +++ b/internal/update/pending_build_test.go @@ -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") + } + }) + } +} diff --git a/upgrade.sh b/upgrade.sh index 6ba91df8..206a85a0 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -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) diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 339f4006..22282e99 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -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._-]+/); }); diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index cacb953c..e218da3f 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -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('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => { diff --git a/web/static/js/hitl-conversation-isolation.test.cjs b/web/static/js/hitl-conversation-isolation.test.cjs index f9483acf..045598a9 100644 --- a/web/static/js/hitl-conversation-isolation.test.cjs +++ b/web/static/js/hitl-conversation-isolation.test.cjs @@ -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('恢复会话审批配置时保留该会话自己的审批人', () => {