feat: add safe agent writeback flow

This commit is contained in:
Yoilun
2026-05-25 21:06:32 +08:00
parent ba975112de
commit 4b5237fda2
19 changed files with 969 additions and 24 deletions

View File

@@ -6,7 +6,7 @@
## Target Architecture
计划架构为Go 后端提供 localhost HTTP APIVue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型动态工作流事件模型;前端已通过集中 API client 接入这些只读端点并显示加载中、连接失败空数据状态。目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`
计划架构为Go 后端提供 localhost HTTP APIVue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 读取、项目配置只读读取、运行线程只读模型动态工作流事件模型,以及单个智能体 TOML 的草稿校验和确认写回;前端已通过集中 API client 接入这些端点并显示加载中、连接失败空数据和写回状态。
## Configuration
@@ -37,6 +37,22 @@ curl http://127.0.0.1:18083/api/health
curl http://127.0.0.1:18083/api/agents
```
校验智能体草稿,不写文件:
```bash
curl -X POST http://127.0.0.1:18083/api/agents/backend/validate \
-H 'Content-Type: application/json' \
-d '{"content":"name = \"后端架构师\"\n"}'
```
创建备份并写回单个智能体文件:
```bash
curl -X POST http://127.0.0.1:18083/api/agents/backend/write \
-H 'Content-Type: application/json' \
-d '{"content":"name = \"后端架构师\"\n","expectedHash":"<validate 返回的 currentHash>"}'
```
读取项目配置:
```bash
@@ -71,21 +87,26 @@ cd web
pnpm dev
```
前端开发地址为 `http://127.0.0.1:13083/`Phase 5 前端按视图调用只读 API项目视图读取 `/api/projects``/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample``api_missing` 等内部 source kind但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API项目视图读取 `/api/projects``/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate``/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample``api_missing` 等内部 source kind但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
## Security Boundaries
- 不读取 `.codex/auth.json`
- 不写入 Codex SQLite。
- 不读取或写入 `.codex/config.toml` 以外的项目只读解析;写回目标仅限 `CODEX_HOME/agents/{id}.toml` 直属文件。
- SQLite 通过纯 Go `modernc.org/sqlite v1.35.0``mode=ro&immutable=1` 打开;缺失 SQLite 返回空列表和低置信度来源说明。
- 运行线程 API 返回聚合 `source` 和分数据源 `sources`;仅 `state_5.sqlite` 或仅 `goals_1.sqlite` 缺失时,聚合来源为 `sqlite_partial` / `medium`,缺失的一侧为 `sqlite_missing` / `low`
- 运行线程读取使用 `PRAGMA table_info` 适配 schema drift缺关键列时对应表返回空列表和 `sqlite_schema_drift` / `low` 证据可选列缺失、NULL 和数值字段按空字符串或文本值处理。
- `.codex/agents/*.toml` 写回必须先备份。
- 当前 `/api/agents` 只读列出 `.codex/agents` 直属 `.toml` 文件,读取前通过 Codex home 边界和 agent TOML 专用 resolver坏 TOML 以单条 `invalid` 状态返回,不导致服务崩溃。
- `POST /api/agents/{id}/validate` 只校验请求体中的 TOML 草稿、生成 diff、返回目标路径、当前 sha256 hash 和字段变更;该端点不写文件。
- `POST /api/agents/{id}/write` 会重新校验 TOML重新读取目标文件并比较 `expectedHash`,先创建 `*.bak-<timestamp>` 备份,备份成功后通过同目录临时文件和 rename 原子写回。
- 写回 id 只允许安全 bare file stem字母、数字、下划线、连字符且不能包含路径分隔符、绝对路径、`.toml` 扩展名或子目录。`agents` 目录 symlink、目标叶子 symlink、非普通文件和路径穿越都会被拒绝。
- 无效 TOML、hash 冲突、备份失败或临时文件写入失败都不会替换原文件;临时文件会尽量清理。
- 当前 `/api/projects` 只读解析 `.codex/config.toml` 中的 `[projects."..."]`,展示路径、显示名、信任等级和目录存在性。
- 当前 `/api/workflow/events` 从运行线程、spawn edges、goals 和计划文件证据生成事件流/交接边/阶段状态,不写死固定流程。
- Phase 4 前端不调用真实 API、不保存草稿、不写回 `.codex/agents/*.toml`;所有写回相关控件仅作为只读步骤展示。
- Phase 5 前端只调用 GET 只读端点,不新增保存、草稿写入或后端写入逻辑
- Phase 6 前端仅在智能体视图提供单文件草稿入口,按钮为“校验 TOML”“查看差异”“创建备份并写回”只有校验成功且存在 currentHash 时才允许写回,写回前使用浏览器确认
- Phase 5 前端 normalizer 负责把 source kind、confidence、状态和 parseStatus 转成中文显示,并过滤工作流中非阶段表格行。
## Known Risks

View File

@@ -36,3 +36,6 @@
- Phase 5 前端新增集中 API client仅调用 `/api/agents``/api/projects``/api/runtime/threads``/api/workflow/events` 四个 GET 只读端点。
- Phase 5 前端 normalizer 统一把后端 source kind、confidence、状态、parseStatus 转为中文展示;空 runtime/workflow 不回退到示例数据,连接失败时示例数据必须标注“示例/等待连接”。
- 工作流 phases 需要在前端过滤计划文件中误解析出的非阶段表格行,并把数字阶段名展示为“阶段 N”避免出现内部英文或无效状态。
- Phase 6 写回只支持单个已有 agent TOML 文件,不创建新 agentvalidate 返回当前 sha256write 使用 expectedHash 阻止校验后覆盖用户改动。
- Phase 6 备份文件写在目标文件旁边,命名为 `*.bak-<timestamp>`;只有备份创建成功后才使用同目录临时文件和 rename 替换目标。
- Phase 6 前端不维护批量草稿队列,不使用 localStorage 自动保存草稿;写回入口在智能体视图,用户确认后才调用 write API。

View File

@@ -6,6 +6,7 @@ type AgentDefinition struct {
ID string `json:"id"`
FilePath string `json:"filePath"`
FileName string `json:"fileName"`
Content string `json:"content"`
Name string `json:"name"`
Description string `json:"description"`
DeveloperInstructions string `json:"developerInstructions"`
@@ -15,3 +16,25 @@ type AgentDefinition struct {
ParseError string `json:"parseError,omitempty"`
DraftStatus string `json:"draftStatus"`
}
type FieldChange struct {
Field string `json:"field"`
Before string `json:"before"`
After string `json:"after"`
}
type DraftValidation struct {
Valid bool `json:"valid"`
Errors []string `json:"errors"`
Diff string `json:"diff"`
TargetPath string `json:"targetPath"`
CurrentHash string `json:"currentHash"`
FieldChanges []FieldChange `json:"fieldChanges"`
}
type WriteResult struct {
Status string `json:"status"`
TargetPath string `json:"targetPath"`
BackupPath string `json:"backupPath"`
CurrentHash string `json:"currentHash"`
}

View File

@@ -90,6 +90,7 @@ func (s Store) readOne(fileName string) AgentDefinition {
def.ParseError = err.Error()
return def
}
def.Content = string(data)
values, err := parseSimpleTOML(string(data))
if err != nil {
def.ParseStatus = "invalid"

View File

@@ -0,0 +1,228 @@
package agents
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"codex-agent-manager/internal/codexhome"
)
var ErrWriteConflict = errors.New("目标文件已在校验后发生变化")
var safeAgentID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
func (s Store) ValidateDraft(id string, content string) (DraftValidation, error) {
targetPath, current, _, err := s.readWriteTarget(id)
if err != nil {
return DraftValidation{}, err
}
result := DraftValidation{
TargetPath: targetPath,
CurrentHash: hashBytes(current),
}
currentFields, currentErr := parseSimpleTOML(string(current))
draftFields, draftErr := parseSimpleTOML(content)
if draftErr != nil {
result.Valid = false
result.Errors = []string{draftErr.Error()}
result.Diff = simpleDiff(string(current), content)
return result, nil
}
result.Valid = true
result.Errors = []string{}
result.Diff = simpleDiff(string(current), content)
if currentErr == nil {
result.FieldChanges = changedFields(currentFields, draftFields)
}
return result, nil
}
func (s Store) WriteDraft(id string, content string, expectedHash string) (WriteResult, error) {
validation, err := s.ValidateDraft(id, content)
if err != nil {
return WriteResult{}, err
}
if !validation.Valid {
return WriteResult{}, errors.New(strings.Join(validation.Errors, ""))
}
if expectedHash == "" || validation.CurrentHash != expectedHash {
return WriteResult{}, ErrWriteConflict
}
targetPath, current, mode, err := s.readWriteTarget(id)
if err != nil {
return WriteResult{}, err
}
if hashBytes(current) != expectedHash {
return WriteResult{}, ErrWriteConflict
}
backupPath, err := s.createBackup(targetPath, current, mode)
if err != nil {
return WriteResult{}, err
}
if err := atomicWrite(targetPath, []byte(content), mode); err != nil {
return WriteResult{}, err
}
return WriteResult{
Status: "written",
TargetPath: targetPath,
BackupPath: backupPath,
CurrentHash: hashBytes([]byte(content)),
}, nil
}
func (s Store) readWriteTarget(id string) (string, []byte, os.FileMode, error) {
if !safeAgentID.MatchString(id) {
return "", nil, 0, codexhome.ErrForbiddenPath
}
agentsPath := filepath.Join(s.CodexHome, "agents")
if info, err := os.Lstat(agentsPath); err != nil {
return "", nil, 0, err
} else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", nil, 0, codexhome.ErrForbiddenPath
}
fileName := id + ".toml"
targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, fileName)
if err != nil {
return "", nil, 0, err
}
info, err := os.Lstat(targetPath)
if err != nil {
return "", nil, 0, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", nil, 0, codexhome.ErrForbiddenPath
}
data, err := os.ReadFile(targetPath)
if err != nil {
return "", nil, 0, err
}
return targetPath, data, info.Mode().Perm(), nil
}
func (s Store) createBackup(targetPath string, content []byte, mode os.FileMode) (string, error) {
backupPath := fmt.Sprintf("%s.bak-%s", targetPath, time.Now().UTC().Format("20060102T150405.000000000Z"))
file, err := os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return "", err
}
if _, err := file.Write(content); err != nil {
_ = file.Close()
_ = os.Remove(backupPath)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(backupPath)
return "", err
}
return backupPath, nil
}
func atomicWrite(targetPath string, content []byte, mode os.FileMode) error {
dir := filepath.Dir(targetPath)
base := filepath.Base(targetPath)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(content); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, targetPath)
}
func hashBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func changedFields(before map[string]string, after map[string]string) []FieldChange {
keys := map[string]bool{}
for key := range before {
keys[key] = true
}
for key := range after {
keys[key] = true
}
ordered := make([]string, 0, len(keys))
for key := range keys {
ordered = append(ordered, key)
}
sort.Strings(ordered)
changes := make([]FieldChange, 0)
for _, key := range ordered {
if before[key] == after[key] {
continue
}
changes = append(changes, FieldChange{Field: key, Before: before[key], After: after[key]})
}
return changes
}
func simpleDiff(before string, after string) string {
if before == after {
return "无差异\n"
}
beforeLines := splitLines(before)
afterLines := splitLines(after)
prefix := 0
for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] {
prefix++
}
suffix := 0
for suffix < len(beforeLines)-prefix &&
suffix < len(afterLines)-prefix &&
beforeLines[len(beforeLines)-1-suffix] == afterLines[len(afterLines)-1-suffix] {
suffix++
}
var b strings.Builder
b.WriteString("--- current\n+++ draft\n@@\n")
for _, line := range beforeLines[prefix : len(beforeLines)-suffix] {
b.WriteString("-")
b.WriteString(line)
b.WriteString("\n")
}
for _, line := range afterLines[prefix : len(afterLines)-suffix] {
b.WriteString("+")
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}
func splitLines(input string) []string {
trimmed := strings.TrimSuffix(input, "\n")
if trimmed == "" {
return []string{}
}
return strings.Split(trimmed, "\n")
}

View File

@@ -0,0 +1,161 @@
package agents
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateInvalidTOMLReturnsInvalidAndDoesNotWrite(t *testing.T) {
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
got, err := store.ValidateDraft("backend", `name = "未闭合`+"\n")
if err != nil {
t.Fatalf("ValidateDraft returned error: %v", err)
}
if got.Valid {
t.Fatalf("validation should be invalid: %#v", got)
}
if len(got.Errors) == 0 {
t.Fatalf("expected validation errors: %#v", got)
}
assertFileContent(t, target, `name = "旧名称"`+"\n")
}
func TestValidateValidTOMLReturnsDiffAndCurrentHash(t *testing.T) {
store, _ := writebackFixture(t, `name = "旧名称"`+"\ndescription = \"旧描述\"\n")
got, err := store.ValidateDraft("backend", `name = "新名称"`+"\ndescription = \"旧描述\"\n")
if err != nil {
t.Fatalf("ValidateDraft returned error: %v", err)
}
if !got.Valid {
t.Fatalf("validation should be valid: %#v", got)
}
if got.CurrentHash == "" {
t.Fatalf("current hash must be returned: %#v", got)
}
if !strings.Contains(got.Diff, `-name = "旧名称"`) || !strings.Contains(got.Diff, `+name = "新名称"`) {
t.Fatalf("diff does not show changed line: %q", got.Diff)
}
if len(got.FieldChanges) != 1 || got.FieldChanges[0].Field != "name" {
t.Fatalf("unexpected field changes: %#v", got.FieldChanges)
}
}
func TestWriteInvalidTOMLRejectsAndLeavesOriginal(t *testing.T) {
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
if err != nil {
t.Fatal(err)
}
_, err = store.WriteDraft("backend", `name = "未闭合`+"\n", validation.CurrentHash)
if err == nil {
t.Fatal("expected invalid TOML write to be rejected")
}
assertFileContent(t, target, `name = "旧名称"`+"\n")
}
func TestWriteExpectedHashMismatchRejectsAndLeavesOriginal(t *testing.T) {
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(`name = "用户已改"`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err = store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash)
if !errors.Is(err, ErrWriteConflict) {
t.Fatalf("expected conflict error, got %v", err)
}
assertFileContent(t, target, `name = "用户已改"`+"\n")
}
func TestWriteSuccessCreatesBackupAndAtomicallyWrites(t *testing.T) {
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
if err != nil {
t.Fatal(err)
}
got, err := store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash)
if err != nil {
t.Fatalf("WriteDraft returned error: %v", err)
}
if got.Status != "written" || got.TargetPath != target || got.BackupPath == "" {
t.Fatalf("unexpected write result: %#v", got)
}
assertFileContent(t, target, `name = "新名称"`+"\n")
assertFileContent(t, got.BackupPath, `name = "旧名称"`+"\n")
}
func TestWriteRejectsUnsafeIDAndSymlinks(t *testing.T) {
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"../backend", "nested/backend", "/tmp/backend", "backend.toml"} {
t.Run("unsafe id "+id, func(t *testing.T) {
if _, err := store.ValidateDraft(id, `name = "x"`+"\n"); err == nil {
t.Fatal("expected unsafe validate id to be rejected")
}
if _, err := store.WriteDraft(id, `name = "x"`+"\n", validation.CurrentHash); err == nil {
t.Fatal("expected unsafe write id to be rejected")
}
})
}
if err := os.Remove(target); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../config.toml", target); err != nil {
t.Fatal(err)
}
if _, err := store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash); err == nil {
t.Fatal("expected symlink leaf to be rejected")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte(`name = "secret"`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(".", filepath.Join(root, "agents")); err != nil {
t.Fatal(err)
}
symlinkedStore := Store{CodexHome: root}
if _, err := symlinkedStore.WriteDraft("backend", `name = "x"`+"\n", validation.CurrentHash); err == nil {
t.Fatal("expected symlinked agents directory to be rejected")
}
}
func writebackFixture(t *testing.T, content string) (Store, string) {
t.Helper()
root := t.TempDir()
agentsDir := filepath.Join(root, "agents")
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(agentsDir, "backend.toml")
if err := os.WriteFile(target, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return Store{CodexHome: root}, target
}
func assertFileContent(t *testing.T, path string, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", path, string(got), want)
}
}

View File

@@ -2,10 +2,13 @@ package server
import (
"encoding/json"
"errors"
"net/http"
"strings"
"codex-agent-manager/internal/agents"
"codex-agent-manager/internal/app"
"codex-agent-manager/internal/codexhome"
"codex-agent-manager/internal/projects"
"codex-agent-manager/internal/runtime"
"codex-agent-manager/internal/workflow"
@@ -37,6 +40,25 @@ func New(cfg app.Config) http.Handler {
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
})
mux.HandleFunc("/api/agents/", func(w http.ResponseWriter, r *http.Request) {
id, action, ok := parseAgentActionPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
switch action {
case "validate":
handleAgentValidate(w, r, agentStore, id)
case "write":
handleAgentWrite(w, r, agentStore, id)
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
@@ -87,6 +109,62 @@ func New(cfg app.Config) http.Handler {
return mux
}
type validateRequest struct {
Content string `json:"content"`
}
type writeRequest struct {
Content string `json:"content"`
ExpectedHash string `json:"expectedHash"`
}
func parseAgentActionPath(path string) (string, string, bool) {
rest := strings.TrimPrefix(path, "/api/agents/")
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func handleAgentValidate(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
var body validateRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
return
}
result, err := store.ValidateDraft(id, body.Content)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func handleAgentWrite(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
var body writeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
return
}
result, err := store.WriteDraft(id, body.Content, body.ExpectedHash)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAgentError(w http.ResponseWriter, err error) {
status := http.StatusBadRequest
if errors.Is(err, agents.ErrWriteConflict) {
status = http.StatusConflict
} else if errors.Is(err, codexhome.ErrForbiddenPath) || errors.Is(err, codexhome.ErrOutsideCodexHome) {
status = http.StatusForbidden
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -1,12 +1,14 @@
package server
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codex-agent-manager/internal/app"
@@ -199,3 +201,113 @@ func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) {
}
}
}
func TestAgentValidateEndpointReturnsDiffAndRejectsUnsupportedMethods(t *testing.T) {
root := t.TempDir()
agentsDir := filepath.Join(root, "agents")
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsDir, "backend.toml"), []byte(`name = "旧名称"`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"}`))
rec := httptest.NewRecorder()
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body struct {
Valid bool `json:"valid"`
Diff string `json:"diff"`
CurrentHash string `json:"currentHash"`
TargetPath string `json:"targetPath"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid json: %v", err)
}
if !body.Valid || body.CurrentHash == "" || body.TargetPath == "" || !strings.Contains(body.Diff, "新名称") {
t.Fatalf("unexpected validate body: %#v", body)
}
req = httptest.NewRequest(http.MethodGet, "/api/agents/backend/validate", nil)
rec = httptest.NewRecorder()
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET validate status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
}
func TestAgentValidateEndpointReturnsBadRequestForInvalidBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{`))
rec := httptest.NewRecorder()
New(app.Config{CodexHome: t.TempDir(), HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestAgentWriteEndpointCreatesBackupAndRejectsConflicts(t *testing.T) {
root := t.TempDir()
agentsDir := filepath.Join(root, "agents")
target := filepath.Join(agentsDir, "backend.toml")
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(`name = "旧名称"`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
handler := New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"})
validateReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"}`))
validateRec := httptest.NewRecorder()
handler.ServeHTTP(validateRec, validateReq)
if validateRec.Code != http.StatusOK {
t.Fatalf("validate status = %d, body = %s", validateRec.Code, validateRec.Body.String())
}
var validation struct {
CurrentHash string `json:"currentHash"`
}
if err := json.Unmarshal(validateRec.Body.Bytes(), &validation); err != nil {
t.Fatal(err)
}
writeBody := `{"content":"name = \"新名称\"\n","expectedHash":"` + validation.CurrentHash + `"}`
writeReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/write", bytes.NewBufferString(writeBody))
writeRec := httptest.NewRecorder()
handler.ServeHTTP(writeRec, writeReq)
if writeRec.Code != http.StatusOK {
t.Fatalf("write status = %d, body = %s", writeRec.Code, writeRec.Body.String())
}
var written struct {
Status string `json:"status"`
TargetPath string `json:"targetPath"`
BackupPath string `json:"backupPath"`
}
if err := json.Unmarshal(writeRec.Body.Bytes(), &written); err != nil {
t.Fatal(err)
}
if written.Status != "written" || written.TargetPath != target || written.BackupPath == "" {
t.Fatalf("unexpected write body: %#v", written)
}
if data, err := os.ReadFile(target); err != nil || string(data) != `name = "新名称"`+"\n" {
t.Fatalf("target content = %q, err = %v", string(data), err)
}
conflictReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/write", bytes.NewBufferString(writeBody))
conflictRec := httptest.NewRecorder()
handler.ServeHTTP(conflictRec, conflictReq)
if conflictRec.Code != http.StatusConflict {
t.Fatalf("conflict status = %d, want %d, body = %s", conflictRec.Code, http.StatusConflict, conflictRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/agents/backend/write", nil)
getRec := httptest.NewRecorder()
handler.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET write status = %d, want %d", getRec.Code, http.StatusMethodNotAllowed)
}
}

View File

@@ -27,6 +27,7 @@
| 2026-05-25 | 5 | coding agent | TDD 接入前端只读 API client、normalizer 和项目/工作流/智能体真实数据视图 | 完成;提交前已通过测试、构建和本地接口 smoke 验证 |
| 2026-05-25 | 5 | spec review | 规格审查未通过valid agent 状态不明确,工作流和 agent 只读文案仍含内部英文 | coding agent 按 blocking 范围修复 |
| 2026-05-25 | 5 | quality review | 代码质量审查未通过:未知后端枚举值会直接进入 UI label | coding agent 按 blocking 范围修复 |
| 2026-05-25 | 6 | coding agent | TDD 实现智能体草稿校验、diff、hash 冲突检测、备份和原子写回 | 完成;待最终全量验证 |
## Test Results
@@ -143,6 +144,15 @@
| 2026-05-25 | `pnpm build` | PASS | Phase 5 unknown workflow phase 修复后前端构建通过 |
| 2026-05-25 | `go test ./...` | PASS | Phase 5 unknown workflow phase 修复未改 Go 行为;全量 Go 回归通过 |
| 2026-05-25 | `git diff --check` | PASS | Phase 5 unknown workflow phase 修复 whitespace 检查通过 |
| 2026-05-25 | `go test ./internal/agents ./internal/server` | FAIL | TDD 红灯:`ValidateDraft`/`WriteDraft` 未实现validate/write 端点返回 404 |
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯writeback normalizer 和 validate/write client 方法未实现 |
| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 后端 validate/write、hash 冲突、备份、路径和 symlink 边界测试通过 |
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 前端 client/normalizer 写回状态测试通过;共 13 个单测 |
| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 指定后端目标包验证通过 |
| 2026-05-25 | `go test ./...` | PASS | Phase 6 全量 Go 验证通过 |
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 前端单测验证通过;共 13 个单测 |
| 2026-05-25 | `cd web && pnpm build` | PASS | Phase 6 前端生产构建通过 |
| 2026-05-25 | `git diff --check` | PASS | Phase 6 whitespace 检查通过 |
## Bug Loop
@@ -169,3 +179,5 @@
| 5 | valid agent 状态只显示“已读取”,且工作流/智能体可见文案残留内部英文 | normalizer 改为“TOML 有效”/“TOML 无效”角色设定字段改中文WorkflowView 改“交接边”和“主智能体” | `pnpm test` PASS |
| 5 | 未知后端枚举值可通过 source/confidence/status/trust label 暴露到 UI | 未知 source 显示“来源未知”,未知 confidence 显示“低”,未知 status/trust 显示“未知” | `pnpm test` PASS |
| 5 | 未知 workflow phase status 被白名单过滤,真实阶段从 UI 消失 | phase 过滤改为只排除表头/伪行,未知 status 交给中文状态兜底显示“未知” | `pnpm test` PASS |
| 6 | 写回可能覆盖校验后用户修改的文件 | validate 返回当前 sha256write 重新读取并比较 expectedHash不匹配返回冲突且不写回 | `go test ./internal/agents ./internal/server` PASS |
| 6 | 无效 TOML 或 unsafe id/symlink 可能进入写回路径 | write 重新执行 TOML 校验id 只允许安全 bare stem拒绝 leaf symlink 和 symlinked agents 目录 | `go test ./internal/agents ./internal/server` PASS |

View File

@@ -23,7 +23,7 @@
| 3 | complete | 项目配置、运行线程和工作流模型 | 能读取 projects、threads、spawn edges、goals状态含来源/置信度;工作流不写死固定流程 |
| 4 | complete | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 |
| 5 | complete | API 集成和只读数据显示 | 前端连接只读 API显示真实 agent 数据和错误状态 |
| 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
| 6 | complete | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
| 7 | pending | 集成验证与文档 | 测试/构建/浏览器验证通过;文档完整 |
## Errors Encountered

View File

@@ -23,14 +23,14 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
<div class="app-shell">
<header class="workspace-header">
<div>
<p class="eyebrow">本地只读工作台</p>
<p class="eyebrow">本地安全工作台</p>
<h1>Codex 智能体管理台</h1>
<p class="lede">前端按视图连接后端只读接口显示真实数据加载中连接失败和空数据状态</p>
<p class="lede">前端按视图连接后端接口显示真实数据加载中连接失败空数据和单文件写回状态</p>
</div>
<div class="connection-card" aria-label="连接状态">
<StatusBadge label="只读接口" status="complete" confidence="中" source="计划文件" />
<strong>按需读取后端数据</strong>
<span>项目运行线程工作流和智能体视图只调用只读端点失败时显示示例/等待连接标注</span>
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
<strong>按需读取和确认写回</strong>
<span>项目运行线程和工作流保持只读智能体草稿仅在校验备份和确认后单文件写回</span>
</div>
</header>

View File

@@ -56,4 +56,18 @@ export const apiClient = {
getWorkflowEvents() {
return requestJSON('/api/workflow/events')
},
validateAgentDraft(id, content) {
return requestJSON(`/api/agents/${encodeURIComponent(id)}/validate`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
},
writeAgentDraft(id, content, expectedHash) {
return requestJSON(`/api/agents/${encodeURIComponent(id)}/write`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ content, expectedHash }),
})
},
}

View File

@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { apiClient } from './client.js'
test('api client keeps readonly APIs on GET and uses POST only for validate/write', async () => {
const calls = []
globalThis.fetch = async (path, options = {}) => {
calls.push({ path, method: options.method ?? 'GET', body: options.body ?? '' })
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
}
await apiClient.getAgents()
await apiClient.getProjects()
await apiClient.getRuntimeThreads()
await apiClient.getWorkflowEvents()
await apiClient.validateAgentDraft('backend', 'name = "新名称"\n')
await apiClient.writeAgentDraft('backend', 'name = "新名称"\n', 'abc123')
assert.deepEqual(
calls.map((call) => [call.path, call.method]),
[
['/api/agents', 'GET'],
['/api/projects', 'GET'],
['/api/runtime/threads', 'GET'],
['/api/workflow/events', 'GET'],
['/api/agents/backend/validate', 'POST'],
['/api/agents/backend/write', 'POST'],
],
)
assert.equal(calls[4].body, JSON.stringify({ content: 'name = "新名称"\n' }))
assert.equal(calls[5].body, JSON.stringify({ content: 'name = "新名称"\n', expectedHash: 'abc123' }))
})

View File

@@ -247,10 +247,64 @@ export function normalizeWorkflow(payload = {}) {
}
}
export function normalizeValidationResult(result = {}) {
const valid = result.valid === true
const errors = Array.isArray(result.errors) ? result.errors.filter(Boolean) : []
return {
valid,
errors,
diff: result.diff || '',
targetPath: result.targetPath || '',
currentHash: result.currentHash || '',
fieldChanges: Array.isArray(result.fieldChanges) ? result.fieldChanges : [],
statusLabel: valid ? 'TOML 有效' : 'TOML 无效',
errorText: errors.join('') || (valid ? '' : 'TOML 校验失败'),
steps: valid ? ['草稿', '已校验'] : ['草稿'],
}
}
export function normalizeWritebackResult(result = {}) {
return {
status: result.status || 'unknown',
statusLabel: result.status === 'written' ? '已写回' : '未知',
targetPath: result.targetPath || '',
backupPath: result.backupPath || '',
currentHash: result.currentHash || '',
steps: result.status === 'written' ? ['草稿', '已校验', '已备份', '已写回'] : ['草稿'],
summary: `目标路径:${result.targetPath || '未返回'};备份路径:${result.backupPath || '未返回'}`,
}
}
export function normalizeDraftWriteback({ validation = null, writeback = null } = {}) {
const normalizedValidation = validation
? (Object.hasOwn(validation, 'statusLabel') ? validation : normalizeValidationResult(validation))
: null
const normalizedWriteback = writeback
? (Object.hasOwn(writeback, 'statusLabel') ? writeback : normalizeWritebackResult(writeback))
: null
const canWrite = Boolean(normalizedValidation?.valid && normalizedValidation.currentHash && !normalizedWriteback)
return {
canWrite,
validation: normalizedValidation,
writeback: normalizedWriteback,
steps: normalizedWriteback?.steps ?? normalizedValidation?.steps ?? ['草稿'],
writeDisabledReason: canWrite
? ''
: normalizedValidation && !normalizedValidation.valid
? 'TOML 无效,不能写回'
: normalizedWriteback
? '已写回'
: '请先校验 TOML',
}
}
function synthesizeAgentPreview(agent, { isInvalid, description }) {
if (isInvalid) {
return `# TOML 无效,仅只读展示解析错误\n# ${description}`
}
if (agent.content) {
return agent.content
}
const lines = [
'# 接口未返回原始 TOML以下为只读展示的已解析字段',

View File

@@ -6,8 +6,11 @@ import {
formatSourceKind,
formatStatus,
normalizeAgent,
normalizeDraftWriteback,
normalizeProject,
normalizeRuntime,
normalizeValidationResult,
normalizeWritebackResult,
normalizeWorkflow,
} from './normalizers.js'
import { settings } from '../data.js'
@@ -164,3 +167,32 @@ test('keeps workflow phases with unknown backend status visible', () => {
assert.equal(workflow.phases[0].name, '阶段 5')
assert.equal(workflow.phases[0].label, '未知')
})
test('normalizes writeback response with Chinese steps and paths', () => {
const result = normalizeWritebackResult({
status: 'written',
targetPath: '/tmp/codex/agents/backend.toml',
backupPath: '/tmp/codex/agents/backend.toml.bak-20260525',
})
assert.deepEqual(result.steps, ['草稿', '已校验', '已备份', '已写回'])
assert.equal(result.statusLabel, '已写回')
assert.equal(result.targetPath, '/tmp/codex/agents/backend.toml')
assert.equal(result.backupPath, '/tmp/codex/agents/backend.toml.bak-20260525')
assert.match(result.summary, /目标路径/)
assert.match(result.summary, /备份路径/)
})
test('invalid validation disables writeback state', () => {
const validation = normalizeValidationResult({
valid: false,
errors: ['第 1 行不是有效的键值字段'],
targetPath: '/tmp/codex/agents/backend.toml',
})
const draft = normalizeDraftWriteback({ validation })
assert.equal(validation.statusLabel, 'TOML 无效')
assert.equal(draft.canWrite, false)
assert.equal(draft.writeDisabledReason, 'TOML 无效,不能写回')
assert.deepEqual(draft.steps, ['草稿'])
})

View File

@@ -158,6 +158,6 @@ export const settings = [
{ name: 'Codex 主目录', value: '/Users/yoilun/.codex', detail: '由后端配置提供', enabled: true },
{ name: 'SQLite 状态读取', value: '只读 mode=ro&immutable=1', detail: '通过只读接口展示', enabled: true },
{ name: '本机进程辅助判断', value: '等待后端聚合', detail: '只显示来源与置信度,不伪装确定状态', enabled: true },
{ name: '写回能力', value: '阶段 6 才启用', detail: '当前没有保存、写入或批量写回按钮', enabled: false },
{ name: '写回能力', value: '单文件确认写回', detail: '只允许校验后创建备份并写回一个智能体 TOML', enabled: true },
{ name: '敏感文件黑名单', value: 'auth.json', detail: '前端只展示规则摘要,不读取内容', enabled: true },
]

View File

@@ -650,6 +650,60 @@ button {
border-color: var(--green);
}
.action-tabs button {
flex: 0 0 auto;
padding: 9px 12px;
color: var(--green);
background: var(--panel-muted);
border: 1px solid var(--line);
border-radius: 8px;
font-weight: 700;
}
.action-tabs button:not(:disabled):hover {
border-color: var(--green);
}
.action-tabs button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.draft-workspace {
display: grid;
gap: 14px;
}
.draft-editor {
display: grid;
gap: 8px;
}
.draft-editor span,
.draft-hint {
color: var(--muted);
font-size: 0.86rem;
font-weight: 700;
}
.draft-editor textarea {
width: 100%;
min-height: 320px;
padding: 14px;
color: #25322d;
background: #ebe4d8;
border: 1px solid var(--line);
border-radius: 8px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
line-height: 1.6;
resize: vertical;
}
.draft-hint {
margin: 0;
}
.readonly-code {
overflow: auto;
padding: 16px;

View File

@@ -1,21 +1,54 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import StatusBadge from '../components/StatusBadge.vue'
import WritebackSteps from '../components/WritebackSteps.vue'
import { apiClient } from '../api/client'
import { normalizeAgents } from '../api/normalizers'
import {
normalizeAgents,
normalizeDraftWriteback,
normalizeValidationResult,
normalizeWritebackResult,
} from '../api/normalizers'
import { agents as sampleAgents } from '../data'
const loading = ref(true)
const error = ref('')
const agentState = ref(normalizeAgents())
const selectedId = ref('')
const draftContent = ref('')
const draftError = ref('')
const validation = ref(null)
const writeback = ref(null)
const validating = ref(false)
const writing = ref(false)
const showDiff = ref(false)
const validatedContent = ref('')
const displayAgents = computed(() => (error.value ? sampleAgents.map((agent) => ({ ...agent, name: `示例:${agent.name}`, statusLabel: agent.status })) : agentState.value.agents))
const selectedAgent = computed(() => displayAgents.value.find((agent) => agent.id === selectedId.value) ?? displayAgents.value[0])
const draftFlow = computed(() => normalizeDraftWriteback({ validation: validation.value, writeback: writeback.value }))
const canUseWriteback = computed(() => Boolean(selectedAgent.value && !error.value && selectedAgent.value.parseStatus !== 'invalid'))
watch(displayAgents, (items) => {
selectedId.value = items[0]?.id ?? ''
})
watch(selectedAgent, (agent) => {
draftContent.value = agent?.toml ?? ''
validation.value = null
writeback.value = null
draftError.value = ''
showDiff.value = false
validatedContent.value = ''
})
watch(draftContent, (content) => {
if (!validation.value || content === validatedContent.value) return
validation.value = null
writeback.value = null
showDiff.value = false
draftError.value = '草稿已修改,请重新校验 TOML'
})
onMounted(loadAgents)
async function loadAgents() {
@@ -29,6 +62,47 @@ async function loadAgents() {
loading.value = false
}
}
async function validateDraft() {
if (!selectedAgent.value) return
validating.value = true
draftError.value = ''
writeback.value = null
try {
validation.value = normalizeValidationResult(
await apiClient.validateAgentDraft(selectedAgent.value.id, draftContent.value),
)
validatedContent.value = draftContent.value
showDiff.value = validation.value.valid
} catch (err) {
validation.value = null
draftError.value = err?.message || '校验请求失败'
} finally {
validating.value = false
}
}
function toggleDiff() {
showDiff.value = !showDiff.value
}
async function writeDraft() {
if (!selectedAgent.value || !validation.value?.currentHash) return
if (!window.confirm(`确认创建备份并写回 ${selectedAgent.value.fileName}`)) {
return
}
writing.value = true
draftError.value = ''
try {
writeback.value = normalizeWritebackResult(
await apiClient.writeAgentDraft(selectedAgent.value.id, draftContent.value, validation.value.currentHash),
)
} catch (err) {
draftError.value = err?.message || '写回请求失败'
} finally {
writing.value = false
}
}
</script>
<template>
@@ -104,20 +178,65 @@ async function loadAgents() {
</label>
</div>
<div class="subtabs" aria-label="智能体信息子标签">
<span class="active">预览</span>
<span>TOML</span>
<span>差异</span>
<span>备份</span>
<div class="subtabs action-tabs" aria-label="智能体草稿操作">
<button
type="button"
:disabled="!canUseWriteback || validating"
@click="validateDraft"
>
{{ validating ? '校验中' : '校验 TOML' }}
</button>
<button
type="button"
:disabled="!validation"
@click="toggleDiff"
>
查看差异
</button>
<button
type="button"
:disabled="!draftFlow.canWrite || writing"
@click="writeDraft"
>
{{ writing ? '写回中' : '创建备份并写回' }}
</button>
</div>
<div v-if="selectedAgent" class="readonly-code">
<pre>{{ selectedAgent.toml }}</pre>
<div v-if="selectedAgent" class="draft-workspace">
<WritebackSteps :active-steps="draftFlow.steps" />
<label class="draft-editor">
<span>草稿内容</span>
<textarea
v-model="draftContent"
:readonly="!canUseWriteback"
rows="14"
aria-label="智能体 TOML 草稿内容"
></textarea>
</label>
<div v-if="draftError" class="empty-state compact error-state">
<strong>操作失败</strong>
<p>{{ draftError }}</p>
</div>
<div v-if="validation" class="empty-state compact" :class="{ 'error-state': !validation.valid }">
<strong>{{ validation.statusLabel }}</strong>
<p v-if="validation.valid">
目标路径{{ validation.targetPath }}字段变更{{ validation.fieldChanges.length ? validation.fieldChanges.map((item) => item.field).join('、') : '无' }}
</p>
<p v-else>{{ validation.errorText }}</p>
</div>
<div v-if="validation && showDiff" class="readonly-code">
<pre>{{ validation.diff || '无差异' }}</pre>
</div>
<div v-if="writeback" class="empty-state compact">
<strong>{{ writeback.statusLabel }}</strong>
<p>{{ writeback.summary }}</p>
</div>
<p v-if="!draftFlow.canWrite && !writeback" class="draft-hint">{{ draftFlow.writeDisabledReason }}</p>
</div>
<div class="empty-state compact">
<strong>当前阶段没有保存入口</strong>
<p>这里只读展示接口返回的智能体字段真实草稿校验差异和写回会在阶段 6 实现</p>
<strong>单文件安全写回</strong>
<p>写回前会重新校验 TOML比较校验时的文件 hash并先创建备份校验失败或文件已变化时不会写回</p>
</div>
</section>
</section>

View File

@@ -12,7 +12,7 @@ import { drafts } from '../data'
<p class="eyebrow">草稿</p>
<h2>未写回变更</h2>
</div>
<span class="read-only-chip">写回未启用</span>
<span class="read-only-chip">智能体视图写回</span>
</div>
<div class="draft-list">
@@ -36,12 +36,12 @@ import { drafts } from '../data'
<aside class="panel draft-side">
<div class="panel-heading">
<p class="eyebrow">空状态</p>
<h2>等待阶段 6 写回流程</h2>
<p class="eyebrow">入口</p>
<h2>到智能体视图编辑草稿</h2>
</div>
<div class="empty-state">
<strong>没有真实草稿队列</strong>
<p>当前只展示步骤结构草稿已校验已备份已写回不会创建放弃或写回任何文件</p>
<p>当前不做批量队列也不自动保存草稿请在智能体视图中选择单个文件草稿已校验已备份已写回流程操作</p>
</div>
</aside>
</section>