diff --git a/docs/project.md b/docs/project.md index aa3182c..9e4d6c4 100644 --- a/docs/project.md +++ b/docs/project.md @@ -6,7 +6,7 @@ ## Target Architecture -计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;前端已通过集中 API client 接入这些只读端点并显示加载中、连接失败和空数据状态。目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`。 +计划架构为:Go 后端提供 localhost HTTP API,Vue 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":""}' +``` + 读取项目配置: ```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-` 备份,备份成功后通过同目录临时文件和 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 diff --git a/findings.md b/findings.md index cecd1e4..a490539 100644 --- a/findings.md +++ b/findings.md @@ -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 文件,不创建新 agent;validate 返回当前 sha256,write 使用 expectedHash 阻止校验后覆盖用户改动。 +- Phase 6 备份文件写在目标文件旁边,命名为 `*.bak-`;只有备份创建成功后才使用同目录临时文件和 rename 替换目标。 +- Phase 6 前端不维护批量草稿队列,不使用 localStorage 自动保存草稿;写回入口在智能体视图,用户确认后才调用 write API。 diff --git a/internal/agents/model.go b/internal/agents/model.go index 91cbe75..c607e28 100644 --- a/internal/agents/model.go +++ b/internal/agents/model.go @@ -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"` +} diff --git a/internal/agents/store.go b/internal/agents/store.go index 92c188a..860224e 100644 --- a/internal/agents/store.go +++ b/internal/agents/store.go @@ -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" diff --git a/internal/agents/writeback.go b/internal/agents/writeback.go new file mode 100644 index 0000000..1303bf8 --- /dev/null +++ b/internal/agents/writeback.go @@ -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") +} diff --git a/internal/agents/writeback_test.go b/internal/agents/writeback_test.go new file mode 100644 index 0000000..88a3424 --- /dev/null +++ b/internal/agents/writeback_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f017732..c9c4908 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f04ad9c..e9d4302 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/progress.md b/progress.md index 08c3ced..e363fdc 100644 --- a/progress.md +++ b/progress.md @@ -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 返回当前 sha256;write 重新读取并比较 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 | diff --git a/task_plan.md b/task_plan.md index 3ca180e..dfb98c8 100644 --- a/task_plan.md +++ b/task_plan.md @@ -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 diff --git a/web/src/App.vue b/web/src/App.vue index 3910c49..b5f5357 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -23,14 +23,14 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
-

本地只读工作台

+

本地安全工作台

Codex 智能体管理台

-

前端按视图连接后端只读接口,显示真实数据、加载中、连接失败和空数据状态。

+

前端按视图连接后端接口,显示真实数据、加载中、连接失败、空数据和单文件写回状态。

- - 按需读取后端数据 - 项目、运行线程、工作流和智能体视图只调用只读端点;失败时显示“示例/等待连接”标注。 + + 按需读取和确认写回 + 项目、运行线程和工作流保持只读;智能体草稿仅在校验、备份和确认后单文件写回。
diff --git a/web/src/api/client.js b/web/src/api/client.js index 82fa119..c5315a0 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -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 }), + }) + }, } diff --git a/web/src/api/client.test.mjs b/web/src/api/client.test.mjs new file mode 100644 index 0000000..737e997 --- /dev/null +++ b/web/src/api/client.test.mjs @@ -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' })) +}) diff --git a/web/src/api/normalizers.js b/web/src/api/normalizers.js index 5805f95..e3fb5eb 100644 --- a/web/src/api/normalizers.js +++ b/web/src/api/normalizers.js @@ -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;以下为只读展示的已解析字段', diff --git a/web/src/api/normalizers.test.mjs b/web/src/api/normalizers.test.mjs index 3a16d3d..bddc59b 100644 --- a/web/src/api/normalizers.test.mjs +++ b/web/src/api/normalizers.test.mjs @@ -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, ['草稿']) +}) diff --git a/web/src/data.js b/web/src/data.js index 2a0b7b7..3a4d895 100644 --- a/web/src/data.js +++ b/web/src/data.js @@ -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 }, ] diff --git a/web/src/styles.css b/web/src/styles.css index b116588..2f3eb0c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index cc23875..3d9f251 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -1,21 +1,54 @@