You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -208,6 +208,7 @@ type fakeGit struct {
|
||||
cloneErr error
|
||||
fetchErr error
|
||||
cloneSeen []string
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (f *fakeGit) Clone(_ context.Context, dst, _, _ string, _ *git.Credential) error {
|
||||
@@ -222,7 +223,10 @@ func (f *fakeGit) Commit(_ context.Context, _, _ string, _ bool) (string, error)
|
||||
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
|
||||
func (f *fakeGit) MergeFFOnly(_ context.Context, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeRemove(_ context.Context, _, path string) error {
|
||||
f.removed = append(f.removed, path)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -88,31 +88,34 @@ func (s *worktreeService) List(ctx context.Context, c Caller, wsID uuid.UUID) ([
|
||||
}
|
||||
|
||||
func (s *worktreeService) Delete(ctx context.Context, c Caller, wtID uuid.UUID) error {
|
||||
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
|
||||
ws, err := s.authorizeWorktreeWrite(ctx, c, wtID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ws, err := s.repo.GetWorkspaceByID(ctx, wt.WorkspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
||||
return err
|
||||
} else if !canWrite {
|
||||
return errs.New(errs.CodeForbidden, "no write access")
|
||||
}
|
||||
if wt.Status == WorktreeStatusActive {
|
||||
return errs.New(errs.CodeWorktreeAlreadyActive, "release before delete")
|
||||
}
|
||||
// 先 set pruning,再调 git rm,然后 DELETE row。中途失败 row 留 pruning。
|
||||
// check-and-transition 必须原子:在 tx 内对行加 FOR UPDATE,确认 idle 后才转 pruning。
|
||||
// 否则并发 Acquire(FOR UPDATE)可能刚把它置为 active,而这里用非锁读看到旧的 idle,
|
||||
// 进而 git rm 掉 agent 正在使用的目录。仅 idle→pruning 在锁下完成。
|
||||
var wtPath string
|
||||
err = s.repo.InTx(ctx, func(tx Tx) error {
|
||||
_, e := tx.SetWorktreePruning(ctx, wtID)
|
||||
return e
|
||||
wt, e := tx.GetWorktreeByIDForUpdate(ctx, wtID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if wt.Status != WorktreeStatusIdle {
|
||||
return errs.New(errs.CodeWorktreeAlreadyActive, "release before delete")
|
||||
}
|
||||
pruned, e := tx.SetWorktreePruning(ctx, wtID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
wtPath = pruned.Path
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rmErr := s.gitr.WorktreeRemove(ctx, ws.MainPath, wt.Path); rmErr != nil {
|
||||
// 仅在守卫后的转移成功后才动磁盘。中途失败 row 留 pruning。
|
||||
if rmErr := s.gitr.WorktreeRemove(ctx, ws.MainPath, wtPath); rmErr != nil {
|
||||
return errs.Wrap(rmErr, errs.CodeGitCmdFailed, "git worktree remove")
|
||||
}
|
||||
if err := s.repo.DeleteWorktree(ctx, wtID); err != nil {
|
||||
@@ -123,6 +126,9 @@ func (s *worktreeService) Delete(ctx context.Context, c Caller, wtID uuid.UUID)
|
||||
}
|
||||
|
||||
func (s *worktreeService) Acquire(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
|
||||
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holder := c.Holder()
|
||||
var out *Worktree
|
||||
err := s.repo.InTx(ctx, func(tx Tx) error {
|
||||
@@ -157,6 +163,9 @@ func (s *worktreeService) Acquire(ctx context.Context, c Caller, wtID uuid.UUID)
|
||||
}
|
||||
|
||||
func (s *worktreeService) Release(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
|
||||
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holder := c.Holder()
|
||||
var out *Worktree
|
||||
err := s.repo.InTx(ctx, func(tx Tx) error {
|
||||
@@ -190,6 +199,27 @@ func (s *worktreeService) ReleaseByHolder(ctx context.Context, holder string) ([
|
||||
return s.repo.ReleaseWorktreesByHolder(ctx, holder)
|
||||
}
|
||||
|
||||
// authorizeWorktreeWrite 把 worktree → workspace → project 解析出来并要求 write 权限,
|
||||
// 与 Delete/Create 的鉴权方式一致。返回 worktree 所属的 workspace 供调用方使用
|
||||
// (如取 MainPath)。Acquire/Release 此前缺失该校验,导致任意已登录用户可凭 UUID
|
||||
// 跨租户锁定/释放任意 worktree。
|
||||
func (s *worktreeService) authorizeWorktreeWrite(ctx context.Context, c Caller, wtID uuid.UUID) (*Workspace, error) {
|
||||
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws, err := s.repo.GetWorkspaceByID(ctx, wt.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
||||
return nil, err
|
||||
} else if !canWrite {
|
||||
return nil, errs.New(errs.CodeForbidden, "no write access")
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (s *worktreeService) audit(ctx context.Context, uid *uuid.UUID, action, ttype, tid string, meta map[string]any) {
|
||||
_ = s.rec.Record(ctx, audit.Entry{
|
||||
UserID: uid, Action: action, TargetType: ttype, TargetID: tid, Metadata: meta,
|
||||
|
||||
@@ -6,8 +6,17 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
func requireCode(t *testing.T, err error, want errs.Code) {
|
||||
t.Helper()
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok, "expected *errs.AppError, got %T", err)
|
||||
require.Equal(t, want, ae.Code)
|
||||
}
|
||||
|
||||
func newWtSvc(t *testing.T) (*worktreeService, *fakeRepo, *fakePA, *fakeGit, uuid.UUID) {
|
||||
t.Helper()
|
||||
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||
@@ -81,3 +90,55 @@ func TestWorktreeService_Delete_BlockedWhenActive(t *testing.T) {
|
||||
err = svc.Delete(context.Background(), caller, wt.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Bug #5: Delete 的 check-and-transition 必须在锁下原子完成。即使 worktree 处于
|
||||
// active(被持有),Delete 也必须在事务内(GetWorktreeByIDForUpdate)发现并拒绝,
|
||||
// 返回 CodeWorktreeAlreadyActive,且不触碰磁盘 / 不删行。
|
||||
func TestWorktreeService_Delete_AtomicGuard_RefusesActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, repo, _, gitr, wsID := newWtSvc(t)
|
||||
caller := Caller{UserID: uuid.New()}
|
||||
wt, err := svc.Create(context.Background(), caller, wsID, CreateWorktreeInput{Branch: "feat-g"})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Acquire(context.Background(), caller, wt.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.Delete(context.Background(), caller, wt.ID)
|
||||
require.Error(t, err)
|
||||
requireCode(t, err, errs.CodeWorktreeAlreadyActive)
|
||||
// 磁盘未被触碰,行仍存在且保持 active。
|
||||
require.Empty(t, gitr.removed)
|
||||
got, err := repo.GetWorktreeByID(context.Background(), wt.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, WorktreeStatusActive, got.Status)
|
||||
}
|
||||
|
||||
// Bug #6: Acquire 必须做 project 鉴权。无 write 权限的 caller 不得锁定 worktree。
|
||||
func TestWorktreeService_Acquire_DeniedWithoutWriteAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _, pa, _, wsID := newWtSvc(t)
|
||||
caller := Caller{UserID: uuid.New()}
|
||||
wt, err := svc.Create(context.Background(), caller, wsID, CreateWorktreeInput{Branch: "feat-a"})
|
||||
require.NoError(t, err)
|
||||
|
||||
pa.canWrite = false
|
||||
_, err = svc.Acquire(context.Background(), caller, wt.ID)
|
||||
require.Error(t, err)
|
||||
requireCode(t, err, errs.CodeForbidden)
|
||||
}
|
||||
|
||||
// Bug #6: Release 同样必须做 project 鉴权(在 holder-match 之前)。
|
||||
func TestWorktreeService_Release_DeniedWithoutWriteAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _, pa, _, wsID := newWtSvc(t)
|
||||
caller := Caller{UserID: uuid.New()}
|
||||
wt, err := svc.Create(context.Background(), caller, wsID, CreateWorktreeInput{Branch: "feat-rr"})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Acquire(context.Background(), caller, wt.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pa.canWrite = false
|
||||
_, err = svc.Release(context.Background(), caller, wt.ID)
|
||||
require.Error(t, err)
|
||||
requireCode(t, err, errs.CodeForbidden)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user