From 047a183571d43ab6942098b21ba88ae245847607 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 18:16:10 +0800 Subject: [PATCH 001/104] fix: close final workspace review gaps --- cmd/drove/AGENTS.md | 1 + cmd/drove/main.go | 39 +++--- cmd/drove/main_test.go | 10 ++ internal/workspace/AGENTS.md | 9 +- internal/workspace/directory_error.go | 14 --- internal/workspace/directory_error_plan9.go | 12 -- internal/workspace/prepared_worktree.go | 3 +- internal/workspace/recovery_security_test.go | 89 +++++++++++++- internal/workspace/removal.go | 37 +++++- internal/workspace/repository_capability.go | 3 +- internal/workspace/secure_remove.go | 118 +++++++++++++------ internal/workspace/workspace.go | 39 ++---- internal/workspace/workspace_test.go | 36 ++++++ 13 files changed, 291 insertions(+), 119 deletions(-) delete mode 100644 internal/workspace/directory_error.go delete mode 100644 internal/workspace/directory_error_plan9.go diff --git a/cmd/drove/AGENTS.md b/cmd/drove/AGENTS.md index 35a3d1f..cdba55b 100644 --- a/cmd/drove/AGENTS.md +++ b/cmd/drove/AGENTS.md @@ -53,6 +53,7 @@ log / timeline / explain / attach / stop / worktree / token rotate / web / versi - main 必须保持极薄:解析参数 → 调用 client → 退出码语义化(0 成功 / 1 用户错误 / 2 运行时错误)。 - 未知顶层或嵌套子命令属于用户输入错误,必须返回退出码 1。 +- Cobra 的隐藏补全入口 `__complete` 与 `__completeNoDesc` 必须绕过未知命令预检。 - 禁止在 cmd 中复制业务逻辑或直接构造 session/pty/store;一律走 `internal/client`。 - 本地自定义类型必须与 daemon API 的 JSON 契约一致(增删字段需同步 API 层测试)。 - 导出符号:无(main 包不导出)。 diff --git a/cmd/drove/main.go b/cmd/drove/main.go index 49ab4cf..ccf7953 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -71,27 +71,34 @@ func main() { func executeRoot(root *cobra.Command, args []string) error { root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() - command, remaining, err := root.Find(args) - if err != nil { - return markUsageError(err) - } - if len(remaining) > 0 && remaining[0] == "--" { - remaining = remaining[1:] - } - if command.HasSubCommands() && - !command.Runnable() && - len(remaining) > 0 && - !strings.HasPrefix(remaining[0], "-") { - return markUsageError(fmt.Errorf( - "unknown command %q for %q", - remaining[0], - command.CommandPath(), - )) + if !isCompletionRequest(args) { + command, remaining, err := root.Find(args) + if err != nil { + return markUsageError(err) + } + if len(remaining) > 0 && remaining[0] == "--" { + remaining = remaining[1:] + } + if command.HasSubCommands() && + !command.Runnable() && + len(remaining) > 0 && + !strings.HasPrefix(remaining[0], "-") { + return markUsageError(fmt.Errorf( + "unknown command %q for %q", + remaining[0], + command.CommandPath(), + )) + } } root.SetArgs(args) return root.Execute() } +func isCompletionRequest(args []string) bool { + return len(args) > 0 && + (args[0] == "__complete" || args[0] == "__completeNoDesc") +} + func commandExitCode(err error) int { if err == nil { return exitOK diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 76007d8..705b66d 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -299,6 +299,16 @@ func TestExecuteRootSupportsDefaultCommands(t *testing.T) { args: []string{"completion", "bash"}, wantOutput: "__start_drove", }, + { + name: "completion request", + args: []string{"__complete", "workt"}, + wantOutput: "worktree", + }, + { + name: "completion request without descriptions", + args: []string{"__completeNoDesc", "workt"}, + wantOutput: "worktree", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 9bd5ae2..172eff8 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -94,8 +94,10 @@ 持久删除目录 marker,再清除 intent。ack sidecar 隔离名在正式记录缺失时必须按 Agent ID 恢复并回读 operation ID,使 session 重试和重启 reconciliation 能继续; 正式记录已存在时不得恢复旧 ack。清理始终保留分支。 -- Manager 创建不预先查找 Git;只有实际查询或变更 worktree 时才解析并执行 `git`, - 因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。 +- Manager 创建不预先查找 Git,也不创建目录;只有实际查询或变更 worktree 时才解析并 + 执行 `git`,因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。缺失的 + DataDir 必须从已打开的最近现存祖先通过 `os.Root` 逐级创建,禁止 `MkdirAll` 沿可替换 + 的公开路径创建。 - Manager 初始化时固定既有 data directory、worktrees root 和 repository bucket 的 文件身份,新建目录则在首次打开时固定。后续通过 `os.Root` 逐级打开并持续复核; 任一中间实目录或 symlink 被替换时必须 fail-stop。List 与 record scan 全程持有固定 @@ -105,7 +107,8 @@ sidecar 安装在原子改名前后都要确认已打开的 repository bucket 仍位于规范 hash 路径, 文件改名先把已校验源链接到内部随机别名,再从别名安装最终目标;公开临时路径被替换 只能导致失败,不得覆盖最终记录。清理临时名时也必须确认它仍指向已打开文件。缺少 - 对应原子原语的平台必须返回错误。 + 对应原子原语的平台必须返回错误。空 repository bucket 在创建后永久保留,禁止按公开 + 名称删除并清除已固定的目录身份。 - `Discard` 只供创建事务在会话元数据持久化前回滚;它会删除本次新建的 worktree 和 本次新建的分支。普通或重启后的 Discard 遇到路径存在但 preparation sidecar 尚未持久化 完整 Git 身份时必须 fail-stop;同一次 Prepare 仍持有原始仓库根句柄时,如果 sidecar diff --git a/internal/workspace/directory_error.go b/internal/workspace/directory_error.go deleted file mode 100644 index b1e2c28..0000000 --- a/internal/workspace/directory_error.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !plan9 - -package workspace - -import ( - "errors" - "io/fs" - "syscall" -) - -func isDirectoryNotEmptyError(err error) bool { - return errors.Is(err, syscall.ENOTEMPTY) || - errors.Is(err, fs.ErrExist) -} diff --git a/internal/workspace/directory_error_plan9.go b/internal/workspace/directory_error_plan9.go deleted file mode 100644 index 4026604..0000000 --- a/internal/workspace/directory_error_plan9.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build plan9 - -package workspace - -import ( - "errors" - "io/fs" -) - -func isDirectoryNotEmptyError(err error) bool { - return errors.Is(err, fs.ErrExist) -} diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index a73dfa8..7601e97 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" ) func (m *Manager) createPreparedWorktreeTarget( @@ -323,7 +322,7 @@ func (m *Manager) worktreeGitDirectoryAtRoot( err, ) } - gitDirectory := strings.TrimSpace(string(output)) + gitDirectory := trimGitLineTerminator(output) if !filepath.IsAbs(gitDirectory) { return "", fmt.Errorf( "workspace: Git directory for %q is not absolute", diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 8ae998b..6a8a5f4 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -11,6 +11,33 @@ import ( "testing" ) +func TestEnsureManagedRootRejectsLateParentSymlink(t *testing.T) { + parent := t.TempDir() + redirect := filepath.Join(parent, "redirect") + dataDir := filepath.Join(redirect, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := os.Lstat(redirect); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("new manager changed the filesystem: %v", err) + } + + outside := t.TempDir() + if err := os.Symlink(outside, redirect); err != nil { + t.Skipf("create late parent symlink: %v", err) + } + if err := manager.ensureManagedRoot(); err == nil { + t.Fatal("managed root creation followed a late parent symlink") + } + if _, err := os.Lstat(filepath.Join(outside, "data")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("managed root creation changed the symlink target: %v", err) + } +} + func TestDiscardRejectsReplacedDataDirectory(t *testing.T) { repository := newTestRepository(t) parent := t.TempDir() @@ -296,6 +323,58 @@ func TestAcknowledgeRemovalRejectsReplacedRepositoryBucket(t *testing.T) { } } +func TestAcknowledgeRemovalPreservesRecordWhenRepositoryDisappears( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + movedRepository := repository + "-moved" + if err := os.Rename(repository, movedRepository); err != nil { + t.Fatalf("move source repository: %v", err) + } + repositoryMoved := true + t.Cleanup(func() { + if repositoryMoved { + _ = os.Rename(movedRepository, repository) + } + }) + if err := manager.AcknowledgeRemoval(result.Removal); err == nil { + t.Fatal("acknowledgement ignored a missing source repository") + } + if _, err := os.Lstat(workspaceRecordPath(prepared.Path)); err != nil { + t.Fatalf("failed acknowledgement removed the sidecar: %v", err) + } + + if err := os.Rename(movedRepository, repository); err != nil { + t.Fatalf("restore source repository: %v", err) + } + repositoryMoved = false + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge restored removal: %v", err) + } +} + func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -1169,7 +1248,7 @@ func TestPreparedRefTransactionLocksBranchDuringValidation(t *testing.T) { } } -func TestManagerRecreatesAcknowledgedRepositoryBucket(t *testing.T) { +func TestManagerRetainsAcknowledgedRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -1191,6 +1270,14 @@ func TestManagerRecreatesAcknowledgedRepositoryBucket(t *testing.T) { if err := manager.AcknowledgeRemoval(result.Removal); err != nil { t.Fatalf("acknowledge first removal: %v", err) } + bucket := filepath.Dir(first.Path) + info, err := os.Lstat(bucket) + if err != nil { + t.Fatalf("inspect retained repository bucket: %v", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("retained repository bucket mode = %v", info.Mode()) + } second, err := manager.Prepare( context.Background(), repository, diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index e7b71db..baa2e16 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -210,12 +210,16 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { Started: true, Quarantined: true, } + bucketAbsent, err := m.removalBucketAbsent(removal.Workspace) + if err != nil { + return err + } + if bucketAbsent { + return nil + } inspectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() facts, err := m.removalFacts(inspectCtx, record) - if errors.Is(err, os.ErrNotExist) { - return nil - } if err != nil { return err } @@ -227,12 +231,33 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { if err := m.removeAcknowledgedWorkspaceRecord(removal); err != nil { return err } - if err := m.removeManagedBucketIfEmpty(removal.Workspace); err != nil { - return err - } return nil } +func (m *Manager) removalBucketAbsent( + target Workspace, +) (_ bool, result error) { + root, err := m.openWorktreeRoot() + if errors.Is(err, os.ErrNotExist) { + return true, nil + } + if err != nil { + return false, err + } + defer func() { + result = errors.Join(result, root.Close()) + }() + _, err = root.Lstat(repositoryHash(target.Repository)) + switch { + case errors.Is(err, os.ErrNotExist): + return true, nil + case err != nil: + return false, fmt.Errorf("workspace: inspect repository bucket: %w", err) + default: + return false, nil + } +} + type removalFacts struct { pathExists bool quarantineName string diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 56d8113..fc6e3ce 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -188,14 +188,13 @@ func (r repositoryCapability) repositoryDirectories( err, ) } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") + lines := strings.Split(trimGitLineTerminator(output), "\n") if len(lines) != 2 { return "", "", errors.New( "workspace: Git directory query returned an invalid response", ) } resolve := func(kind string, path string) (string, error) { - path = strings.TrimSpace(path) if !filepath.IsAbs(path) { return "", fmt.Errorf( "workspace: %s Git directory is not absolute", diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index d3e2728..bef6a14 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -15,11 +15,7 @@ func (m *Manager) ensureManagedRoot() (result error) { if m.rootErr != nil { return m.rootErr } - dataDir := filepath.Dir(m.root) - if err := ensureDirectory(dataDir); err != nil { - return err - } - root, err := m.openDataDirectoryRoot() + root, err := m.openOrCreateDataDirectoryRoot() if err != nil { return err } @@ -56,6 +52,28 @@ func (m *Manager) ensureManagedRoot() (result error) { return opened.Close() } +func (m *Manager) openOrCreateDataDirectoryRoot() (*os.Root, error) { + if m.dataDirInfo != nil { + return m.openDataDirectoryRoot() + } + path := filepath.Dir(m.root) + root, err := openOrCreateRealPathRoot(path) + if err != nil { + return nil, fmt.Errorf("workspace: create data directory: %w", err) + } + opened, err := root.Stat(".") + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("workspace: inspect created data directory: %w", err) + } + if err := verifyRealPathRoot(path, root); err != nil { + _ = root.Close() + return nil, err + } + m.dataDirInfo = opened + return root, nil +} + func (m *Manager) pinDataDirectory() error { root, err := m.openDataDirectoryRoot() if err != nil { @@ -583,34 +601,6 @@ func restoreManagedQuarantine( return verifyRootEntryUnchanged(bucket, record.AgentID, opened) } -func (m *Manager) removeManagedBucketIfEmpty( - target Workspace, -) (result error) { - if err := m.validateManagedPath(target); err != nil { - return err - } - root, err := m.openWorktreeRoot() - if err != nil { - return err - } - defer func() { - result = errors.Join(result, root.Close()) - }() - name := repositoryHash(target.Repository) - err = root.Remove(name) - switch { - case err == nil: - delete(m.bucketInfo, name) - return nil - case errors.Is(err, os.ErrNotExist): - return nil - case isDirectoryNotEmptyError(err): - return nil - default: - return fmt.Errorf("remove repository bucket: %w", err) - } -} - func (m *Manager) openRecordBucket( worktreePath string, ) (*os.Root, string, error) { @@ -711,6 +701,68 @@ func openRealPathRoot(path string) (*os.Root, error) { return root, nil } +func openOrCreateRealPathRoot(path string) (*os.Root, error) { + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("%q is not an absolute path", path) + } + current := path + var missing []string + for { + info, err := os.Lstat(current) + switch { + case err == nil: + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%q is not a real directory", current) + } + root, err := openRealPathRoot(current) + if err != nil { + return nil, err + } + for index := len(missing) - 1; index >= 0; index-- { + name := missing[index] + if err := root.Mkdir(name, 0o700); err != nil && + !errors.Is(err, os.ErrExist) { + _ = root.Close() + return nil, fmt.Errorf( + "create directory %q: %w", + filepath.Join(current, name), + err, + ) + } + child, err := openRealRootFromRoot(root, name) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf( + "open created directory %q: %w", + filepath.Join(current, name), + err, + ) + } + if err := root.Close(); err != nil { + _ = child.Close() + return nil, fmt.Errorf( + "close parent directory %q: %w", + current, + err, + ) + } + current = filepath.Join(current, name) + root = child + } + return root, nil + case !errors.Is(err, os.ErrNotExist): + return nil, fmt.Errorf("inspect directory %q: %w", current, err) + } + parent := filepath.Dir(current) + if parent == current { + return nil, fmt.Errorf("find existing ancestor for %q: %w", path, err) + } + missing = append(missing, filepath.Base(current)) + current = parent + } +} + func verifyRealPathRoot(path string, opened *os.Root) error { openedInfo, err := opened.Stat(".") if err != nil { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 8b96600..29ab99f 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -299,10 +299,7 @@ func (m *Manager) prepare( ) defer cancel() if !recordInstalled { - return Workspace{}, errors.Join( - err, - m.removeManagedBucketIfEmpty(result), - ) + return Workspace{}, err } cleanupTarget := result cleanupTarget.createdBranch = false @@ -865,17 +862,9 @@ func (m *Manager) discardWithRepository( branchCleaned = true } } - recordRemoved := false if worktreeRemoved && branchCleaned { if err := m.removeWorkspaceRecord(target.Path); err != nil { result = errors.Join(result, err) - } else { - recordRemoved = true - } - } - if recordRemoved { - if err := m.removeManagedBucketIfEmpty(target); err != nil { - result = errors.Join(result, err) } } return result @@ -1097,7 +1086,7 @@ func (m *Manager) worktreeGitDirectory( err, ) } - gitDirectory := strings.TrimSpace(string(output)) + gitDirectory := trimGitLineTerminator(output) if !filepath.IsAbs(gitDirectory) { return "", fmt.Errorf( "workspace: Git directory for %q is not absolute", @@ -1183,7 +1172,7 @@ func (m *Manager) repositoryPaths( err, ) } - sourcePath := strings.TrimSpace(string(output)) + sourcePath := trimGitLineTerminator(output) sourcePath, err = filepath.EvalSymlinks(sourcePath) if err != nil { return "", "", fmt.Errorf( @@ -1277,7 +1266,7 @@ func (m *Manager) repositoryRootWith( if err != nil { return "", fmt.Errorf("workspace: inspect common Git directory: %w", err) } - commonPath := strings.TrimSpace(string(commonOutput)) + commonPath := trimGitLineTerminator(commonOutput) if !filepath.IsAbs(commonPath) { commonPath = filepath.Join(worktreePath, commonPath) } @@ -1296,7 +1285,7 @@ func (m *Manager) repositoryRootWith( "core.worktree", ) if err == nil { - configured := strings.TrimSpace(string(worktreeOutput)) + configured := trimGitLineTerminator(worktreeOutput) if !filepath.IsAbs(configured) { configured = filepath.Join(commonPath, configured) } @@ -1571,6 +1560,10 @@ func isExitCode(err error, code int) bool { return errors.As(err, &exitErr) && exitErr.ExitCode() == code } +func trimGitLineTerminator(output []byte) string { + return strings.TrimSuffix(string(output), "\n") +} + func resolvePath(path string) (string, error) { path = filepath.Clean(path) var suffix []string @@ -1620,20 +1613,6 @@ func validRepositoryHash(value string) bool { return true } -func ensureDirectory(path string) error { - if err := os.MkdirAll(path, 0o700); err != nil { - return fmt.Errorf("workspace: create directory %q: %w", path, err) - } - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("workspace: inspect directory %q: %w", path, err) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("workspace: path %q is not a real directory", path) - } - return nil -} - func ensureAbsent(path string) error { _, err := os.Lstat(path) switch { diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index fd2e1b3..5df35ae 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1974,6 +1974,42 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestPreparePreservesTrailingSpaceInRepositoryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot end in a space") + } + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve repository parent: %v", err) + } + repository := filepath.Join(parent, "repository ") + initTestRepositoryAt(t, repository) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare trailing-space repository: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "prepared repository = %q, want %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard trailing-space repository: %v", err) + } +} + func TestPrepareRejectsMissingRepositoryWithoutRunningGit(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { From 703e98edfa97750b31e2e92bc1c5dc95f6ed98e1 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 19:30:40 +0800 Subject: [PATCH 002/104] fix: harden workspace lifecycle boundaries --- internal/session/AGENTS.md | 3 +- internal/session/projection.go | 1 - internal/session/projection_test.go | 54 +++ internal/workspace/AGENTS.md | 24 +- internal/workspace/manager_lock_key_other.go | 9 + .../workspace/manager_lock_key_windows.go | 12 + .../manager_lock_key_windows_test.go | 17 + internal/workspace/preparation.go | 44 ++- internal/workspace/preparation_test.go | 65 ++++ internal/workspace/prepared_worktree.go | 324 +++++++++++++++-- internal/workspace/record.go | 33 +- internal/workspace/record_rename_windows.go | 7 +- .../workspace/record_rename_windows_test.go | 41 +++ internal/workspace/removal.go | 82 ++++- internal/workspace/repository_capability.go | 66 ++-- internal/workspace/source_swap_test.go | 343 ++++++++++++++++-- internal/workspace/workspace.go | 94 ++++- internal/workspace/workspace_test.go | 38 +- 18 files changed, 1134 insertions(+), 123 deletions(-) create mode 100644 internal/workspace/manager_lock_key_other.go create mode 100644 internal/workspace/manager_lock_key_windows.go create mode 100644 internal/workspace/manager_lock_key_windows_test.go diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index 7aa4df6..a3f65f9 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -28,7 +28,8 @@ 本身不持有 goroutine 或回调。 - 一个全局 Committer goroutine 独占运行时事件序号和写入顺序。Store batch 成功后, Committer 才应用 Agent 投影并按序发布 Hub。workspace removal 使用 typed - operation,在 append 后清除 working directory、关闭 Resume,再发布事件。 + operation,在 append 后清除 working directory、关闭 Resume,再发布事件;该元数据 + 事件不改变 Agent 状态的 UpdatedAt,恢复投影必须保持相同语义。 - `Start(ctx, req)`:校验并默认 `RunMode` → 按 vendor 取适配器 → 构造 agent → 持久化 `starting` → 以统一的 40 行 × 120 列初始尺寸创建带固定回调的 PTY → 创建 terminal actor → 持久化 `working` → 依次放行 signal 与 PTY callback → diff --git a/internal/session/projection.go b/internal/session/projection.go index 714e781..e1dd4fd 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -300,7 +300,6 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { ) } draft.workspaceRemoved = true - draft.updatedAt = row.Timestamp return nil } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 7d653f3..507795f 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -1019,6 +1019,60 @@ func TestRecoveryProjectorAcceptsValidatedResizeWithoutChangingState(t *testing. } } +func TestRecoveryProjectorWorkspaceRemovalDoesNotChangeStateTimestamp( + t *testing.T, +) { + projector := newRecoveryProjector() + base := time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC) + stoppedAt := base.Add(time.Second) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "s1", + AgentID: "s1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"generic"}`, + }, + { + Seq: 2, + Timestamp: stoppedAt, + Type: string(event.TypeStateChanged), + SessionID: "s1", + AgentID: "s1", + From: string(agent.StatePending), + To: string(agent.StateStopped), + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "s1", + AgentID: "s1", + Reason: workspaceRemovedReason, + Payload: `{"version":1}`, + }, + } + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + draft := projector.sessions["s1"] + if draft == nil || !draft.updatedAt.Equal(stoppedAt) { + t.Fatalf("workspace removal changed recovered state time: %+v", draft) + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if len(plan.Snapshots) != 1 || + !plan.Snapshots[0].UpdatedAt.Equal(stoppedAt) { + t.Fatalf("workspace removal snapshot = %+v", plan.Snapshots) + } +} + func TestRecoveryProjectorAcceptsAttachmentAuditWithoutChangingState(t *testing.T) { projector := newRecoveryProjector() base := time.Date(2026, time.October, 4, 13, 0, 0, 0, time.UTC) diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 172eff8..36e9cd1 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -13,7 +13,8 @@ `drove/`。 - 指向同一规范 DataDir 的所有进程内 Manager 共享互斥锁;Prepare、List、Remove、 ReconcilePreparations、AcknowledgePreparation、ReconcileRemovals 和 Discard 串行 - 执行。workspace 记录先写入同目录临时文件并 fsync,原子安装最终文件后再 fsync + 执行;Windows 的锁 key 必须折叠路径大小写,缺失目录的大小写别名也不能取得不同锁。 + workspace 记录先写入同目录临时文件并 fsync,原子安装最终文件后再 fsync 父目录。 - Prepare 全程固定已打开的源仓库根目录;仓库识别、分支查询与创建、include 匹配和 `git worktree add` 都必须从该根句柄执行,并禁用 Git hooks。Linux 使用 @@ -22,8 +23,11 @@ macOS/BSD 上只依赖私有 Git directory 的查询必须从该目录句柄执行,禁止经由使用 公开私有 Git 路径的 worktree helper;同时依赖 index 与 worktree 的查询必须由两个 各自固定一侧的视角交叉验证。 - `git worktree add` 使用 `--no-checkout`,新 worktree 打开后先核对预先持久化的 - 文件系统目录身份,再持久化 Git 私有目录,最后从固定目标根执行 `git reset --hard`。仓库根目录的 + `git worktree add` 使用 `--no-checkout`,只接收 bucket 内随机且与 Agent ID 同前缀的 + staging 目录,禁止接触公开 Agent 路径。新 worktree 打开后先核对预先持久化的 + 文件系统目录身份,再持久化 Git 私有目录并从固定目标根执行 `git reset --hard`; + 初始化完成后把 staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 + `git worktree repair .`。仓库根目录的 `.worktreeinclude` 使用 gitignore 语义,并从已打开文件读取一次。Unix 通过继承的 只读文件描述符交给 Git,其余平台通过受复核的私有临时副本交给 Git,禁止 Git 再按 源 manifest 路径打开。 @@ -34,7 +38,7 @@ 不得重新解释目标 worktree 的 manifest。目标父目录必须从已固定 worktree root 向下创建,不能从 repository bucket 重新解析可替换的公开路径。 - `Prepare` 在创建新分支和执行 `git worktree add` 前持久化未提交的 preparation - sidecar,并在受约束 repository bucket 内预创建空目标目录、固定目录身份、同步父目录 + sidecar,并在受约束 repository bucket 内预创建随机 staging 目录、固定目录身份、同步父目录 后把身份写回 sidecar。每次 preparation 都有唯一 operation ID,用于拒绝另一 Manager 的冲突清理。 version 5 sidecar 记录源 worktree、源私有 Git directory 和 common Git directory @@ -64,8 +68,9 @@ 目录身份、Git registration、私有 Git directory,以及源 worktree、源私有 Git directory 和 common Git directory 的公开路径仍绑定 preparation lease 固定的目录 实例;源 worktree 的 `.git` 指针还必须重新解析到固定的私有 Git directory 和 - common Git directory,两个目录必须由同一次 rooted Git 查询取得,禁止跨进程拼接 - 解析结果。提交 sidecar 后和清理 ownership ref 前后也要复核,不能只信 session 携带 + common Git directory,两个目录必须由同一次 rooted Git 查询取得,输出只接受唯一一对 + 可打开的绝对目录,不能按固定行数切分或跨进程拼接。提交 sidecar 后、清理 ownership ref + 前后及最终记录更新后都要重新复核目标与仓库,不能只信 session 携带 的历史值。 - `List` 只枚举 Drove 根目录下符合路径约定的 worktree,并从 Git 查询仓库、分支和 dirty 状态。每个成功创建的 worktree 都有同目录私有记录,用于识别 detached HEAD @@ -93,7 +98,9 @@ sidecar repository evidence 重开的 capability;拒绝未开始的 removal 时必须先验证并 持久删除目录 marker,再清除 intent。ack sidecar 隔离名在正式记录缺失时必须按 Agent ID 恢复并回读 operation ID,使 session 重试和重启 reconciliation 能继续; - 正式记录已存在时不得恢复旧 ack。清理始终保留分支。 + 正式记录已存在时不得恢复旧 ack。对 version 1-4 记录发起新删除时,必须在写 intent + 前取得并持久化完整的 version 5 repository evidence;已经存在但缺少该证据的 removal + intent 只能 fail-stop,禁止在恢复时重新信任记录中的公开仓库路径。清理始终保留分支。 - Manager 创建不预先查找 Git,也不创建目录;只有实际查询或变更 worktree 时才解析并 执行 `git`,因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。缺失的 DataDir 必须从已打开的最近现存祖先通过 `os.Root` 逐级创建,禁止 `MkdirAll` 沿可替换 @@ -103,7 +110,8 @@ 任一中间实目录或 symlink 被替换时必须 fail-stop。List 与 record scan 全程持有固定 root/bucket 句柄。sidecar 的原子写、确认读取和删除必须在受约束 bucket 句柄内完成。 初次 sidecar 安装必须使用 no-replace 原语;removal acknowledgement 先把匹配 token 的 - sidecar 原子移动到 operation ID 隔离名,再校验并删除,崩溃后从隔离名恢复。 + sidecar 原子移动到 operation ID 隔离名,校验后再移动到同格式的新随机私有名并按已打开 + 文件身份删除,崩溃后从任一隔离名恢复。 sidecar 安装在原子改名前后都要确认已打开的 repository bucket 仍位于规范 hash 路径, 文件改名先把已校验源链接到内部随机别名,再从别名安装最终目标;公开临时路径被替换 只能导致失败,不得覆盖最终记录。清理临时名时也必须确认它仍指向已打开文件。缺少 diff --git a/internal/workspace/manager_lock_key_other.go b/internal/workspace/manager_lock_key_other.go new file mode 100644 index 0000000..108d16b --- /dev/null +++ b/internal/workspace/manager_lock_key_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package workspace + +import "path/filepath" + +func workspaceManagerLockKey(path string) string { + return filepath.Clean(path) +} diff --git a/internal/workspace/manager_lock_key_windows.go b/internal/workspace/manager_lock_key_windows.go new file mode 100644 index 0000000..f9a9e9a --- /dev/null +++ b/internal/workspace/manager_lock_key_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package workspace + +import ( + "path/filepath" + "strings" +) + +func workspaceManagerLockKey(path string) string { + return strings.ToUpper(filepath.Clean(path)) +} diff --git a/internal/workspace/manager_lock_key_windows_test.go b/internal/workspace/manager_lock_key_windows_test.go new file mode 100644 index 0000000..0b4c358 --- /dev/null +++ b/internal/workspace/manager_lock_key_windows_test.go @@ -0,0 +1,17 @@ +//go:build windows + +package workspace + +import "testing" + +func TestWorkspaceManagerLockKeyFoldsPathCase(t *testing.T) { + lower := workspaceManagerLockKey(`C:\drove\data\worktrees`) + upper := workspaceManagerLockKey(`c:\DROVE\DATA\WORKTREES`) + if lower != upper { + t.Fatalf("manager lock keys differ: %q != %q", lower, upper) + } + if workspaceManagerLock(`C:\drove\data\worktrees`) != + workspaceManagerLock(`c:\DROVE\DATA\WORKTREES`) { + t.Fatal("case aliases received different manager locks") + } +} diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index 7d4f03f..557b114 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -37,9 +37,8 @@ func (m *Manager) acknowledgePreparation( } if record.PreparationCommitted && record.BranchOperationID == "" && - (target.preparation != nil || - (record.Removal != nil && - record.RepositoryEvidence == nil)) { + record.Removal != nil && + record.RepositoryEvidence == nil { return target.preparation.Close() } lease := target.preparation @@ -73,6 +72,26 @@ func (m *Manager) acknowledgePreparation( } return nil } + verifyWorktree := func(stage string) error { + ctx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + if err := m.verifyPreparedWorktreeForAcknowledgement( + ctx, + record.workspace(), + record, + lease.repository, + ); err != nil { + return fmt.Errorf( + "workspace: verify prepared worktree %s: %w", + stage, + err, + ) + } + return nil + } verifyCtx, cancelVerify := context.WithTimeout( context.Background(), 10*time.Second, @@ -98,11 +117,17 @@ func (m *Manager) acknowledgePreparation( if err := verifyRepository("after committing preparation"); err != nil { return err } + if err := verifyWorktree("after committing preparation"); err != nil { + return err + } } if record.BranchOperationID != "" { if err := verifyRepository("before ownership cleanup"); err != nil { return err } + if err := verifyWorktree("before ownership cleanup"); err != nil { + return err + } cleanupCtx, cancel := context.WithTimeout( context.Background(), 10*time.Second, @@ -117,6 +142,9 @@ func (m *Manager) acknowledgePreparation( if err := verifyRepository("after ownership cleanup"); err != nil { return err } + if err := verifyWorktree("after ownership cleanup"); err != nil { + return err + } record.BranchOperationID = "" if err := m.replaceWorkspaceRecord(record); err != nil { return fmt.Errorf( @@ -124,6 +152,16 @@ func (m *Manager) acknowledgePreparation( err, ) } + if err := verifyRepository( + "after persisting ownership cleanup", + ); err != nil { + return err + } + if err := verifyWorktree( + "after persisting ownership cleanup", + ); err != nil { + return err + } } if temporaryLease { return nil diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index b87c52f..5202fa6 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -149,6 +149,71 @@ func TestReconcilePreparationsDiscardsPendingAfterRestart(t *testing.T) { } } +func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("restore promote-pending staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile staging preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + if strings.Contains( + runGit(t, repository, "worktree", "list", "--porcelain"), + stagingPath, + ) { + t.Fatal("staging worktree registration remains") + } +} + func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( t *testing.T, ) { diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 7601e97..dcd76cd 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -6,24 +6,65 @@ import ( "fmt" "os" "path/filepath" + + "github.com/google/uuid" ) +type preparedWorktreeTarget struct { + root *os.Root + name string + path string +} + +func preparedWorktreeStagingName( + agentID string, + operationID string, +) (string, error) { + operation, err := uuid.Parse(operationID) + if err != nil || operation.String() != operationID { + return "", errors.New( + "workspace: preparation operation ID is invalid", + ) + } + name := agentID + for _, value := range operation { + name += fmt.Sprintf("%03d", value) + } + return name, nil +} + func (m *Manager) createPreparedWorktreeTarget( target *Workspace, -) (result error) { +) (_ *preparedWorktreeTarget, result error) { bucket, err := m.openManagedBucketRoot(*target) if err != nil { - return err + return nil, err } defer func() { - result = errors.Join(result, bucket.Close()) + if bucket != nil { + result = errors.Join(result, bucket.Close()) + } }() - if err := bucket.Mkdir(target.AgentID, 0o700); err != nil { - return fmt.Errorf("workspace: create prepared worktree target: %w", err) + name, err := preparedWorktreeStagingName( + target.AgentID, + target.branchOperationID, + ) + if err != nil { + return nil, err } - opened, err := openRealRootFromRoot(bucket, target.AgentID) + path := filepath.Join(filepath.Dir(target.Path), name) + if err := bucket.Mkdir(name, 0o700); err != nil { + return nil, fmt.Errorf( + "workspace: create prepared worktree target: %w", + err, + ) + } + opened, err := openRealRootFromRoot(bucket, name) if err != nil { - return fmt.Errorf("workspace: open prepared worktree target: %w", err) + return nil, fmt.Errorf( + "workspace: open prepared worktree target: %w", + err, + ) } removeOnFailure := true defer func() { @@ -32,7 +73,7 @@ func (m *Manager) createPreparedWorktreeTarget( result, removeOpenedDirectoryFromRoot( bucket, - target.AgentID, + name, opened, ), ) @@ -45,21 +86,21 @@ func (m *Manager) createPreparedWorktreeTarget( directoryIdentity, err := openedDirectoryIdentity(opened) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: inspect prepared worktree target identity: %w", err, ) } if err := verifyRootEntryUnchanged( bucket, - target.AgentID, + name, opened, ); err != nil { - return err + return nil, err } directory, err := bucket.Open(".") if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: open prepared worktree parent: %w", err, ) @@ -67,7 +108,7 @@ func (m *Manager) createPreparedWorktreeTarget( syncErr := syncRecordDirectory(directory) closeErr := directory.Close() if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: sync prepared worktree parent: %w", err, ) @@ -83,18 +124,18 @@ func (m *Manager) createPreparedWorktreeTarget( err = errors.New("workspace: preparation record is missing") } if err != nil { - return err + return nil, err } if target.branchOperationID == "" || record.BranchOperationID != target.branchOperationID || !sameWorkspace(record.workspace(), *target) { - return errors.New( + return nil, errors.New( "workspace: preparation record does not match prepared target", ) } record.DirectoryIdentity = directoryIdentity if err := m.replaceWorkspaceRecord(record); err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: persist prepared worktree target identity: %w", err, ) @@ -105,48 +146,53 @@ func (m *Manager) createPreparedWorktreeTarget( target.Path, ) if err != nil { - return err + return nil, err } if !exists || persisted.DirectoryIdentity != directoryIdentity || persisted.BranchOperationID != target.branchOperationID || !sameWorkspace(persisted.workspace(), *target) { - return errors.New( + return nil, errors.New( "workspace: prepared worktree target identity was not persisted", ) } currentIdentity, err := openedDirectoryIdentity(opened) if err != nil { - return err + return nil, err } if currentIdentity != directoryIdentity { - return errors.New( + return nil, errors.New( "workspace: prepared worktree target identity changed after persistence", ) } if err := verifyRootEntryUnchanged( bucket, - target.AgentID, + name, opened, ); err != nil { - return err + return nil, err } removeOnFailure = false - return nil + if err := bucket.Close(); err != nil { + bucket = nil + return nil, err + } + bucket = nil + openedResult := &preparedWorktreeTarget{ + root: opened, + name: name, + path: path, + } + opened = nil + return openedResult, nil } func (m *Manager) initializePreparedWorktree( ctx context.Context, target *Workspace, + prepared *preparedWorktreeTarget, ) (result error) { - root, err := openRealPathRoot(target.Path) - if err != nil { - return fmt.Errorf("workspace: open prepared worktree: %w", err) - } - defer func() { - result = errors.Join(result, root.Close()) - }() - + root := prepared.root directoryIdentity, err := openedDirectoryIdentity(root) if err != nil { return fmt.Errorf( @@ -166,13 +212,13 @@ func (m *Manager) initializePreparedWorktree( } gitDirectory, err := m.worktreeGitDirectoryAtRoot( ctx, - target.Path, + prepared.path, root, ) if err != nil { return err } - if err := verifyRealPathRoot(target.Path, root); err != nil { + if err := verifyRealPathRoot(prepared.path, root); err != nil { return err } target.gitDirectory = gitDirectory @@ -197,7 +243,7 @@ func (m *Manager) initializePreparedWorktree( } if _, err := m.runRootedGit( ctx, - target.Path, + prepared.path, root, "reset", "--hard", @@ -207,6 +253,216 @@ func (m *Manager) initializePreparedWorktree( return nil } +func (m *Manager) promotePreparedWorktreeTarget( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, +) (result error) { + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + if err := verifyRootEntryUnchanged( + bucket, + prepared.name, + prepared.root, + ); err != nil { + return err + } + if _, err := bucket.Lstat(target.AgentID); !errors.Is( + err, + os.ErrNotExist, + ) { + if err == nil { + return errors.New( + "workspace: prepared worktree target already exists", + ) + } + return fmt.Errorf( + "workspace: inspect prepared worktree destination: %w", + err, + ) + } + opened, err := prepared.root.Stat(".") + if err != nil { + return err + } + directory, err := bucket.Open(".") + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + prepared.name, + target.AgentID, + ) + if moved { + prepared.name = target.AgentID + prepared.path = target.Path + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: promote prepared worktree target: %w", + err, + ) + } + if !moved { + return errors.New("workspace: prepared worktree target was not promoted") + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + prepared.root, + ); err != nil { + return err + } + if _, err := m.runRootedGit( + ctx, + target.Path, + prepared.root, + "worktree", + "repair", + ".", + ); err != nil { + return fmt.Errorf( + "workspace: repair promoted worktree registration: %w", + err, + ) + } + return verifyRootEntryUnchanged( + bucket, + target.AgentID, + prepared.root, + ) +} + +func (m *Manager) cleanupPreparedWorktreeTarget( + target Workspace, + prepared *preparedWorktreeTarget, +) (result error) { + if prepared == nil || prepared.root == nil { + return nil + } + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return errors.Join(err, prepared.root.Close()) + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + root := prepared.root + prepared.root = nil + return removeOpenedDirectoryFromRoot(bucket, prepared.name, root) +} + +func (prepared *preparedWorktreeTarget) Close() error { + if prepared == nil || prepared.root == nil { + return nil + } + err := prepared.root.Close() + prepared.root = nil + return err +} + +func (m *Manager) preparedWorktreeStagingPath( + target Workspace, + record workspaceRecord, +) (_ string, _ bool, result error) { + name, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return "", false, err + } + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return "", false, err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + info, err := bucket.Lstat(name) + switch { + case errors.Is(err, os.ErrNotExist): + return "", false, nil + case err != nil: + return "", false, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return "", false, errors.New( + "workspace: prepared worktree staging path is not a real directory", + ) + default: + return filepath.Join(filepath.Dir(target.Path), name), true, nil + } +} + +func (m *Manager) removeStagedPreparedWorktree( + ctx context.Context, + target Workspace, + record workspaceRecord, + path string, +) (result error) { + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + name := filepath.Base(path) + opened, err := openRealRootFromRoot(bucket, name) + if err != nil { + return err + } + openedOwned := true + defer func() { + if openedOwned { + result = errors.Join(result, opened.Close()) + } + }() + identity, err := openedDirectoryIdentity(opened) + if err != nil { + return err + } + if record.DirectoryIdentity == "" || + identity != record.DirectoryIdentity { + return errors.New( + "workspace: prepared worktree staging identity changed", + ) + } + repository, err := m.repositoryRootAtRoot(ctx, path, opened) + if err != nil { + return err + } + if repository != record.Repository { + return errors.New( + "workspace: prepared worktree staging repository changed", + ) + } + gitDirectory, err := m.worktreeGitDirectoryAtRoot(ctx, path, opened) + if err != nil { + return err + } + if record.GitDirectory == "" || + gitDirectory != record.GitDirectory { + return errors.New( + "workspace: prepared worktree staging Git directory changed", + ) + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } + openedOwned = false + return removeOpenedDirectoryFromRoot(bucket, name, opened) +} + func (m *Manager) verifyPreparedWorktreeForAcknowledgement( ctx context.Context, target Workspace, diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 05829e4..ac0c8b7 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -1112,15 +1112,40 @@ func (m *Manager) removeRecordAcknowledgementQuarantine( ); err != nil { return err } - if err := file.Close(); err != nil { + directory, err := bucket.Open(".") + if err != nil { return fmt.Errorf( - "workspace: close quarantined removal record %q: %w", + "workspace: open quarantined record directory %q: %w", recordPath, err, ) } - fileOpen = false - if err := bucket.Remove(name); err != nil { + deleteName := recordAcknowledgementPrefix( + removal.Workspace.AgentID, + removal.operationID, + ) + uuid.NewString() + moved, moveErr := moveRecordFile( + directory, + file, + name, + deleteName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(moveErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: isolate quarantined record %q: %w", + recordPath, + err, + ) + } + if !moved { + return fmt.Errorf( + "workspace: quarantined record %q was not isolated", + recordPath, + ) + } + if err := removeRecordPathIfSame(bucket, deleteName, file); err != nil { return fmt.Errorf( "workspace: remove quarantined record %q: %w", recordPath, diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 67d1b09..65df6dc 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -54,7 +54,7 @@ func renameRecordFile( return installed, err } if err := windows.FlushFileBuffers( - windows.Handle(temporary.Fd()), + windows.Handle(renaming.Fd()), ); err != nil { return true, fmt.Errorf("flush renamed record: %w", err) } @@ -127,7 +127,10 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { ) err = windows.NtCreateFile( &handle, - windows.FILE_READ_ATTRIBUTES|windows.DELETE|windows.SYNCHRONIZE, + windows.FILE_READ_ATTRIBUTES| + windows.FILE_WRITE_DATA| + windows.DELETE| + windows.SYNCHRONIZE, attributes, &status, nil, diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 44bdc1d..7e05b34 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -180,3 +180,44 @@ func TestRenameWindowsHandleAcceptsReadOnlyFile(t *testing.T) { } assertFileContents(t, filepath.Join(rootPath, "target"), "source\n") } + +func TestMoveRecordFileFlushesThroughRenameHandle(t *testing.T) { + rootPath := t.TempDir() + if err := os.WriteFile( + filepath.Join(rootPath, "source"), + []byte("source\n"), + 0o600, + ); err != nil { + t.Fatalf("write source: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + source, err := root.Open("source") + if err != nil { + t.Fatalf("open read-only source: %v", err) + } + defer source.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + moved, moveErr := moveRecordFile( + directory, + source, + "source", + "target", + ) + closeErr := directory.Close() + if moveErr != nil || !moved || closeErr != nil { + t.Fatalf( + "move read-only source = moved %v, move %v, close %v", + moved, + moveErr, + closeErr, + ) + } + assertFileContents(t, filepath.Join(rootPath, "target"), "source\n") +} diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index baa2e16..af1dbd2 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -86,6 +86,12 @@ func (m *Manager) remove( } } + if record.Removal == nil { + record, err = m.ensureRemovalRepositoryEvidence(ctx, record) + if err != nil { + return RemovalResult{}, err + } + } removal := Removal{Workspace: workspaceForRemoval(record)} if record.Removal != nil { removal.operationID = record.Removal.OperationID @@ -271,9 +277,9 @@ func (m *Manager) removalRepository( ) (repositoryCapability, func() error, error) { if record.Version < workspaceRecordVersion || record.RepositoryEvidence == nil { - return pathRepositoryCapability(m, record.Repository), - func() error { return nil }, - nil + return repositoryCapability{}, nil, errors.New( + "workspace: pending removal has no repository identity evidence", + ) } lease, err := openRecordedPreparationLease(ctx, m, record) if err != nil { @@ -282,6 +288,76 @@ func (m *Manager) removalRepository( return lease.repository, lease.Close, nil } +func (m *Manager) ensureRemovalRepositoryEvidence( + ctx context.Context, + record workspaceRecord, +) (_ workspaceRecord, result error) { + if record.Version >= workspaceRecordVersion && + record.RepositoryEvidence != nil { + return record, nil + } + if record.Removal != nil { + return workspaceRecord{}, errors.New( + "workspace: pending removal has no repository identity evidence", + ) + } + root, err := openRealPathRoot(record.Repository) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: open removal repository: %w", + err, + ) + } + rootOwned := true + defer func() { + if rootOwned { + result = errors.Join(result, root.Close()) + } + }() + repository, err := m.repositoryRootAtRoot( + ctx, + record.Repository, + root, + ) + if err != nil { + return workspaceRecord{}, err + } + if repository != record.Repository { + return workspaceRecord{}, errors.New( + "workspace: removal repository changed while opening", + ) + } + lease, err := newPreparationLease( + ctx, + m, + record.Repository, + root, + ) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: retain removal repository: %w", + err, + ) + } + rootOwned = false + defer func() { + result = errors.Join(result, lease.Close()) + }() + + upgradeWorkspaceRecord(&record) + record.RepositoryEvidence = cloneRepositoryEvidence(&lease.evidence) + if record.IncludedPaths == nil { + record.IncludedPaths = []string{} + } + if err := m.replaceWorkspaceRecord(record); err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: persist removal repository identity: %w", + err, + ) + } + return record, nil +} + func (m *Manager) removalFacts( ctx context.Context, record workspaceRecord, diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index fc6e3ce..7bc0825 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -188,38 +188,56 @@ func (r repositoryCapability) repositoryDirectories( err, ) } - lines := strings.Split(trimGitLineTerminator(output), "\n") - if len(lines) != 2 { + return parseRepositoryDirectories(output) +} + +func parseRepositoryDirectories(output []byte) (string, string, error) { + raw := trimGitLineTerminator(output) + type directoryPair struct { + git string + common string + } + var candidates []directoryPair + for index := strings.IndexByte(raw, '\n'); index >= 0; { + gitPath := raw[:index] + commonPath := raw[index+1:] + if filepath.IsAbs(gitPath) && filepath.IsAbs(commonPath) { + resolvedGit, gitErr := resolveExistingDirectory(gitPath) + resolvedCommon, commonErr := resolveExistingDirectory(commonPath) + if gitErr == nil && commonErr == nil { + candidates = append(candidates, directoryPair{ + git: resolvedGit, + common: resolvedCommon, + }) + } + } + next := strings.IndexByte(raw[index+1:], '\n') + if next < 0 { + break + } + index += next + 1 + } + if len(candidates) != 1 { return "", "", errors.New( "workspace: Git directory query returned an invalid response", ) } - resolve := func(kind string, path string) (string, error) { - if !filepath.IsAbs(path) { - return "", fmt.Errorf( - "workspace: %s Git directory is not absolute", - kind, - ) - } - resolved, resolveErr := resolvePath(path) - if resolveErr != nil { - return "", fmt.Errorf( - "workspace: resolve %s Git directory: %w", - kind, - resolveErr, - ) - } - return resolved, nil - } - gitPath, err := resolve("source", lines[0]) + return candidates[0].git, candidates[0].common, nil +} + +func resolveExistingDirectory(path string) (string, error) { + resolved, err := resolvePath(path) if err != nil { - return "", "", err + return "", err } - commonPath, err := resolve("common", lines[1]) + root, err := openRealPathRoot(resolved) if err != nil { - return "", "", err + return "", err + } + if err := root.Close(); err != nil { + return "", err } - return gitPath, commonPath, nil + return resolved, nil } func (r repositoryCapability) headOID( diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 8e067d2..6ba1caa 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -4,6 +4,7 @@ package workspace import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -66,11 +67,7 @@ func TestPrepareRollsBackRootedWorktreeAfterPostSuccessSourceSwap( if err != nil { t.Fatalf("resolve source: %v", err) } - targetPath := filepath.Join( - manager.root, - repositoryHash(canonicalSource), - testAgentID, - ) + targetPath := filepath.Join(manager.root, repositoryHash(canonicalSource), testAgentID) manager.git = postSuccessSwapGitWrapper( t, parent, @@ -258,21 +255,20 @@ func TestPrepareRemovesPrecreatedTargetWhenWorktreeAddFails(t *testing.T) { invoked := filepath.Join(parent, "worktree-add-invoked") wrapper := filepath.Join(parent, "git-wrapper-add-failure") script := `#!/bin/sh -saw_worktree= +next_target= for argument in "$@"; do - if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then - test -d "$DROVE_TEST_TARGET" || exit 98 - : > "$DROVE_TEST_INVOKED" + if [ -n "$next_target" ]; then + test -d "$argument" || exit 98 + printf '%s' "$argument" > "$DROVE_TEST_INVOKED" exit 97 fi - [ "$argument" = "worktree" ] && saw_worktree=1 + [ "$argument" = "--no-checkout" ] && next_target=1 done exec "$DROVE_TEST_REAL_GIT" "$@" ` if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_TARGET", targetPath) t.Setenv("DROVE_TEST_INVOKED", invoked) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -288,6 +284,13 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if _, err := os.Lstat(invoked); err != nil { t.Fatalf("worktree add wrapper was not invoked: %v", err) } + stagingPath, err := os.ReadFile(invoked) + if err != nil { + t.Fatalf("read worktree add target: %v", err) + } + if _, err := os.Lstat(string(stagingPath)); !os.IsNotExist(err) { + t.Fatalf("staging target remains or inspect failed: %v", err) + } if _, err := os.Lstat(targetPath); !os.IsNotExist(err) { t.Fatalf("precreated target remains or inspect failed: %v", err) } @@ -307,16 +310,6 @@ func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - canonicalSource, err := resolvePath(source) - if err != nil { - t.Fatalf("resolve source: %v", err) - } - targetPath := filepath.Join( - manager.root, - repositoryHash(canonicalSource), - testAgentID, - ) - originalPath := targetPath + "-original" replacementPath := filepath.Join(parent, "replacement-target") if err := os.Mkdir(replacementPath, 0o700); err != nil { t.Fatalf("create replacement target: %v", err) @@ -325,35 +318,36 @@ func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { t.Fatalf("write replacement sentinel: %v", err) } + swappedPath := filepath.Join(parent, "swapped-target") realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("find Git: %v", err) } wrapper := filepath.Join(parent, "git-wrapper-target-swap") script := `#!/bin/sh -matched= -saw_worktree= +target= +next_target= for argument in "$@"; do - if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then - matched=1 - break + if [ -n "$next_target" ]; then + target=$argument + next_target= fi - [ "$argument" = "worktree" ] && saw_worktree=1 + [ "$argument" = "--no-checkout" ] && next_target=1 done "$DROVE_TEST_REAL_GIT" "$@" status=$? -if [ "$status" -eq 0 ] && [ -n "$matched" ]; then - mv "$DROVE_TEST_TARGET" "$DROVE_TEST_ORIGINAL" || exit 91 - mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_TARGET" || exit 92 +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + mv "$target" "$target-opened" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$target" || exit 92 + printf '%s' "$target" > "$DROVE_TEST_SWAPPED_PATH" || exit 93 fi exit "$status" ` if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_TARGET", targetPath) - t.Setenv("DROVE_TEST_ORIGINAL", originalPath) t.Setenv("DROVE_TEST_REPLACEMENT", replacementPath) + t.Setenv("DROVE_TEST_SWAPPED_PATH", swappedPath) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -365,7 +359,15 @@ exit "$status" ); err == nil { t.Fatal("prepare accepted a replaced precreated target") } - assertFileContents(t, filepath.Join(targetPath, "must-remain"), "replacement\n") + rawPath, err := os.ReadFile(swappedPath) + if err != nil { + t.Fatalf("read swapped target path: %v", err) + } + assertFileContents( + t, + filepath.Join(string(rawPath), "must-remain"), + "replacement\n", + ) } func TestPrepareRollsBackExistingBranchMovedBeforeWorktreeAdd(t *testing.T) { @@ -640,6 +642,112 @@ exec "$DROVE_TEST_REAL_GIT" "$@" assertPreparationRefsAbsent(t, source, prepared.Branch) } +func TestReconcileUpgradedRemovalRejectsReplacementSourceRepository( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + replacement := filepath.Join(parent, "replacement") + openedSource := source + "-opened" + dataDir := filepath.Join(parent, "data") + initSourceSwapRepository(t, source) + initSourceSwapRepository(t, replacement) + + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Version = repositoryWorkspaceRecordVersion + record.RepositoryEvidence.GitDirectory = "" + record.RepositoryEvidence.GitDirectoryIdentity = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 4 workspace record: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-prune-failure") + script := `#!/bin/sh +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "prune" ]; then + exit 97 + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err == nil || result.State != RemovalPending { + t.Fatalf("removal with prune failure = %+v, err=%v", result, err) + } + upgraded, exists, recordErr := manager.readWorkspaceRecord(prepared.Path) + if recordErr != nil || !exists || + upgraded.Version != workspaceRecordVersion || + upgraded.RepositoryEvidence == nil { + t.Fatalf( + "upgraded removal record = %+v, exists=%v err=%v", + upgraded, + exists, + recordErr, + ) + } + manager.git = realGit + if err := os.Rename(source, openedSource); err != nil { + t.Fatalf("move source repository: %v", err) + } + if err := os.Rename(replacement, source); err != nil { + t.Fatalf("install replacement repository: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if _, err := restarted.ReconcileRemovals( + context.Background(), + ); err == nil { + t.Fatal("upgraded removal accepted a replacement repository") + } + if listing := runGit( + t, + openedSource, + "worktree", + "list", + "--porcelain", + ); !strings.Contains(listing, prepared.Path) { + t.Fatal("original repository registration was unexpectedly pruned") + } +} + func TestAcknowledgeRejectsReplacementCommonGitDirectory( t *testing.T, ) { @@ -752,6 +860,171 @@ func TestAcknowledgeRejectsReplacementSourceGitPointer(t *testing.T) { } } +func TestPrepareDoesNotExposeCanonicalTargetToWorktreeAdd(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + dataDir := filepath.Join(parent, "data") + outside := filepath.Join(parent, "outside") + initSourceSwapRepository(t, source) + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside directory: %v", err) + } + canonicalSource, err := resolvePath(source) + if err != nil { + t.Fatalf("resolve source repository: %v", err) + } + targetPath := filepath.Join( + dataDir, + worktreeDirectory, + repositoryHash(canonicalSource), + testAgentID, + ) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +case " $* " in + *" worktree add "*) + if [ -d "$DROVE_TEST_TARGET" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_TARGET-opened" || exit 91 + ln -s "$DROVE_TEST_OUTSIDE" "$DROVE_TEST_TARGET" || exit 92 + : > "$DROVE_TEST_SWAPPED" + fi + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", targetPath) + t.Setenv("DROVE_TEST_OUTSIDE", outside) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if _, err := os.Lstat(swapped); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canonical target was exposed during worktree add: %v", err) + } + if _, err := os.Lstat(filepath.Join(outside, ".git")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("Git wrote outside the managed bucket: %v", err) + } + manager.git = realGit + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared workspace: %v", err) + } +} + +func TestAcknowledgeRejectsTargetSwapDuringOwnershipCleanup(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + openedTarget := prepared.Path + "-opened" + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +case " $* " in + *" update-ref -d refs/drove/preparations/"*) + if [ ! -e "$DROVE_TEST_SWAPPED" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_OPENED_TARGET" || exit 91 + mkdir "$DROVE_TEST_TARGET" || exit 92 + printf 'replacement\n' > "$DROVE_TEST_TARGET/must-remain" || exit 93 + : > "$DROVE_TEST_SWAPPED" + fi + ;; +esac +"$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", prepared.Path) + t.Setenv("DROVE_TEST_OPENED_TARGET", openedTarget) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + err = manager.AcknowledgePreparation(prepared) + if err == nil || !strings.Contains( + err.Error(), + "prepared worktree directory identity changed", + ) { + t.Fatalf("acknowledgement target swap error = %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, "must-remain"), + "replacement\n", + ) + record, exists, recordErr := manager.readWorkspaceRecord(prepared.Path) + if recordErr != nil || !exists || + !record.PreparationCommitted || + record.BranchOperationID == "" { + t.Fatalf( + "failed acknowledgement record = %+v, exists=%v err=%v", + record, + exists, + recordErr, + ) + } + + if err := os.RemoveAll(prepared.Path); err != nil { + t.Fatalf("remove replacement target: %v", err) + } + if err := os.Rename(openedTarget, prepared.Path); err != nil { + t.Fatalf("restore prepared target: %v", err) + } + manager.git = realGit + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("retry acknowledgement: %v", err) + } + if _, err := removeWorkspace( + t, + manager, + context.Background(), + prepared.AgentID, + true, + ); err != nil { + t.Fatalf("remove acknowledged workspace: %v", err) + } +} + func TestAcknowledgeQueriesSourceGitDirectoriesTogether(t *testing.T) { parent := t.TempDir() repository := filepath.Join(parent, "repository") @@ -796,7 +1069,8 @@ for argument in "$@"; do esac done matched= -if [ -n "$has_git" ] && [ -z "$has_common" ]; then +if [ -n "$has_git" ] && [ -z "$has_common" ] && + [ "$GIT_WORK_TREE" = "$DROVE_TEST_SOURCE" ]; then count=0 if [ -f "$DROVE_TEST_COUNTER" ]; then count=$(cat "$DROVE_TEST_COUNTER") || exit 90 @@ -824,6 +1098,7 @@ exit "$status" filepath.Join(replacement, ".git"), ) t.Setenv("DROVE_TEST_SOURCE_POINTER", sourcePointer) + t.Setenv("DROVE_TEST_SOURCE", source) t.Setenv("DROVE_TEST_SWAPPED", swapped) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 29ab99f..860c740 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -308,7 +308,8 @@ func (m *Manager) prepare( m.discard(cleanupCtx, cleanupTarget), ) } - if err := m.createPreparedWorktreeTarget(&result); err != nil { + preparedTarget, err := m.createPreparedWorktreeTarget(&result) + if err != nil { cleanupCtx, cancel := context.WithTimeout( context.Background(), 10*time.Second, @@ -324,6 +325,33 @@ func (m *Manager) prepare( ), ) } + defer func() { + if resultErr != nil { + cleanupCtx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + cleanupErr := m.cleanupPreparedWorktreeTarget( + result, + preparedTarget, + ) + pruneErr := sourceRepository.pruneWorktrees(cleanupCtx) + discardErr := m.discardWithRepository( + cleanupCtx, + result, + sourceRepository, + true, + ) + resultErr = errors.Join( + resultErr, + cleanupErr, + pruneErr, + discardErr, + ) + } + resultErr = errors.Join(resultErr, preparedTarget.Close()) + }() if createdBranch { if err := sourceRepository.createOwnedBranch( ctx, @@ -394,8 +422,14 @@ func (m *Manager) prepare( ) } } - arguments := []string{"worktree", "add", "--quiet", "--no-checkout"} - arguments = append(arguments, path, branch) + arguments := []string{ + "worktree", + "add", + "--quiet", + "--no-checkout", + preparedTarget.path, + branch, + } if _, err := sourceRepository.runForward( ctx, "", @@ -417,7 +451,11 @@ func (m *Manager) prepare( ) } - if err := m.initializePreparedWorktree(ctx, &result); err != nil { + if err := m.initializePreparedWorktree( + ctx, + &result, + preparedTarget, + ); err != nil { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return Workspace{}, errors.Join( @@ -430,6 +468,26 @@ func (m *Manager) prepare( ), ) } + if err := m.promotePreparedWorktreeTarget( + ctx, + result, + preparedTarget, + ); err != nil { + cleanupCtx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + return Workspace{}, errors.Join( + err, + m.discardWithRepository( + cleanupCtx, + result, + sourceRepository, + true, + ), + ) + } if err := verifyRealPathRoot(sourcePath, sourceRoot); err != nil { cleanupCtx, cancel := context.WithTimeout( @@ -518,7 +576,8 @@ func (m *Manager) prepare( } func workspaceManagerLock(root string) *sync.Mutex { - lock, _ := managerLocks.LoadOrStore(root, &sync.Mutex{}) + key := workspaceManagerLockKey(root) + lock, _ := managerLocks.LoadOrStore(key, &sync.Mutex{}) return lock.(*sync.Mutex) } @@ -768,12 +827,24 @@ func (m *Manager) discardWithRepository( _, pathErr := os.Lstat(target.Path) pathExists := pathErr == nil pathMissing := errors.Is(pathErr, os.ErrNotExist) + activePath := target.Path if pathErr != nil && !errors.Is(pathErr, os.ErrNotExist) { result = errors.Join( result, fmt.Errorf("workspace: inspect discarded worktree: %w", pathErr), ) } + if pathMissing { + stagingPath, exists, stagingErr := + m.preparedWorktreeStagingPath(target, record) + if stagingErr != nil { + result = errors.Join(result, stagingErr) + } else if exists { + activePath = stagingPath + pathExists = true + pathMissing = false + } + } if pathExists && (record.DirectoryIdentity == "" || (record.GitDirectory == "" && !allowIncompleteGitIdentity)) { @@ -786,7 +857,7 @@ func (m *Manager) discardWithRepository( } registered, err := repository.worktreeRegistered( ctx, - target.Path, + activePath, ) worktreeRemoved := false if err != nil { @@ -795,7 +866,14 @@ func (m *Manager) discardWithRepository( pathRemoved := pathMissing if pathExists { var removeErr error - if repository.root != nil { + if activePath != target.Path { + removeErr = m.removeStagedPreparedWorktree( + ctx, + target, + record, + activePath, + ) + } else if repository.root != nil { removeErr = m.removePreparedWorktreeAtRoot( ctx, target, @@ -834,7 +912,7 @@ func (m *Manager) discardWithRepository( if pathRemoved { stillRegistered, err := repository.worktreeRegistered( ctx, - target.Path, + activePath, ) if err != nil { result = errors.Join(result, err) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 5df35ae..b51576a 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -953,7 +953,7 @@ func TestRemoveUpgradesCommittedVersionFourRecord(t *testing.T) { t.Fatalf("read upgraded removal record: exists=%v err=%v", exists, err) } if upgraded.Version != workspaceRecordVersion || - upgraded.RepositoryEvidence != nil || + upgraded.RepositoryEvidence == nil || upgraded.BranchOperationID != "" || upgraded.Removal == nil { t.Fatalf("upgraded removal record = %+v", upgraded) @@ -2010,6 +2010,42 @@ func TestPreparePreservesTrailingSpaceInRepositoryPath(t *testing.T) { } } +func TestPrepareSupportsNewlineInRepositoryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot contain a newline") + } + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve repository parent: %v", err) + } + repository := filepath.Join(parent, "repository\nline") + initTestRepositoryAt(t, repository) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare newline repository: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "prepared repository = %q, want %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard newline repository: %v", err) + } +} + func TestPrepareRejectsMissingRepositoryWithoutRunningGit(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { From 23eeb950c77b8657dbcf8142c8e24157d796983e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 21:26:25 +0800 Subject: [PATCH 003/104] fix: close workspace recovery review gaps --- internal/session/AGENTS.md | 2 + internal/session/projection.go | 8 +- internal/session/projection_test.go | 78 ++++++ internal/workspace/AGENTS.md | 3 +- internal/workspace/include.go | 2 +- internal/workspace/manager_lock_key_darwin.go | 12 + .../workspace/manager_lock_key_darwin_test.go | 36 +++ internal/workspace/manager_lock_key_other.go | 2 +- internal/workspace/preparation.go | 5 + internal/workspace/preparation_lease.go | 7 + internal/workspace/preparation_test.go | 68 ++++++ internal/workspace/prepared_rollback.go | 163 +++++++++++++ internal/workspace/prepared_worktree.go | 12 +- internal/workspace/record.go | 129 ++++++++-- internal/workspace/record_rename_windows.go | 7 +- internal/workspace/recovery_security_test.go | 93 ++++++++ internal/workspace/removal.go | 59 ++--- internal/workspace/repository_capability.go | 15 +- internal/workspace/secure_remove.go | 224 ++++++++++-------- internal/workspace/source_swap_test.go | 177 ++++++++++++++ internal/workspace/workspace.go | 80 ++++++- internal/workspace/workspace_test.go | 11 + 22 files changed, 1011 insertions(+), 182 deletions(-) create mode 100644 internal/workspace/manager_lock_key_darwin.go create mode 100644 internal/workspace/manager_lock_key_darwin_test.go diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index a3f65f9..e5933e8 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -120,6 +120,8 @@ - 恢复投影只接受紧邻同 Agent `agent.resumed` 的 `Stopped -> Starting`;启动自动恢复 只消费重启前非终态且已有 ref 的一次性候选,并按创建时间排序。候选等待同 Agent cleanup completion,只有成功安装 resume reservation 后才消费;取消等待保留候选。 + daemon 重启生成并持久化的 recovery `Stopped` 在真正尝试 resume 前仍保留该资格, + 连续启动失败不能把候选静默降级为普通停止会话。 - 信号与状态证据 reader 同时接受 v1、v2、typed screen v3 和 typed terminal v4;v2 的 notify 只在 fallback 下确认 Idle。未知补充版本按既有计数策略跳过,已知畸形版本报错。 adapter 标记为忽略的厂商内部通知不提交事件。 diff --git a/internal/session/projection.go b/internal/session/projection.go index e1dd4fd..1348b89 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -59,6 +59,7 @@ type sessionDraft struct { workingDir string workspace *workspaceMetadata workspaceRemoved bool + restartStopped bool hasCreated bool hasState bool } @@ -354,6 +355,8 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { p.report.PartialHistory++ } draft.state = to + draft.restartStopped = + to == agent.StateStopped && row.Reason == restartStopReason if known { draft.lastTransition = evidence } @@ -678,8 +681,9 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) plan.VendorSessionRefs[draft.id] = draft.vendorSessionRef plan.ResumeOnStart[draft.id] = !draft.workspaceRemoved && - state != agent.StateDone && - state != agent.StateStopped + (state != agent.StateDone && + state != agent.StateStopped || + draft.restartStopped) } workingDir := draft.workingDir if draft.workspaceRemoved { diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 507795f..01ac2e4 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -322,6 +322,84 @@ func TestRecoveryProjectorRestoresLatestVendorSessionReference(t *testing.T) { } } +func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 12, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-05T12:00:02Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + } + first := newRecoveryProjector() + for _, row := range rows { + if err := first.Apply(row); err != nil { + t.Fatalf("apply initial seq %d: %v", row.Seq, err) + } + } + firstPlan, err := first.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish initial projection: %v", err) + } + rows = append(rows, firstPlan.Reconciliation...) + + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if !restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("persisted daemon restart stop lost automatic resume intent") + } + if len(restartedPlan.Reconciliation) != 0 { + t.Fatalf( + "restarted reconciliation = %+v, want no duplicate stop", + restartedPlan.Reconciliation, + ) + } +} + func TestRecoveryProjectorReadsVersionTwoMetadataAndEvidence(t *testing.T) { base := time.Date(2026, time.October, 3, 5, 0, 0, 0, time.UTC) projector := newRecoveryProjector() diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 36e9cd1..3309729 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -98,12 +98,13 @@ sidecar repository evidence 重开的 capability;拒绝未开始的 removal 时必须先验证并 持久删除目录 marker,再清除 intent。ack sidecar 隔离名在正式记录缺失时必须按 Agent ID 恢复并回读 operation ID,使 session 重试和重启 reconciliation 能继续; + 同 operation 且 `os.SameFile` 的多个 ack 名必须折叠,任一不同身份仍 fail-stop; 正式记录已存在时不得恢复旧 ack。对 version 1-4 记录发起新删除时,必须在写 intent 前取得并持久化完整的 version 5 repository evidence;已经存在但缺少该证据的 removal intent 只能 fail-stop,禁止在恢复时重新信任记录中的公开仓库路径。清理始终保留分支。 - Manager 创建不预先查找 Git,也不创建目录;只有实际查询或变更 worktree 时才解析并 执行 `git`,因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。缺失的 - DataDir 必须从已打开的最近现存祖先通过 `os.Root` 逐级创建,禁止 `MkdirAll` 沿可替换 + DataDir 必须从已打开的卷根通过 `os.Root` 逐级创建,禁止 `MkdirAll` 沿可替换 的公开路径创建。 - Manager 初始化时固定既有 data directory、worktrees root 和 repository bucket 的 文件身份,新建目录则在首次打开时固定。后续通过 `os.Root` 逐级打开并持续复核; diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 02fe69d..1730761 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -745,7 +745,7 @@ func copyIncludedPath( output, err := destinationParent.OpenFile( temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, - sourceInfo.Mode().Perm(), + openedInfo.Mode().Perm(), ) if err != nil { return fmt.Errorf("create staged destination: %w", err) diff --git a/internal/workspace/manager_lock_key_darwin.go b/internal/workspace/manager_lock_key_darwin.go new file mode 100644 index 0000000..5b92cb7 --- /dev/null +++ b/internal/workspace/manager_lock_key_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package workspace + +import ( + "path/filepath" + "strings" +) + +func workspaceManagerLockKey(path string) string { + return strings.ToUpper(filepath.Clean(path)) +} diff --git a/internal/workspace/manager_lock_key_darwin_test.go b/internal/workspace/manager_lock_key_darwin_test.go new file mode 100644 index 0000000..1bee37e --- /dev/null +++ b/internal/workspace/manager_lock_key_darwin_test.go @@ -0,0 +1,36 @@ +//go:build darwin + +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWorkspaceManagerLockKeyFoldsDarwinPathCase(t *testing.T) { + root := t.TempDir() + lower := filepath.Join(root, "data", "worktrees") + upper := filepath.Join(root, "DATA", "WORKTREES") + if workspaceManagerLockKey(lower) != workspaceManagerLockKey(upper) { + t.Fatal("case aliases received different manager lock keys") + } + if workspaceManagerLock(lower) != workspaceManagerLock(upper) { + t.Fatal("case aliases received different manager locks") + } +} + +func TestRegisteredWorktreePathAcceptsDarwinCaseAlias(t *testing.T) { + root := t.TempDir() + actual := filepath.Join(root, "Worktree") + if err := os.Mkdir(actual, 0o700); err != nil { + t.Fatalf("create worktree: %v", err) + } + alias := filepath.Join(root, "worktree") + if _, err := os.Stat(alias); err != nil { + t.Skip("test volume is case-sensitive") + } + if !sameRegisteredWorktreePath(actual, alias) { + t.Fatal("case aliases were treated as different worktree registrations") + } +} diff --git a/internal/workspace/manager_lock_key_other.go b/internal/workspace/manager_lock_key_other.go index 108d16b..e53cd5a 100644 --- a/internal/workspace/manager_lock_key_other.go +++ b/internal/workspace/manager_lock_key_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !darwin && !windows package workspace diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index 557b114..de68ffb 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -41,6 +41,11 @@ func (m *Manager) acknowledgePreparation( record.RepositoryEvidence == nil { return target.preparation.Close() } + if record.PreparationCommitted && + record.BranchOperationID == "" && + target.preparation.isClosed() { + return target.preparation.Close() + } lease := target.preparation temporaryLease := false if lease == nil { diff --git a/internal/workspace/preparation_lease.go b/internal/workspace/preparation_lease.go index f1da850..a23911d 100644 --- a/internal/workspace/preparation_lease.go +++ b/internal/workspace/preparation_lease.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "sync" + "sync/atomic" ) type preparationLease struct { @@ -20,6 +21,7 @@ type preparationLease struct { closeOnce sync.Once closeErr error + closed atomic.Bool } func newPreparationLease( @@ -327,6 +329,11 @@ func (l *preparationLease) Close() error { l.guard.Close(), l.root.Close(), ) + l.closed.Store(true) }) return l.closeErr } + +func (l *preparationLease) isClosed() bool { + return l != nil && l.closed.Load() +} diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 5202fa6..763679e 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -214,6 +214,71 @@ func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( } } +func TestReconcilePreparationsRepairsPromotionBeforeDiscard(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("move worktree to staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := os.Rename(stagingPath, prepared.Path); err != nil { + t.Fatalf("promote worktree without repairing registration: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile promote-pending preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } +} + func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( t *testing.T, ) { @@ -641,6 +706,9 @@ func TestAcknowledgePreparationClearsBranchOwnershipMarker(t *testing.T) { if !record.PreparationCommitted || record.BranchOperationID != "" { t.Fatalf("acknowledged record = %+v", record) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("repeat acknowledgement: %v", err) + } } func TestAcknowledgePreparationRejectsReplacementTarget(t *testing.T) { diff --git a/internal/workspace/prepared_rollback.go b/internal/workspace/prepared_rollback.go index fb9d079..3e9498c 100644 --- a/internal/workspace/prepared_rollback.go +++ b/internal/workspace/prepared_rollback.go @@ -3,6 +3,8 @@ package workspace import ( "context" "errors" + "fmt" + "path/filepath" ) func (m *Manager) removePreparedWorktreeAtRoot( @@ -24,6 +26,26 @@ func (m *Manager) removePreparedWorktreeAtRoot( if err != nil { return err } + if record.GitDirectory != "" && !exists { + repaired, repairErr := m.repairPromotedPreparedWorktree( + ctx, + target, + record, + repository, + ) + if repairErr != nil { + return repairErr + } + if repaired { + registered, exists, err = repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return err + } + } + } switch { case record.GitDirectory != "" && !exists: return errors.New( @@ -113,3 +135,144 @@ func (m *Manager) removePreparedWorktreeAtRoot( opened, ) } + +func (m *Manager) repairPromotedPreparedWorktree( + ctx context.Context, + target Workspace, + record workspaceRecord, + repository repositoryCapability, +) (result bool, resultErr error) { + if record.BranchOperationID == "" { + return false, nil + } + stagingName, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return false, err + } + stagingPath := filepath.Join(filepath.Dir(target.Path), stagingName) + stale, exists, err := repository.worktreeRegistration(ctx, stagingPath) + if err != nil || !exists { + return false, err + } + stagingTarget := target + stagingTarget.Path = stagingPath + if err := repository.verifyPreparedWorktree( + ctx, + stagingTarget, + record.GitDirectory, + stale, + ); err != nil { + return false, fmt.Errorf( + "workspace: verify promoted worktree staging registration: %w", + err, + ) + } + + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return false, err + } + defer func() { + resultErr = errors.Join(resultErr, bucket.Close()) + }() + opened, err := openRealRootFromRoot(bucket, target.AgentID) + if err != nil { + return false, err + } + defer func() { + resultErr = errors.Join(resultErr, opened.Close()) + }() + identity, err := openedDirectoryIdentity(opened) + if err != nil { + return false, err + } + if identity != record.DirectoryIdentity { + return false, errors.New( + "workspace: promoted worktree directory identity changed", + ) + } + currentRepository, err := m.repositoryRootAtRoot( + ctx, + target.Path, + opened, + ) + if err != nil { + return false, err + } + if currentRepository != record.Repository { + return false, errors.New( + "workspace: promoted worktree repository changed", + ) + } + gitDirectory, err := m.worktreeGitDirectoryAtRoot( + ctx, + target.Path, + opened, + ) + if err != nil { + return false, err + } + if gitDirectory != record.GitDirectory { + return false, errors.New( + "workspace: promoted worktree Git directory changed", + ) + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + opened, + ); err != nil { + return false, err + } + if _, err := m.runRootedGit( + ctx, + target.Path, + opened, + "worktree", + "repair", + ".", + ); err != nil { + return false, fmt.Errorf( + "workspace: repair promoted worktree registration: %w", + err, + ) + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + opened, + ); err != nil { + return false, err + } + current, exists, err := repository.worktreeRegistration(ctx, target.Path) + if err != nil { + return false, err + } + if !exists { + return false, errors.New( + "workspace: repaired worktree registration is missing", + ) + } + if err := repository.verifyPreparedWorktree( + ctx, + target, + record.GitDirectory, + current, + ); err != nil { + return false, err + } + if _, exists, err := repository.worktreeRegistration( + ctx, + stagingPath, + ); err != nil { + return false, err + } else if exists { + return false, errors.New( + "workspace: stale staging registration remains after repair", + ) + } + return true, nil +} diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index dcd76cd..e947fb8 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -172,18 +172,18 @@ func (m *Manager) createPreparedWorktreeTarget( ); err != nil { return nil, err } - removeOnFailure = false - if err := bucket.Close(); err != nil { - bucket = nil - return nil, err - } - bucket = nil openedResult := &preparedWorktreeTarget{ root: opened, name: name, path: path, } opened = nil + removeOnFailure = false + if err := bucket.Close(); err != nil { + bucket = nil + return openedResult, err + } + bucket = nil return openedResult, nil } diff --git a/internal/workspace/record.go b/internal/workspace/record.go index ac0c8b7..4399c79 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/google/uuid" @@ -58,6 +59,11 @@ type workspaceRemovalRecord struct { PathAbsent bool `json:"path_absent,omitempty"` } +type recordAcknowledgement struct { + name string + operationID string +} + func workspaceRecordPath(worktreePath string) string { return worktreePath + workspaceRecordSuffix } @@ -971,11 +977,7 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { return err } normalRecords := make(map[string]struct{}) - type acknowledgement struct { - name string - operationID string - } - acknowledgements := make(map[string][]acknowledgement) + acknowledgements := make(map[string][]recordAcknowledgement) for _, entry := range entries { if agentID, ok := workspaceRecordAgentID(entry.Name()); ok { normalRecords[agentID] = struct{}{} @@ -989,7 +991,7 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { } acknowledgements[agentID] = append( acknowledgements[agentID], - acknowledgement{ + recordAcknowledgement{ name: entry.Name(), operationID: operationID, }, @@ -999,15 +1001,18 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { if _, exists := normalRecords[agentID]; exists { continue } - if len(candidates) != 1 { + candidate, err := coalesceRecordAcknowledgements( + bucketRoot, + candidates, + ) + if err != nil { _ = bucketRoot.Close() return fmt.Errorf( - "workspace: agent %q has %d acknowledgement records", + "workspace: recover acknowledgement aliases for agent %q: %w", agentID, - len(candidates), + err, ) } - candidate := candidates[0] name := agentID + workspaceRecordSuffix if err := restoreRecordAcknowledgement( bucketRoot, @@ -1058,31 +1063,113 @@ func findRecordAcknowledgementQuarantine( return "", false, err } prefix := recordAcknowledgementPrefix(agentID, operationID) - var matched string + var candidates []recordAcknowledgement for _, entry := range entries { if !strings.HasPrefix(entry.Name(), prefix) { continue } - if matched != "" { - return "", false, fmt.Errorf( - "workspace: removal acknowledgement %q has multiple quarantined records", - operationID, + candidates = append(candidates, recordAcknowledgement{ + name: entry.Name(), + operationID: operationID, + }) + } + if len(candidates) == 0 { + return "", false, nil + } + matched, err := coalesceRecordAcknowledgements(bucket, candidates) + if err != nil { + return "", false, err + } + return matched.name, true, nil +} + +func coalesceRecordAcknowledgements( + bucket *os.Root, + candidates []recordAcknowledgement, +) (_ recordAcknowledgement, result error) { + if len(candidates) == 0 { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement candidate list is empty", + ) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].name < candidates[j].name + }) + operationID := candidates[0].operationID + var expected *os.File + for _, candidate := range candidates { + if candidate.operationID != operationID { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement aliases have different operation IDs", ) } - info, err := bucket.Lstat(entry.Name()) + info, err := bucket.Lstat(candidate.name) if err != nil { - return "", false, err + return recordAcknowledgement{}, err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return "", false, fmt.Errorf( + return recordAcknowledgement{}, fmt.Errorf( "workspace: acknowledgement quarantine %q is not a regular file", - entry.Name(), + candidate.name, ) } - matched = entry.Name() + file, err := bucket.Open(candidate.name) + if err != nil { + return recordAcknowledgement{}, err + } + opened, statErr := file.Stat() + if statErr == nil && + (!opened.Mode().IsRegular() || !os.SameFile(info, opened)) { + statErr = fmt.Errorf( + "workspace: acknowledgement quarantine %q changed while opening", + candidate.name, + ) + } + if statErr == nil && expected != nil { + expectedInfo, expectedErr := expected.Stat() + if expectedErr != nil { + statErr = expectedErr + } else if !os.SameFile(expectedInfo, opened) { + statErr = errors.New( + "workspace: acknowledgement aliases refer to different records", + ) + } + } + if statErr != nil { + _ = file.Close() + return recordAcknowledgement{}, statErr + } + if expected == nil { + expected = file + defer func() { + result = errors.Join(result, expected.Close()) + }() + continue + } + if err := file.Close(); err != nil { + return recordAcknowledgement{}, err + } + } + for _, duplicate := range candidates[1:] { + if err := removeRecordPathIfSame( + bucket, + duplicate.name, + expected, + ); err != nil { + return recordAcknowledgement{}, fmt.Errorf( + "workspace: remove acknowledgement alias %q: %w", + duplicate.name, + err, + ) + } + } + if len(candidates) > 1 { + if err := syncRecordBucket(bucket, candidates[0].name); err != nil { + return recordAcknowledgement{}, err + } } - return matched, matched != "", nil + return candidates[0], nil } func (m *Manager) removeRecordAcknowledgementQuarantine( diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 65df6dc..a486d83 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -154,6 +154,11 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { return file, nil } -func syncRecordDirectory(*os.File) error { +func syncRecordDirectory(directory *os.File) error { + if err := windows.FlushFileBuffers( + windows.Handle(directory.Fd()), + ); err != nil { + return fmt.Errorf("flush directory metadata: %w", err) + } return nil } diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 6a8a5f4..7a4b2c8 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -38,6 +38,38 @@ func TestEnsureManagedRootRejectsLateParentSymlink(t *testing.T) { } } +func TestEnsureManagedRootRejectsLateAncestorSymlink(t *testing.T) { + parent := t.TempDir() + ancestor := filepath.Join(parent, "ancestor") + stable := filepath.Join(ancestor, "stable") + if err := os.MkdirAll(stable, 0o700); err != nil { + t.Fatalf("create stable ancestor: %v", err) + } + manager, err := New(filepath.Join(stable, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if err := os.Rename(ancestor, ancestor+"-opened"); err != nil { + t.Fatalf("move original ancestor: %v", err) + } + outside := filepath.Join(parent, "outside") + if err := os.MkdirAll(filepath.Join(outside, "stable"), 0o700); err != nil { + t.Fatalf("create redirect target: %v", err) + } + if err := os.Symlink(outside, ancestor); err != nil { + t.Skipf("create ancestor symlink: %v", err) + } + + if err := manager.ensureManagedRoot(); err == nil { + t.Fatal("managed root creation followed a symlink in an earlier ancestor") + } + if _, err := os.Lstat( + filepath.Join(outside, "stable", "data"), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed root creation changed the symlink target: %v", err) + } +} + func TestDiscardRejectsReplacedDataDirectory(t *testing.T) { repository := newTestRepository(t) parent := t.TempDir() @@ -1580,6 +1612,67 @@ func TestAcknowledgeRemovalRecoversQuarantinedRecord(t *testing.T) { } } +func TestReconcileRemovalCoalescesAcknowledgementHardLinks(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + recordPath := workspaceRecordPath(prepared.Path) + prefix := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + firstPath := filepath.Join( + filepath.Dir(recordPath), + prefix+"71717171-7171-4717-8717-717171717171", + ) + secondPath := filepath.Join( + filepath.Dir(recordPath), + prefix+"72727272-7272-4727-8727-727272727272", + ) + if err := os.Rename(recordPath, firstPath); err != nil { + t.Fatalf("quarantine removal record: %v", err) + } + if err := os.Link(firstPath, secondPath); err != nil { + t.Skipf("create acknowledgement hard link: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + removals, err := restarted.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile acknowledgement aliases: %v", err) + } + if len(removals) != 1 { + t.Fatalf("reconciled removals = %+v, want one", removals) + } + if err := restarted.AcknowledgeRemoval(removals[0]); err != nil { + t.Fatalf("acknowledge reconciled removal: %v", err) + } + for _, path := range []string{recordPath, firstPath, secondPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("acknowledgement path %q remains: %v", path, err) + } + } +} + func TestRemoveRecoversQuarantinedAcknowledgementRecord(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index af1dbd2..772efc4 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -216,13 +216,6 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { Started: true, Quarantined: true, } - bucketAbsent, err := m.removalBucketAbsent(removal.Workspace) - if err != nil { - return err - } - if bucketAbsent { - return nil - } inspectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() facts, err := m.removalFacts(inspectCtx, record) @@ -240,30 +233,6 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { return nil } -func (m *Manager) removalBucketAbsent( - target Workspace, -) (_ bool, result error) { - root, err := m.openWorktreeRoot() - if errors.Is(err, os.ErrNotExist) { - return true, nil - } - if err != nil { - return false, err - } - defer func() { - result = errors.Join(result, root.Close()) - }() - _, err = root.Lstat(repositoryHash(target.Repository)) - switch { - case errors.Is(err, os.ErrNotExist): - return true, nil - case err != nil: - return false, fmt.Errorf("workspace: inspect repository bucket: %w", err) - default: - return false, nil - } -} - type removalFacts struct { pathExists bool quarantineName string @@ -301,7 +270,17 @@ func (m *Manager) ensureRemovalRepositoryEvidence( "workspace: pending removal has no repository identity evidence", ) } - root, err := openRealPathRoot(record.Repository) + sourcePath := record.Repository + var legacyEvidence *repositoryEvidence + if record.Version == repositoryWorkspaceRecordVersion && + record.RepositoryEvidence != nil { + legacyEvidence = cloneRepositoryEvidence(record.RepositoryEvidence) + if err := validateLegacyRepositoryEvidence(*legacyEvidence); err != nil { + return workspaceRecord{}, err + } + sourcePath = legacyEvidence.SourcePath + } + root, err := openRealPathRoot(sourcePath) if err != nil { return workspaceRecord{}, fmt.Errorf( "workspace: open removal repository: %w", @@ -316,7 +295,7 @@ func (m *Manager) ensureRemovalRepositoryEvidence( }() repository, err := m.repositoryRootAtRoot( ctx, - record.Repository, + sourcePath, root, ) if err != nil { @@ -330,7 +309,7 @@ func (m *Manager) ensureRemovalRepositoryEvidence( lease, err := newPreparationLease( ctx, m, - record.Repository, + sourcePath, root, ) if err != nil { @@ -343,6 +322,18 @@ func (m *Manager) ensureRemovalRepositoryEvidence( defer func() { result = errors.Join(result, lease.Close()) }() + if legacyEvidence != nil && + (legacyEvidence.SourcePath != lease.evidence.SourcePath || + legacyEvidence.SourceDirectoryIdentity != + lease.evidence.SourceDirectoryIdentity || + legacyEvidence.CommonGitDirectory != + lease.evidence.CommonGitDirectory || + legacyEvidence.CommonGitDirectoryIdentity != + lease.evidence.CommonGitDirectoryIdentity) { + return workspaceRecord{}, errors.New( + "workspace: legacy removal repository identity evidence changed", + ) + } upgradeWorkspaceRecord(&record) record.RepositoryEvidence = cloneRepositoryEvidence(&lease.evidence) diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 7bc0825..d16aefa 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -429,7 +429,7 @@ func (r repositoryCapability) worktreeRegistration( } path = filepath.Clean(path) for _, registered := range worktrees { - if registered.path == path { + if sameRegisteredWorktreePath(registered.path, path) { return registered, true, nil } } @@ -518,7 +518,7 @@ func (repositoryCapability) verifyPreparedWorktreeRegistration( registered registeredWorktree, expectedHeadOID string, ) error { - if registered.path != target.Path || + if !sameRegisteredWorktreePath(registered.path, target.Path) || registered.detached || registered.branch != "refs/heads/"+target.Branch || registered.head == "" || @@ -535,6 +535,17 @@ func (repositoryCapability) verifyPreparedWorktreeRegistration( return nil } +func sameRegisteredWorktreePath(left string, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if left == right { + return true + } + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) +} + func (r repositoryCapability) boundGitDirectory( path string, agentID string, diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index bef6a14..e416345 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -302,49 +302,8 @@ func ensureRemovalMarker( _, err := root.Lstat(name) switch { case errors.Is(err, os.ErrNotExist): - file, err := root.OpenFile( - name, - os.O_WRONLY|os.O_CREATE|os.O_EXCL, - 0o600, - ) - if err != nil { - return fmt.Errorf( - "workspace: create removal marker: %w", - err, - ) - } - if _, err := io.WriteString( - file, - record.Removal.DirectoryToken+"\n", - ); err != nil { - _ = file.Close() - return fmt.Errorf( - "workspace: write removal marker: %w", - err, - ) - } - syncErr := file.Sync() - closeErr := file.Close() - if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: sync removal marker: %w", - err, - ) - } - directory, err := root.Open(".") - if err != nil { - return fmt.Errorf( - "workspace: open removal marker directory: %w", - err, - ) - } - syncErr = syncRecordDirectory(directory) - closeErr = directory.Close() - if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: sync removal marker directory: %w", - err, - ) + if err := installRemovalMarker(root, name, record.Removal.DirectoryToken); err != nil { + return err } case err != nil: return fmt.Errorf("workspace: inspect removal marker: %w", err) @@ -352,6 +311,54 @@ func ensureRemovalMarker( return verifyRemovalMarker(root, record) } +func installRemovalMarker( + root *os.Root, + name string, + token string, +) (result error) { + file, err := root.OpenFile( + name, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return fmt.Errorf("workspace: create removal marker: %w", err) + } + cleanupOnFailure := true + defer func() { + if cleanupOnFailure { + result = errors.Join( + result, + removeRecordPathIfSame(root, name, file), + syncRecordBucket(root, name), + ) + } + result = errors.Join(result, file.Close()) + }() + payload := token + "\n" + written, err := io.WriteString(file, payload) + if err != nil { + return fmt.Errorf("workspace: write removal marker: %w", err) + } + if written != len(payload) { + return fmt.Errorf("workspace: write removal marker: %w", io.ErrShortWrite) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("workspace: sync removal marker: %w", err) + } + directory, err := root.Open(".") + if err != nil { + return fmt.Errorf("workspace: open removal marker directory: %w", err) + } + syncErr := syncRecordDirectory(directory) + closeDirectoryErr := directory.Close() + if err := errors.Join(syncErr, closeDirectoryErr); err != nil { + return fmt.Errorf("workspace: sync removal marker directory: %w", err) + } + cleanupOnFailure = false + return nil +} + func verifyRemovalMarker( root *os.Root, record workspaceRecord, @@ -553,12 +560,8 @@ func quarantineManagedPath( if !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(openedInfo, current) { - restoreErr := restoreManagedQuarantine(bucket, record, name) - return name, true, errors.Join( - errors.New( - "workspace: managed path changed while entering quarantine", - ), - restoreErr, + return name, true, errors.New( + "workspace: managed path changed while entering quarantine", ) } return name, true, nil @@ -706,61 +709,82 @@ func openOrCreateRealPathRoot(path string) (*os.Root, error) { if !filepath.IsAbs(path) { return nil, fmt.Errorf("%q is not an absolute path", path) } - current := path - var missing []string - for { - info, err := os.Lstat(current) - switch { - case err == nil: - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("%q is not a real directory", current) - } - root, err := openRealPathRoot(current) - if err != nil { - return nil, err - } - for index := len(missing) - 1; index >= 0; index-- { - name := missing[index] - if err := root.Mkdir(name, 0o700); err != nil && - !errors.Is(err, os.ErrExist) { - _ = root.Close() - return nil, fmt.Errorf( - "create directory %q: %w", - filepath.Join(current, name), - err, - ) - } - child, err := openRealRootFromRoot(root, name) - if err != nil { - _ = root.Close() - return nil, fmt.Errorf( - "open created directory %q: %w", - filepath.Join(current, name), - err, - ) - } - if err := root.Close(); err != nil { - _ = child.Close() - return nil, fmt.Errorf( - "close parent directory %q: %w", - current, - err, - ) - } - current = filepath.Join(current, name) - root = child + volumeRoot := filepath.VolumeName(path) + string(filepath.Separator) + relative, err := filepath.Rel(volumeRoot, path) + if err != nil { + return nil, fmt.Errorf("resolve path below volume root: %w", err) + } + root, err := openRealPathRoot(volumeRoot) + if err != nil { + return nil, fmt.Errorf("open volume root %q: %w", volumeRoot, err) + } + if relative == "." { + return root, nil + } + current := volumeRoot + for _, name := range strings.Split(relative, string(filepath.Separator)) { + if name == "" || name == "." || name == ".." { + _ = root.Close() + return nil, fmt.Errorf("invalid path component %q in %q", name, path) + } + info, inspectErr := root.Lstat(name) + if errors.Is(inspectErr, os.ErrNotExist) { + if err := root.Mkdir(name, 0o700); err != nil && + !errors.Is(err, os.ErrExist) { + _ = root.Close() + return nil, fmt.Errorf( + "create directory %q: %w", + filepath.Join(current, name), + err, + ) } - return root, nil - case !errors.Is(err, os.ErrNotExist): - return nil, fmt.Errorf("inspect directory %q: %w", current, err) + info, inspectErr = root.Lstat(name) } - parent := filepath.Dir(current) - if parent == current { - return nil, fmt.Errorf("find existing ancestor for %q: %w", path, err) + if inspectErr != nil { + _ = root.Close() + return nil, fmt.Errorf( + "inspect directory %q: %w", + filepath.Join(current, name), + inspectErr, + ) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + _ = root.Close() + return nil, fmt.Errorf( + "%q is not a real directory", + filepath.Join(current, name), + ) + } + child, err := openRealRootFromRoot(root, name) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf( + "open directory %q: %w", + filepath.Join(current, name), + err, + ) + } + if err := verifyRootEntryUnchanged(root, name, child); err != nil { + _ = child.Close() + _ = root.Close() + return nil, err } - missing = append(missing, filepath.Base(current)) - current = parent + if err := root.Close(); err != nil { + _ = child.Close() + return nil, fmt.Errorf( + "close parent directory %q: %w", + current, + err, + ) + } + current = filepath.Join(current, name) + root = child + } + if err := verifyRealPathRoot(path, root); err != nil { + _ = root.Close() + return nil, err } + return root, nil } func verifyRealPathRoot(path string, opened *os.Root) error { diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 6ba1caa..05470d6 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -748,6 +748,183 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestRemoveLegacyRecordRejectsReplacementSourceBeforeUpgrade( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + replacement := filepath.Join(parent, "replacement") + openedSource := source + "-opened" + initSourceSwapRepository(t, source) + initSourceSwapRepository(t, replacement) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Version = repositoryWorkspaceRecordVersion + record.RepositoryEvidence.GitDirectory = "" + record.RepositoryEvidence.GitDirectoryIdentity = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 4 workspace record: %v", err) + } + if err := os.Rename(source, openedSource); err != nil { + t.Fatalf("move source repository: %v", err) + } + if err := os.Rename(replacement, source); err != nil { + t.Fatalf("install replacement repository: %v", err) + } + + if _, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ); err == nil || !strings.Contains( + err.Error(), + "legacy removal repository identity evidence changed", + ) { + t.Fatalf("remove replacement source error = %v", err) + } + current, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || + current.Version != repositoryWorkspaceRecordVersion || + current.Removal != nil { + t.Fatalf( + "failed upgrade record = %+v, exists=%v err=%v", + current, + exists, + err, + ) + } + if listing := runGit( + t, + openedSource, + "worktree", + "list", + "--porcelain", + ); !strings.Contains(listing, prepared.Path) { + t.Fatal("original repository registration was unexpectedly changed") + } +} + +func TestListRunsStatusFromOpenedWorktreeRoot(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) + } + if err := os.WriteFile( + filepath.Join(prepared.Path, "tracked.txt"), + []byte("dirty\n"), + 0o600, + ); err != nil { + t.Fatalf("dirty worktree: %v", err) + } + replacement := filepath.Join(parent, "replacement-worktree") + if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatalf("create replacement worktree: %v", err) + } + gitPointer, err := os.ReadFile(filepath.Join(prepared.Path, ".git")) + if err != nil { + t.Fatalf("read worktree Git pointer: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, ".git"), + gitPointer, + 0o600, + ); err != nil { + t.Fatalf("write replacement Git pointer: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, "tracked.txt"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write clean replacement: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + opened := prepared.Path + "-opened" + swapped := filepath.Join(parent, "status-swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +matched= +for argument in "$@"; do + [ "$argument" = "status" ] && matched=1 +done +if [ -n "$matched" ] && [ ! -e "$DROVE_TEST_SWAPPED" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_OPENED" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_TARGET" || exit 92 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_REPLACEMENT" || exit 93 + mv "$DROVE_TEST_OPENED" "$DROVE_TEST_TARGET" || exit 94 + : > "$DROVE_TEST_SWAPPED" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", prepared.Path) + t.Setenv("DROVE_TEST_OPENED", opened) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + listed, err := manager.List(context.Background()) + if err != nil { + t.Fatalf("list workspaces: %v", err) + } + if len(listed) != 1 || !listed[0].Dirty { + t.Fatalf("listed workspaces = %+v, want dirty original", listed) + } + if _, err := os.Stat(swapped); err != nil { + t.Fatalf("status swap hook was not invoked: %v", err) + } + manager.git = realGit + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil { + t.Fatalf("remove workspace: %v", err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } +} + func TestAcknowledgeRejectsReplacementCommonGitDirectory( t *testing.T, ) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 860c740..1f6a701 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -317,6 +317,7 @@ func (m *Manager) prepare( defer cancel() return Workspace{}, errors.Join( err, + m.cleanupPreparedWorktreeTarget(result, preparedTarget), m.discardWithRepository( cleanupCtx, result, @@ -725,7 +726,12 @@ func (m *Manager) listRepositoryBucket( if hasRecord { recorded = &record } - current, inspectErr := m.inspect(ctx, path, recorded) + current, inspectErr := m.inspectAtRoot( + ctx, + path, + workspaceRoot, + recorded, + ) verifyErr := verifyRootEntryUnchanged( bucket, agentID, @@ -897,6 +903,16 @@ func (m *Manager) discardWithRepository( ), ) } else { + if !registered { + registered, removeErr = + repository.worktreeRegistered( + ctx, + activePath, + ) + if removeErr != nil { + result = errors.Join(result, removeErr) + } + } pathRemoved = true } } @@ -970,7 +986,12 @@ func (m *Manager) removeDiscardedPath( result = errors.Join(result, opened.Close()) } }() - current, inspectErr := m.inspect(ctx, target.Path, &record) + current, inspectErr := m.inspectAtRoot( + ctx, + target.Path, + opened, + &record, + ) verifyErr := verifyRootEntryUnchanged( bucket, target.AgentID, @@ -1020,9 +1041,27 @@ func (m *Manager) inspect( ctx context.Context, path string, recorded *workspaceRecord, +) (_ Workspace, result error) { + root, err := openRealPathRoot(path) + if err != nil { + return Workspace{}, err + } + defer func() { + result = errors.Join(result, root.Close()) + }() + current, err := m.inspectAtRoot(ctx, path, root, recorded) + verifyErr := verifyRealPathRoot(path, root) + return current, errors.Join(err, verifyErr) +} + +func (m *Manager) inspectAtRoot( + ctx context.Context, + path string, + root *os.Root, + recorded *workspaceRecord, ) (Workspace, error) { agentID := filepath.Base(path) - directoryIdentity, err := worktreeDirectoryIdentity(path) + directoryIdentity, err := openedDirectoryIdentity(root) if err != nil { return Workspace{}, err } @@ -1034,7 +1073,7 @@ func (m *Manager) inspect( path, ) } - repository, err := m.repositoryRoot(ctx, path) + repository, err := m.repositoryRootAtRoot(ctx, path, root) if err != nil { return Workspace{}, fmt.Errorf("workspace: inspect repository for %q: %w", path, err) } @@ -1044,7 +1083,7 @@ func (m *Manager) inspect( path, ) } - gitDirectory, err := m.worktreeGitDirectory(ctx, path) + gitDirectory, err := m.worktreeGitDirectoryAtRoot(ctx, path, root) if err != nil { return Workspace{}, err } @@ -1057,7 +1096,15 @@ func (m *Manager) inspect( ) } - branchOutput, err := m.run(ctx, "-C", path, "symbolic-ref", "--quiet", "--short", "HEAD") + branchOutput, err := m.runRootedGit( + ctx, + path, + root, + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + ) detached := false branch := strings.TrimSpace(string(branchOutput)) if isExitCode(err, 1) { @@ -1065,7 +1112,14 @@ func (m *Manager) inspect( if recorded != nil { branch = recorded.Branch } else { - head, headErr := m.run(ctx, "-C", path, "rev-parse", "--short", "HEAD") + head, headErr := m.runRootedGit( + ctx, + path, + root, + "rev-parse", + "--short", + "HEAD", + ) if headErr != nil { return Workspace{}, fmt.Errorf( "workspace: inspect detached HEAD for %q: %w", @@ -1078,10 +1132,10 @@ func (m *Manager) inspect( } else if err != nil { return Workspace{}, fmt.Errorf("workspace: inspect branch for %q: %w", path, err) } - status, err := m.run( + status, err := m.runRootedGit( ctx, - "-C", path, + root, "status", "--porcelain=v1", "--untracked-files=all", @@ -1095,7 +1149,7 @@ func (m *Manager) inspect( if recorded != nil { includedPaths = append([]string(nil), recorded.IncludedPaths...) for _, relative := range includedPaths { - _, pathErr := os.Lstat(filepath.Join(path, relative)) + _, pathErr := root.Lstat(relative) switch { case pathErr == nil: includedDirty = true @@ -1274,8 +1328,8 @@ func (m *Manager) validateBranch(ctx context.Context, branch string) error { strings.ContainsRune(branch, '\x00') { return fmt.Errorf("%w: %q", ErrInvalidBranch, branch) } - if _, err := m.run(ctx, "check-ref-format", "refs/heads/"+branch); err != nil { - if isExitCode(err, 1) { + if _, err := m.run(ctx, "check-ref-format", "--branch", branch); err != nil { + if isExitCode(err, 1) || isExitCode(err, 128) { return errors.Join( fmt.Errorf("%w: %q", ErrInvalidBranch, branch), err, @@ -1459,7 +1513,7 @@ func (m *Manager) worktreeRegistration( } path = filepath.Clean(path) for _, registered := range worktrees { - if registered.path == path { + if sameRegisteredWorktreePath(registered.path, path) { return registered, true, nil } } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index b51576a..8cf2591 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -2087,6 +2087,17 @@ func TestValidateBranchPreservesRuntimeGitFailure(t *testing.T) { } } +func TestValidateBranchRejectsHEAD(t *testing.T) { + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + err = manager.validateBranch(context.Background(), "HEAD") + if !errors.Is(err, ErrInvalidBranch) { + t.Fatalf("validate HEAD error = %v, want ErrInvalidBranch", err) + } +} + func newTestRepository(t *testing.T) string { t.Helper() if _, err := exec.LookPath("git"); err != nil { From 8da2d6de74796f02b42dde42b72929add0a4a0b8 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 21:57:27 +0800 Subject: [PATCH 004/104] fix: close follow-up recovery review gaps --- go.mod | 1 + go.sum | 10 +- internal/session/projection.go | 5 +- internal/session/projection_test.go | 72 ++++++++++ internal/workspace/manager_lock_key_darwin.go | 4 +- .../workspace/manager_lock_key_darwin_test.go | 23 ++++ internal/workspace/record.go | 90 ++++++++++++- internal/workspace/record_rename_windows.go | 7 +- .../workspace/record_rename_windows_test.go | 11 ++ internal/workspace/recovery_security_test.go | 126 ++++++++++++++++++ internal/workspace/workspace.go | 2 +- 11 files changed, 335 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 3168d66..47cc2a4 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/muesli/cancelreader v0.2.2 github.com/spf13/cobra v1.8.1 golang.org/x/sys v0.41.0 + golang.org/x/text v0.29.0 modernc.org/sqlite v1.33.1 ) diff --git a/go.sum b/go.sum index 56db5b0..7476b60 100644 --- a/go.sum +++ b/go.sum @@ -58,15 +58,17 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= diff --git a/internal/session/projection.go b/internal/session/projection.go index 1348b89..afb4dcb 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -356,7 +356,10 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { } draft.state = to draft.restartStopped = - to == agent.StateStopped && row.Reason == restartStopReason + to == agent.StateStopped && + from != agent.StateDone && + from != agent.StateStopped && + row.Reason == restartStopReason if known { draft.lastTransition = evidence } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 01ac2e4..f4a849e 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -400,6 +400,78 @@ func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( } } +func TestRecoveryProjectorDoesNotResumeCompletedSessionAfterRestartStop( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 12, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"done-ref",` + + `"confidence":1,"received_at":"2026-10-05T12:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "done", + }, + } + first := newRecoveryProjector() + for _, row := range rows { + if err := first.Apply(row); err != nil { + t.Fatalf("apply initial seq %d: %v", row.Seq, err) + } + } + firstPlan, err := first.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish initial projection: %v", err) + } + if firstPlan.ResumeOnStart["agent-1"] { + t.Fatal("completed session is eligible for automatic resume") + } + rows = append(rows, firstPlan.Reconciliation...) + + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("persisted done restart stop enabled automatic resume") + } + if len(restartedPlan.Reconciliation) != 0 { + t.Fatalf( + "restarted reconciliation = %+v, want no duplicate stop", + restartedPlan.Reconciliation, + ) + } +} + func TestRecoveryProjectorReadsVersionTwoMetadataAndEvidence(t *testing.T) { base := time.Date(2026, time.October, 3, 5, 0, 0, 0, time.UTC) projector := newRecoveryProjector() diff --git a/internal/workspace/manager_lock_key_darwin.go b/internal/workspace/manager_lock_key_darwin.go index 5b92cb7..330d1ad 100644 --- a/internal/workspace/manager_lock_key_darwin.go +++ b/internal/workspace/manager_lock_key_darwin.go @@ -5,8 +5,10 @@ package workspace import ( "path/filepath" "strings" + + "golang.org/x/text/unicode/norm" ) func workspaceManagerLockKey(path string) string { - return strings.ToUpper(filepath.Clean(path)) + return norm.NFD.String(strings.ToUpper(filepath.Clean(path))) } diff --git a/internal/workspace/manager_lock_key_darwin_test.go b/internal/workspace/manager_lock_key_darwin_test.go index 1bee37e..c1ad4d2 100644 --- a/internal/workspace/manager_lock_key_darwin_test.go +++ b/internal/workspace/manager_lock_key_darwin_test.go @@ -20,6 +20,29 @@ func TestWorkspaceManagerLockKeyFoldsDarwinPathCase(t *testing.T) { } } +func TestNewFoldsDarwinUnicodePathAliases(t *testing.T) { + parent := t.TempDir() + composed := filepath.Join(parent, "\u00e9") + decomposed := filepath.Join(parent, "e\u0301") + if err := os.Mkdir(composed, 0o700); err != nil { + t.Fatalf("create composed data directory: %v", err) + } + if _, err := os.Stat(decomposed); err != nil { + t.Skip("test volume does not canonicalize Unicode path aliases") + } + first, err := New(composed) + if err != nil { + t.Fatalf("new composed manager: %v", err) + } + second, err := New(decomposed) + if err != nil { + t.Fatalf("new decomposed manager: %v", err) + } + if first.mu != second.mu { + t.Fatal("Unicode path aliases received different manager locks") + } +} + func TestRegisteredWorktreePathAcceptsDarwinCaseAlias(t *testing.T) { root := t.TempDir() actual := filepath.Join(root, "Worktree") diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 4399c79..1100791 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -357,6 +357,39 @@ func removeRecordPathIfSame( return root.Remove(name) } +func isolateRecordPathIfSame( + root *os.Root, + directory *os.File, + name string, + isolatedName string, + expected *os.File, + afterValidation func(), +) (bool, error) { + current, err := root.Lstat(name) + if err != nil { + return false, err + } + opened, err := expected.Stat() + if err != nil { + return false, err + } + if !os.SameFile(opened, current) { + return false, fmt.Errorf( + "workspace: record path %q changed identity", + name, + ) + } + if afterValidation != nil { + afterValidation() + } + return moveRecordFile( + directory, + expected, + name, + isolatedName, + ) +} + func (m *Manager) readWorkspaceRecord( worktreePath string, ) (_ workspaceRecord, _ bool, result error) { @@ -1086,6 +1119,18 @@ func findRecordAcknowledgementQuarantine( func coalesceRecordAcknowledgements( bucket *os.Root, candidates []recordAcknowledgement, +) (recordAcknowledgement, error) { + return coalesceRecordAcknowledgementsAfterValidation( + bucket, + candidates, + nil, + ) +} + +func coalesceRecordAcknowledgementsAfterValidation( + bucket *os.Root, + candidates []recordAcknowledgement, + afterValidation func(), ) (_ recordAcknowledgement, result error) { if len(candidates) == 0 { return recordAcknowledgement{}, errors.New( @@ -1151,18 +1196,57 @@ func coalesceRecordAcknowledgements( return recordAcknowledgement{}, err } } + agentID, parsedOperationID, ok := recordAcknowledgementIdentity( + candidates[0].name, + ) + if !ok || parsedOperationID != operationID { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement candidate name is invalid", + ) + } for _, duplicate := range candidates[1:] { - if err := removeRecordPathIfSame( + directory, err := bucket.Open(".") + if err != nil { + return recordAcknowledgement{}, err + } + isolatedName := recordAcknowledgementPrefix( + agentID, + operationID, + ) + uuid.NewString() + isolated, isolateErr := isolateRecordPathIfSame( bucket, + directory, duplicate.name, + isolatedName, expected, - ); err != nil { + afterValidation, + ) + afterValidation = nil + closeErr := directory.Close() + if err := errors.Join(isolateErr, closeErr); err != nil { return recordAcknowledgement{}, fmt.Errorf( - "workspace: remove acknowledgement alias %q: %w", + "workspace: isolate acknowledgement alias %q: %w", duplicate.name, err, ) } + if !isolated { + return recordAcknowledgement{}, fmt.Errorf( + "workspace: acknowledgement alias %q was not isolated", + duplicate.name, + ) + } + if err := removeRecordPathIfSame( + bucket, + isolatedName, + expected, + ); err != nil { + return recordAcknowledgement{}, fmt.Errorf( + "workspace: remove isolated acknowledgement alias %q: %w", + isolatedName, + err, + ) + } } if len(candidates) > 1 { if err := syncRecordBucket(bucket, candidates[0].name); err != nil { diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index a486d83..936f200 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -154,11 +154,6 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { return file, nil } -func syncRecordDirectory(directory *os.File) error { - if err := windows.FlushFileBuffers( - windows.Handle(directory.Fd()), - ); err != nil { - return fmt.Errorf("flush directory metadata: %w", err) - } +func syncRecordDirectory(_ *os.File) error { return nil } diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 7e05b34..abe2300 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -8,6 +8,17 @@ import ( "testing" ) +func TestSyncRecordDirectoryAcceptsReadOnlyWindowsHandle(t *testing.T) { + directory, err := os.Open(t.TempDir()) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + if err := syncRecordDirectory(directory); err != nil { + t.Fatalf("sync read-only directory handle: %v", err) + } +} + func TestInstallAndReplaceWorkspaceRecordOnWindows(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 7a4b2c8..e1a6167 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -488,6 +488,82 @@ func TestDiscardRejectsReplacementWorkspacePath(t *testing.T) { } } +func TestDiscardPreservesRecoveryEvidenceWhenStagingProbeFails(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + originalPath := prepared.Path + "-original" + if err := os.Rename(prepared.Path, originalPath); err != nil { + t.Fatalf("move prepared workspace: %v", err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + prepared.branchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Symlink(originalPath, stagingPath); err != nil { + t.Skipf("create replacement staging symlink: %v", err) + } + + if err := manager.Discard(context.Background(), prepared); err == nil { + t.Fatal("discard accepted a replacement staging path") + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "recovery record after failed staging probe: exists=%v err=%v", + exists, + err, + ) + } + if record.BranchOperationID != prepared.branchOperationID { + t.Fatalf("recovery record changed after failed staging probe: %+v", record) + } + if err := exec.Command( + "git", + "-C", + repository, + "show-ref", + "--verify", + "--quiet", + "refs/heads/"+prepared.Branch, + ).Run(); err != nil { + t.Fatalf("owned branch was removed after failed staging probe: %v", err) + } + marker, err := manager.branchOwnershipMarkerExists( + context.Background(), + repository, + prepared.branchOperationID, + ) + if err != nil || !marker { + t.Fatalf("branch ownership marker = %v, err=%v", marker, err) + } + + if err := os.Remove(stagingPath); err != nil { + t.Fatalf("remove replacement staging path: %v", err) + } + if err := os.Rename(originalPath, prepared.Path); err != nil { + t.Fatalf("restore prepared workspace: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored workspace: %v", err) + } +} + func TestRemoveRejectsReplacementGitWorktree(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -1673,6 +1749,56 @@ func TestReconcileRemovalCoalescesAcknowledgementHardLinks(t *testing.T) { } } +func TestCoalesceAcknowledgementAliasesPreservesReplacementAfterValidation( + t *testing.T, +) { + rootPath := t.TempDir() + operationID := "73737373-7373-4737-8737-737373737373" + prefix := recordAcknowledgementPrefix(testAgentID, operationID) + firstName := prefix + "74747474-7474-4747-8747-747474747474" + secondName := prefix + "75757575-7575-4757-8757-757575757575" + firstPath := filepath.Join(rootPath, firstName) + secondPath := filepath.Join(rootPath, secondName) + if err := os.WriteFile(firstPath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write first acknowledgement alias: %v", err) + } + if err := os.Link(firstPath, secondPath); err != nil { + t.Skipf("create acknowledgement hard link: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open acknowledgement root: %v", err) + } + defer root.Close() + originalSecondPath := secondPath + "-original" + + _, err = coalesceRecordAcknowledgementsAfterValidation( + root, + []recordAcknowledgement{ + {name: firstName, operationID: operationID}, + {name: secondName, operationID: operationID}, + }, + func() { + if err := os.Rename(secondPath, originalSecondPath); err != nil { + t.Fatalf("move validated acknowledgement alias: %v", err) + } + if err := os.WriteFile( + secondPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement acknowledgement path: %v", err) + } + }, + ) + if err == nil { + t.Fatal("coalescing accepted a replacement acknowledgement alias") + } + assertFileContents(t, firstPath, "record\n") + assertFileContents(t, originalSecondPath, "record\n") + assertFileContents(t, secondPath, "replacement\n") +} + func TestRemoveRecoversQuarantinedAcknowledgementRecord(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 1f6a701..aeea034 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -844,7 +844,7 @@ func (m *Manager) discardWithRepository( stagingPath, exists, stagingErr := m.preparedWorktreeStagingPath(target, record) if stagingErr != nil { - result = errors.Join(result, stagingErr) + return errors.Join(result, stagingErr) } else if exists { activePath = stagingPath pathExists = true From 8bdcba856c919d9d28994b9062ac2ba16448a367 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:15:50 +0800 Subject: [PATCH 005/104] fix: preserve workspace recovery evidence --- internal/workspace/manager_lock_key_darwin.go | 4 +- .../workspace/manager_lock_key_darwin_test.go | 23 +++ internal/workspace/record.go | 41 ++++ internal/workspace/recovery_security_test.go | 44 +++++ internal/workspace/removal.go | 7 + internal/workspace/source_swap_test.go | 40 ++++ internal/workspace/workspace.go | 178 ++++-------------- 7 files changed, 190 insertions(+), 147 deletions(-) diff --git a/internal/workspace/manager_lock_key_darwin.go b/internal/workspace/manager_lock_key_darwin.go index 330d1ad..5838ca0 100644 --- a/internal/workspace/manager_lock_key_darwin.go +++ b/internal/workspace/manager_lock_key_darwin.go @@ -4,11 +4,11 @@ package workspace import ( "path/filepath" - "strings" + "golang.org/x/text/cases" "golang.org/x/text/unicode/norm" ) func workspaceManagerLockKey(path string) string { - return norm.NFD.String(strings.ToUpper(filepath.Clean(path))) + return norm.NFD.String(cases.Fold().String(filepath.Clean(path))) } diff --git a/internal/workspace/manager_lock_key_darwin_test.go b/internal/workspace/manager_lock_key_darwin_test.go index c1ad4d2..04c59f2 100644 --- a/internal/workspace/manager_lock_key_darwin_test.go +++ b/internal/workspace/manager_lock_key_darwin_test.go @@ -43,6 +43,29 @@ func TestNewFoldsDarwinUnicodePathAliases(t *testing.T) { } } +func TestNewFoldsDarwinUnicodeCaseAliases(t *testing.T) { + parent := t.TempDir() + lower := filepath.Join(parent, "\u00df") + upper := filepath.Join(parent, "\u1e9e") + if err := os.Mkdir(lower, 0o700); err != nil { + t.Fatalf("create lowercase data directory: %v", err) + } + if _, err := os.Stat(upper); err != nil { + t.Skip("test volume does not fold Unicode path case") + } + first, err := New(lower) + if err != nil { + t.Fatalf("new lowercase manager: %v", err) + } + second, err := New(upper) + if err != nil { + t.Fatalf("new uppercase manager: %v", err) + } + if first.mu != second.mu { + t.Fatal("Unicode case aliases received different manager locks") + } +} + func TestRegisteredWorktreePathAcceptsDarwinCaseAlias(t *testing.T) { root := t.TempDir() actual := filepath.Join(root, "Worktree") diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 1100791..096fa79 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -937,6 +937,47 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( ) } +func (m *Manager) removalAcknowledgementPending( + removal Removal, +) (_ bool, result error) { + recordPath := workspaceRecordPath(removal.Workspace.Path) + bucket, agentID, err := m.openRecordBucket(removal.Workspace.Path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf( + "workspace: open record directory %q: %w", + recordPath, + err, + ) + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + _, exists, err := findRecordAcknowledgementQuarantine( + bucket, + agentID, + removal.operationID, + ) + if err != nil || exists { + return exists, err + } + _, err = bucket.Lstat(agentID + workspaceRecordSuffix) + switch { + case errors.Is(err, os.ErrNotExist): + return false, nil + case err != nil: + return false, fmt.Errorf( + "workspace: inspect record %q: %w", + recordPath, + err, + ) + default: + return true, nil + } +} + func recordAcknowledgementPrefix( agentID string, operationID string, diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index e1a6167..3eab9c2 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -407,6 +407,50 @@ func TestAcknowledgeRemovalPreservesRecordWhenRepositoryDisappears( } } +func TestAcknowledgeRemovalRetryDoesNotRequireDeletedRepository( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } + movedRepository := repository + "-moved" + if err := os.Rename(repository, movedRepository); err != nil { + t.Fatalf("move acknowledged source repository: %v", err) + } + defer func() { + _ = os.Rename(movedRepository, repository) + }() + + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("retry acknowledgement without source repository: %v", err) + } +} + func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 772efc4..0d8e8c9 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -210,6 +210,13 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { "workspace: removal acknowledgement token is invalid", ) } + pending, err := m.removalAcknowledgementPending(removal) + if err != nil { + return err + } + if !pending { + return nil + } record := newWorkspaceRecord(removal.Workspace, nil) record.Removal = &workspaceRemovalRecord{ OperationID: removal.operationID, diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 05470d6..0be9649 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -368,6 +368,46 @@ exit "$status" filepath.Join(string(rawPath), "must-remain"), "replacement\n", ) + manager.git = realGit + stagingPath := string(rawPath) + registered, err := manager.worktreeRegistered( + context.Background(), + source, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("original staging registration = %v, err=%v", registered, err) + } + if err := os.Rename(stagingPath, replacementPath); err != nil { + t.Fatalf("restore replacement target: %v", err) + } + if err := os.Rename(stagingPath+"-opened", stagingPath); err != nil { + t.Fatalf("restore original staging target: %v", err) + } + _, repository, err := manager.repositoryPaths( + context.Background(), + source, + ) + if err != nil { + t.Fatalf("resolve restored source repository: %v", err) + } + registered, err = manager.worktreeRegistered( + context.Background(), + repository, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("restored staging registration = %v, err=%v", registered, err) + } + if _, err := os.Lstat(workspaceRecordPath(filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ))); err != nil { + t.Fatalf("preparation record was removed: %v", err) + } + runGit(t, stagingPath, "status", "--porcelain") + assertFileContents(t, sentinel, "replacement\n") } func TestPrepareRollsBackExistingBranchMovedBeforeWorktreeAdd(t *testing.T) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index aeea034..ea2c088 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -337,13 +337,16 @@ func (m *Manager) prepare( result, preparedTarget, ) - pruneErr := sourceRepository.pruneWorktrees(cleanupCtx) - discardErr := m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ) + var pruneErr, discardErr error + if cleanupErr == nil { + pruneErr = sourceRepository.pruneWorktrees(cleanupCtx) + discardErr = m.discardWithRepository( + cleanupCtx, + result, + sourceRepository, + true, + ) + } resultErr = errors.Join( resultErr, cleanupErr, @@ -359,23 +362,10 @@ func (m *Manager) prepare( branch, branchOperationID, ); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: create branch %q: %w", - branch, - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: create branch %q: %w", + branch, + err, ) } result.createdBranch = true @@ -384,42 +374,16 @@ func (m *Manager) prepare( err = errors.New("workspace: branch ownership record is missing") } if err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: read branch ownership record: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: read branch ownership record: %w", + err, ) } record.CreatedBranch = true if err := m.replaceWorkspaceRecord(record); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: confirm branch ownership: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: confirm branch ownership: %w", + err, ) } } @@ -436,20 +400,7 @@ func (m *Manager) prepare( "", arguments..., ); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: create worktree: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, fmt.Errorf("workspace: create worktree: %w", err) } if err := m.initializePreparedWorktree( @@ -457,53 +408,20 @@ func (m *Manager) prepare( &result, preparedTarget, ); err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - err, - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, err } if err := m.promotePreparedWorktreeTarget( ctx, result, preparedTarget, ); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - err, - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, err } if err := verifyRealPathRoot(sourcePath, sourceRoot); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: source repository changed: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: source repository changed: %w", + err, ) } copiedPaths, err := m.copyIncludedSelection( @@ -515,17 +433,7 @@ func (m *Manager) prepare( includeSelection, ) if err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - err, - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, err } if len(copiedPaths) != len(includedPaths) { record, exists, err := m.readWorkspaceRecord(result.Path) @@ -537,36 +445,16 @@ func (m *Manager) prepare( err = m.replaceWorkspaceRecord(record) } if err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: persist copied include paths: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: persist copied include paths: %w", + err, ) } } if err := verifyRealPathRoot(sourcePath, sourceRoot); err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: source repository changed: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: source repository changed: %w", + err, ) } result.protectionKnown = true From b72eb58a62321cb50c28c41d16a665c9ffe9cf96 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:20:53 +0800 Subject: [PATCH 006/104] fix: classify default command argument errors --- cmd/drove/main.go | 23 ++++++++++++++++++++++- cmd/drove/main_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index ccf7953..fd1251f 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -71,6 +71,9 @@ func main() { func executeRoot(root *cobra.Command, args []string) error { root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() + if err := validateDefaultHelpArgs(root, args); err != nil { + return err + } if !isCompletionRequest(args) { command, remaining, err := root.Find(args) if err != nil { @@ -91,7 +94,25 @@ func executeRoot(root *cobra.Command, args []string) error { } } root.SetArgs(args) - return root.Execute() + err := root.Execute() + if err != nil && len(args) > 0 && args[0] == "completion" { + return markUsageError(err) + } + return err +} + +func validateDefaultHelpArgs(root *cobra.Command, args []string) error { + if len(args) < 2 || args[0] != "help" { + return nil + } + _, remaining, err := root.Find(args[1:]) + if err == nil && len(remaining) == 0 { + return nil + } + return markUsageError(fmt.Errorf( + "unknown help topic %q", + strings.Join(args[1:], " "), + )) } func isCompletionRequest(args []string) bool { diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 705b66d..607d832 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -283,6 +283,31 @@ func TestExecuteRootTreatsUnknownCommandsAsUsageErrors(t *testing.T) { } } +func TestExecuteRootTreatsDefaultCommandArgumentErrorsAsUsageErrors( + t *testing.T, +) { + tests := [][]string{ + {"help", "definitely-not-a-command"}, + {"completion", "bash", "extra"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "/"), func(t *testing.T) { + err := executeRoot(newRootCmd(), args) + if err == nil { + t.Fatalf("execute %v succeeded", args) + } + if got := commandExitCode(err); got != exitUsage { + t.Fatalf( + "exit code = %d, want %d for %v", + got, + exitUsage, + err, + ) + } + }) + } +} + func TestExecuteRootSupportsDefaultCommands(t *testing.T) { tests := []struct { name string From 139554eec2851e5c8da3b46cea990cd8e58b487a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:28:05 +0800 Subject: [PATCH 007/104] fix: preserve pending workspace recovery state --- internal/workspace/record.go | 46 ++++++---- internal/workspace/recovery_security_test.go | 44 ++++++++++ internal/workspace/source_swap_test.go | 89 ++++++++++++++++++++ internal/workspace/workspace.go | 14 +-- 4 files changed, 171 insertions(+), 22 deletions(-) diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 096fa79..013ea2b 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -955,27 +955,41 @@ func (m *Manager) removalAcknowledgementPending( defer func() { result = errors.Join(result, bucket.Close()) }() - _, exists, err := findRecordAcknowledgementQuarantine( - bucket, - agentID, - removal.operationID, - ) - if err != nil || exists { - return exists, err - } - _, err = bucket.Lstat(agentID + workspaceRecordSuffix) - switch { - case errors.Is(err, os.ErrNotExist): - return false, nil - case err != nil: + entries, err := readRootDirectory(bucket) + if err != nil { return false, fmt.Errorf( - "workspace: inspect record %q: %w", + "workspace: read record directory %q: %w", recordPath, err, ) - default: - return true, nil } + recordName := agentID + workspaceRecordSuffix + acknowledgementPrefix := "." + recordName + ".ack-" + pending := false + for _, entry := range entries { + if entry.Name() == recordName { + pending = true + continue + } + if !strings.HasPrefix(entry.Name(), acknowledgementPrefix) { + continue + } + acknowledgementAgentID, operationID, ok := + recordAcknowledgementIdentity(entry.Name()) + if !ok || acknowledgementAgentID != agentID { + return false, fmt.Errorf( + "workspace: acknowledgement record %q is invalid", + entry.Name(), + ) + } + if operationID != removal.operationID { + return false, errors.New( + "workspace: removal acknowledgement token does not match quarantined record", + ) + } + pending = true + } + return pending, nil } func recordAcknowledgementPrefix( diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 3eab9c2..dcb0fd3 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1732,6 +1732,50 @@ func TestAcknowledgeRemovalRecoversQuarantinedRecord(t *testing.T) { } } +func TestAcknowledgeRemovalRejectsWrongTokenForQuarantinedRecord( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + recordPath := workspaceRecordPath(prepared.Path) + quarantineName := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + "69696969-6969-4969-8969-696969696969" + quarantinePath := filepath.Join(filepath.Dir(recordPath), quarantineName) + if err := os.Rename(recordPath, quarantinePath); err != nil { + t.Fatalf("quarantine removal record: %v", err) + } + wrong := result.Removal + wrong.operationID = "70707070-7070-4070-8070-707070707070" + + if err := manager.AcknowledgeRemoval(wrong); err == nil { + t.Fatal("wrong token acknowledged a quarantined removal record") + } + if _, err := os.Lstat(quarantinePath); err != nil { + t.Fatalf("wrong token changed acknowledgement quarantine: %v", err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal with correct token: %v", err) + } +} + func TestReconcileRemovalCoalescesAcknowledgementHardLinks(t *testing.T) { repository := newTestRepository(t) dataDir := filepath.Join(t.TempDir(), "data") diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 0be9649..e89d6ee 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -410,6 +410,95 @@ exit "$status" assertFileContents(t, sentinel, "replacement\n") } +func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + canonicalSource, err := resolvePath(source) + if err != nil { + t.Fatalf("resolve source: %v", err) + } + targetPath := filepath.Join( + manager.root, + repositoryHash(canonicalSource), + testAgentID, + ) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + repairFailed := filepath.Join(parent, "repair-failed") + pruneFailed := filepath.Join(parent, "prune-failed") + wrapper := filepath.Join(parent, "git-wrapper-repair-prune-failure") + script := `#!/bin/sh +case " $* " in + *" worktree repair "*) + : > "$DROVE_TEST_REPAIR_FAILED" + exit 91 + ;; + *" worktree prune "*) + : > "$DROVE_TEST_PRUNE_FAILED" + exit 92 + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REPAIR_FAILED", repairFailed) + t.Setenv("DROVE_TEST_PRUNE_FAILED", pruneFailed) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after repair and prune failures") + } + if _, err := os.Lstat(repairFailed); err != nil { + t.Fatalf("repair failure was not reached: %v", err) + } + if _, err := os.Lstat(pruneFailed); err != nil { + t.Fatalf("prune failure was not reached: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(targetPath) + if err != nil || !exists { + t.Fatalf("recovery record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + record.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(targetPath), stagingName) + registered, err := manager.worktreeRegistered( + context.Background(), + record.Repository, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("staging registration = %v, err=%v", registered, err) + } + marker, err := manager.branchOwnershipMarkerExists( + context.Background(), + record.Repository, + record.BranchOperationID, + ) + if err != nil || !marker { + t.Fatalf("branch ownership marker = %v, err=%v", marker, err) + } +} + func TestPrepareRollsBackExistingBranchMovedBeforeWorktreeAdd(t *testing.T) { parent := t.TempDir() source := filepath.Join(parent, "source") diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index ea2c088..8e445d8 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -340,12 +340,14 @@ func (m *Manager) prepare( var pruneErr, discardErr error if cleanupErr == nil { pruneErr = sourceRepository.pruneWorktrees(cleanupCtx) - discardErr = m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ) + if pruneErr == nil { + discardErr = m.discardWithRepository( + cleanupCtx, + result, + sourceRepository, + true, + ) + } } resultErr = errors.Join( resultErr, From d44535f66a0c82a1ba7400611dfb65da74255d3f Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:28:15 +0800 Subject: [PATCH 008/104] fix: preserve flags in help topic validation --- cmd/drove/main.go | 14 ++++++++++++-- cmd/drove/main_test.go | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index fd1251f..303fa3a 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -105,13 +105,23 @@ func validateDefaultHelpArgs(root *cobra.Command, args []string) error { if len(args) < 2 || args[0] != "help" { return nil } - _, remaining, err := root.Find(args[1:]) + topic := args[1:] + for index, argument := range topic { + if strings.HasPrefix(argument, "-") { + topic = topic[:index] + break + } + } + if len(topic) == 0 { + return nil + } + _, remaining, err := root.Find(topic) if err == nil && len(remaining) == 0 { return nil } return markUsageError(fmt.Errorf( "unknown help topic %q", - strings.Join(args[1:], " "), + strings.Join(topic, " "), )) } diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 607d832..8e29d56 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -319,6 +319,16 @@ func TestExecuteRootSupportsDefaultCommands(t *testing.T) { args: []string{"help", "worktree"}, wantOutput: "Usage:", }, + { + name: "help command flag", + args: []string{"help", "--help"}, + wantOutput: "Usage:", + }, + { + name: "help target flag", + args: []string{"help", "worktree", "--help"}, + wantOutput: "Usage:", + }, { name: "completion", args: []string{"completion", "bash"}, From 5bf4683cc692bf16d6077373b1a721a14a8b8759 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:31:48 +0800 Subject: [PATCH 009/104] fix: harden durable workspace file copies --- internal/workspace/include.go | 66 ++++++++++++++-- internal/workspace/include_mode_unix_test.go | 59 ++++++++++++++ internal/workspace/include_security_test.go | 67 ++++++++++++++++ internal/workspace/prepared_worktree.go | 4 +- internal/workspace/record.go | 10 +-- .../workspace/record_rename_legacy_unix.go | 4 + internal/workspace/record_rename_other.go | 4 + internal/workspace/record_rename_unix.go | 4 + internal/workspace/record_rename_windows.go | 78 ++++++++++++++++++- .../workspace/record_rename_windows_test.go | 13 +++- internal/workspace/secure_remove.go | 8 +- 11 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 internal/workspace/include_mode_unix_test.go diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 1730761..6a56064 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -3,6 +3,7 @@ package workspace import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "io" @@ -659,6 +660,24 @@ func copyIncludedPath( destinationRoot *os.Root, destinationName string, relative string, +) error { + return copyIncludedPathAfterCopy( + sourceRoot, + destinationBucket, + destinationRoot, + destinationName, + relative, + nil, + ) +} + +func copyIncludedPathAfterCopy( + sourceRoot *os.Root, + destinationBucket *os.Root, + destinationRoot *os.Root, + destinationName string, + relative string, + afterCopy func(), ) (result error) { relative, err := validateIncludedPath(relative) if err != nil { @@ -766,20 +785,53 @@ func copyIncludedPath( result = errors.Join(result, output.Close()) } }() - copied, err := io.Copy(output, input) + copiedDigest := sha256.New() + copied, err := io.Copy(io.MultiWriter(output, copiedDigest), input) if err != nil { return fmt.Errorf("copy staged contents: %w", err) } - afterCopy, err := input.Stat() + if afterCopy != nil { + afterCopy() + } + afterCopyInfo, err := input.Stat() if err != nil { return fmt.Errorf("reinspect copied source: %w", err) } if copied != openedInfo.Size() || - afterCopy.Size() != openedInfo.Size() || - afterCopy.Mode() != openedInfo.Mode() || - !afterCopy.ModTime().Equal(openedInfo.ModTime()) { + afterCopyInfo.Size() != openedInfo.Size() || + afterCopyInfo.Mode() != openedInfo.Mode() || + !afterCopyInfo.ModTime().Equal(openedInfo.ModTime()) { return errors.New("source changed while copying") } + if _, err := input.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind copied source: %w", err) + } + verifiedDigest := sha256.New() + verified, err := io.Copy(verifiedDigest, input) + if err != nil { + return fmt.Errorf("verify copied source contents: %w", err) + } + afterVerification, err := input.Stat() + if err != nil { + return fmt.Errorf("reinspect verified source: %w", err) + } + if verified != openedInfo.Size() || + afterVerification.Size() != openedInfo.Size() || + afterVerification.Mode() != openedInfo.Mode() || + !afterVerification.ModTime().Equal(openedInfo.ModTime()) || + !bytes.Equal(copiedDigest.Sum(nil), verifiedDigest.Sum(nil)) { + return errors.New("source changed while verifying copied contents") + } + if err := output.Chmod(openedInfo.Mode().Perm()); err != nil { + return fmt.Errorf("set staged destination mode: %w", err) + } + stagedInfo, err := output.Stat() + if err != nil { + return fmt.Errorf("inspect staged destination: %w", err) + } + if stagedInfo.Mode().Perm() != openedInfo.Mode().Perm() { + return errors.New("staged destination mode does not match source") + } if err := output.Sync(); err != nil { return fmt.Errorf("sync staged destination: %w", err) } @@ -799,7 +851,7 @@ func copyIncludedPath( return fmt.Errorf("verify destination parent: %w", err) } - directoryFile, err := destinationParent.Open(".") + directoryFile, err := openRecordDirectory(destinationParent) if err != nil { return fmt.Errorf("open destination parent for install: %w", err) } @@ -876,7 +928,7 @@ func openIncludedParent( err, ) } - directory, openErr := current.Open(".") + directory, openErr := openRecordDirectory(current) if openErr != nil { return nil, false, fmt.Errorf( "open destination parent for sync: %w", diff --git a/internal/workspace/include_mode_unix_test.go b/internal/workspace/include_mode_unix_test.go new file mode 100644 index 0000000..e99269c --- /dev/null +++ b/internal/workspace/include_mode_unix_test.go @@ -0,0 +1,59 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package workspace + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCopyIncludedPathPreservesModeUnderRestrictiveUmask(t *testing.T) { + sourcePath := t.TempDir() + if err := os.WriteFile( + filepath.Join(sourcePath, "shared"), + []byte("contents\n"), + 0o644, + ); err != nil { + t.Fatalf("write source: %v", err) + } + destinationPath := t.TempDir() + source, err := openRealPathRoot(sourcePath) + if err != nil { + t.Fatalf("open source root: %v", err) + } + defer source.Close() + destinationBucket, err := openRealPathRoot(filepath.Dir(destinationPath)) + if err != nil { + t.Fatalf("open destination bucket: %v", err) + } + defer destinationBucket.Close() + destination, err := openRealRootFromRoot( + destinationBucket, + filepath.Base(destinationPath), + ) + if err != nil { + t.Fatalf("open destination root: %v", err) + } + defer destination.Close() + + previousUmask := syscall.Umask(0o077) + defer syscall.Umask(previousUmask) + if err := copyIncludedPath( + source, + destinationBucket, + destination, + filepath.Base(destinationPath), + "shared", + ); err != nil { + t.Fatalf("copy included path: %v", err) + } + info, err := os.Stat(filepath.Join(destinationPath, "shared")) + if err != nil { + t.Fatalf("inspect copied destination: %v", err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o644); got != want { + t.Fatalf("copied destination mode = %o, want %o", got, want) + } +} diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 28d6ad2..1327dcd 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -77,6 +77,73 @@ func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { ) } +func TestCopyIncludedPathRejectsSameSizeRewriteDuringCopy(t *testing.T) { + sourcePath := t.TempDir() + sourceFile := filepath.Join(sourcePath, "secret") + if err := os.WriteFile(sourceFile, []byte("before\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + sourceInfo, err := os.Stat(sourceFile) + if err != nil { + t.Fatalf("inspect source: %v", err) + } + destinationPath := t.TempDir() + source, err := openRealPathRoot(sourcePath) + if err != nil { + t.Fatalf("open source root: %v", err) + } + defer source.Close() + destinationBucket, err := openRealPathRoot(filepath.Dir(destinationPath)) + if err != nil { + t.Fatalf("open destination bucket: %v", err) + } + defer destinationBucket.Close() + destination, err := openRealRootFromRoot( + destinationBucket, + filepath.Base(destinationPath), + ) + if err != nil { + t.Fatalf("open destination root: %v", err) + } + defer destination.Close() + + err = copyIncludedPathAfterCopy( + source, + destinationBucket, + destination, + filepath.Base(destinationPath), + "secret", + func() { + if err := os.WriteFile( + sourceFile, + []byte("after!\n"), + 0o600, + ); err != nil { + t.Fatalf("rewrite source: %v", err) + } + if err := os.Chtimes( + sourceFile, + sourceInfo.ModTime(), + sourceInfo.ModTime(), + ); err != nil { + t.Fatalf("restore source modification time: %v", err) + } + }, + ) + if err == nil || + !strings.Contains( + err.Error(), + "source changed while verifying copied contents", + ) { + t.Fatalf("same-size source rewrite error = %v", err) + } + if _, err := os.Lstat( + filepath.Join(destinationPath, "secret"), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected source rewrite installed destination: %v", err) + } +} + func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { sourcePath := filepath.Join(t.TempDir(), "source") if err := os.Mkdir(sourcePath, 0o700); err != nil { diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index e947fb8..4ca81f4 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -98,7 +98,7 @@ func (m *Manager) createPreparedWorktreeTarget( ); err != nil { return nil, err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return nil, fmt.Errorf( "workspace: open prepared worktree parent: %w", @@ -290,7 +290,7 @@ func (m *Manager) promotePreparedWorktreeTarget( if err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return err } diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 013ea2b..c2c19cf 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -254,7 +254,7 @@ func (m *Manager) installWorkspaceRecordState( if err := m.verifyRecordBucket(record.Path, bucket); err != nil { return false, err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return false, fmt.Errorf( "workspace: open record directory %q: %w", @@ -865,7 +865,7 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return fmt.Errorf( "workspace: open record directory %q: %w", @@ -1338,7 +1338,7 @@ func (m *Manager) removeRecordAcknowledgementQuarantine( ); err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return fmt.Errorf( "workspace: open quarantined record directory %q: %w", @@ -1397,7 +1397,7 @@ func restoreRecordAcknowledgement( if err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return err } @@ -1434,7 +1434,7 @@ func syncRecordBucket( bucket *os.Root, recordPath string, ) error { - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return fmt.Errorf( "workspace: open record directory %q for sync: %w", diff --git a/internal/workspace/record_rename_legacy_unix.go b/internal/workspace/record_rename_legacy_unix.go index 3caf6a2..c7edc76 100644 --- a/internal/workspace/record_rename_legacy_unix.go +++ b/internal/workspace/record_rename_legacy_unix.go @@ -30,6 +30,10 @@ func moveRecordFile( ) } +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") +} + func syncRecordDirectory(directory *os.File) error { return directory.Sync() } diff --git a/internal/workspace/record_rename_other.go b/internal/workspace/record_rename_other.go index da6b72b..d294c32 100644 --- a/internal/workspace/record_rename_other.go +++ b/internal/workspace/record_rename_other.go @@ -30,6 +30,10 @@ func moveRecordFile( ) } +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") +} + func syncRecordDirectory(directory *os.File) error { return directory.Sync() } diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index a39da44..c54633a 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -224,6 +224,10 @@ func verifyRecordPathIdentity( return nil } +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") +} + func syncRecordDirectory(directory *os.File) error { return directory.Sync() } diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 936f200..f31c6bb 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -154,6 +154,82 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { return file, nil } -func syncRecordDirectory(_ *os.File) error { +func openRecordDirectory(root *os.Root) (_ *os.File, result error) { + anchor, err := root.Open(".") + if err != nil { + return nil, err + } + defer func() { + if anchor != nil { + result = errors.Join(result, anchor.Close()) + } + }() + anchorInfo, err := anchor.Stat() + if err != nil { + return nil, fmt.Errorf("inspect directory anchor: %w", err) + } + objectName, err := windows.NewNTUnicodeString(".") + if err != nil { + return nil, err + } + attributes := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: windows.Handle(anchor.Fd()), + ObjectName: objectName, + } + var ( + handle windows.Handle + status windows.IO_STATUS_BLOCK + ) + err = windows.NtCreateFile( + &handle, + windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, + attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + windows.FILE_SYNCHRONOUS_IO_NONALERT| + windows.FILE_DIRECTORY_FILE| + windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return nil, err + } + directory := os.NewFile(uintptr(handle), ".") + if directory == nil { + _ = windows.CloseHandle(handle) + return nil, errors.New("create directory file from Windows handle") + } + openedInfo, err := directory.Stat() + if err != nil { + return nil, errors.Join( + fmt.Errorf("inspect writable directory handle: %w", err), + directory.Close(), + ) + } + if !openedInfo.IsDir() || !os.SameFile(anchorInfo, openedInfo) { + return nil, errors.Join( + errors.New("writable directory handle changed identity"), + directory.Close(), + ) + } + closeErr := anchor.Close() + anchor = nil + if closeErr != nil { + return nil, errors.Join(closeErr, directory.Close()) + } + return directory, nil +} + +func syncRecordDirectory(directory *os.File) error { + if err := windows.FlushFileBuffers( + windows.Handle(directory.Fd()), + ); err != nil { + return fmt.Errorf("flush directory metadata: %w", err) + } return nil } diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index abe2300..182cdbc 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -8,14 +8,19 @@ import ( "testing" ) -func TestSyncRecordDirectoryAcceptsReadOnlyWindowsHandle(t *testing.T) { - directory, err := os.Open(t.TempDir()) +func TestOpenRecordDirectoryProvidesSyncableWindowsHandle(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) if err != nil { - t.Fatalf("open directory: %v", err) + t.Fatalf("open root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open syncable directory: %v", err) } defer directory.Close() if err := syncRecordDirectory(directory); err != nil { - t.Fatalf("sync read-only directory handle: %v", err) + t.Fatalf("sync directory handle: %v", err) } } diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index e416345..7ac6698 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -346,7 +346,7 @@ func installRemovalMarker( if err := file.Sync(); err != nil { return fmt.Errorf("workspace: sync removal marker: %w", err) } - directory, err := root.Open(".") + directory, err := openRecordDirectory(root) if err != nil { return fmt.Errorf("workspace: open removal marker directory: %w", err) } @@ -424,7 +424,7 @@ func removeRemovalMarkerIfPresent( if err := root.Remove(name); err != nil { return fmt.Errorf("workspace: remove removal marker: %w", err) } - directory, err := root.Open(".") + directory, err := openRecordDirectory(root) if err != nil { return fmt.Errorf( "workspace: open removal marker directory: %w", @@ -538,7 +538,7 @@ func quarantineManagedPath( return "", false, err } name = removalQuarantinePrefix(record) + uuid.NewString() - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return "", false, err } @@ -583,7 +583,7 @@ func restoreManagedQuarantine( if err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return err } From 3f3d11b55b15908277ca1ed29cb69e77f1840ed3 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:38:34 +0800 Subject: [PATCH 010/104] fix: reconcile stale preparation registrations --- internal/workspace/prepared_worktree.go | 5 +- internal/workspace/source_swap_test.go | 28 ++++++++- internal/workspace/workspace.go | 75 +++++++++++++------------ 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 4ca81f4..dfe6850 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -389,9 +389,10 @@ func (m *Manager) preparedWorktreeStagingPath( result = errors.Join(result, bucket.Close()) }() info, err := bucket.Lstat(name) + path := filepath.Join(filepath.Dir(target.Path), name) switch { case errors.Is(err, os.ErrNotExist): - return "", false, nil + return path, false, nil case err != nil: return "", false, err case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: @@ -399,7 +400,7 @@ func (m *Manager) preparedWorktreeStagingPath( "workspace: prepared worktree staging path is not a real directory", ) default: - return filepath.Join(filepath.Dir(target.Path), name), true, nil + return path, true, nil } } diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index e89d6ee..a54d2b6 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -414,7 +414,8 @@ func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) - manager, err := New(filepath.Join(parent, "data")) + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) if err != nil { t.Fatalf("new manager: %v", err) } @@ -497,6 +498,31 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if err != nil || !marker { t.Fatalf("branch ownership marker = %v, err=%v", marker, err) } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile prunable registration: %v", err) + } + if _, err := os.Lstat(workspaceRecordPath(targetPath)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reconciled preparation record remains: %v", err) + } + registered, err = restarted.worktreeRegistered( + context.Background(), + record.Repository, + stagingPath, + ) + if err != nil || registered { + t.Fatalf("reconciled staging registration = %v, err=%v", registered, err) + } } func TestPrepareRollsBackExistingBranchMovedBeforeWorktreeAdd(t *testing.T) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 8e445d8..51d197c 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -719,47 +719,59 @@ func (m *Manager) discardWithRepository( "workspace: preparation record does not match discard request", ) } - var result error + stagingPath, stagingExists, err := + m.preparedWorktreeStagingPath(target, record) + if err != nil { + return err + } _, pathErr := os.Lstat(target.Path) pathExists := pathErr == nil - pathMissing := errors.Is(pathErr, os.ErrNotExist) - activePath := target.Path if pathErr != nil && !errors.Is(pathErr, os.ErrNotExist) { - result = errors.Join( - result, - fmt.Errorf("workspace: inspect discarded worktree: %w", pathErr), + return fmt.Errorf( + "workspace: inspect discarded worktree: %w", + pathErr, ) } - if pathMissing { - stagingPath, exists, stagingErr := - m.preparedWorktreeStagingPath(target, record) - if stagingErr != nil { - return errors.Join(result, stagingErr) - } else if exists { - activePath = stagingPath - pathExists = true - pathMissing = false - } + if pathExists && stagingExists { + return errors.New( + "workspace: preparation has both public and staging worktree paths", + ) + } + activePath := target.Path + if stagingExists { + activePath = stagingPath + pathExists = true } if pathExists && (record.DirectoryIdentity == "" || (record.GitDirectory == "" && !allowIncompleteGitIdentity)) { - return errors.Join( - result, - errors.New( - "workspace: preparation record has incomplete worktree identity", - ), + return errors.New( + "workspace: preparation record has incomplete worktree identity", ) } - registered, err := repository.worktreeRegistered( - ctx, - activePath, - ) + registrationPaths := []string{target.Path} + if stagingPath != target.Path { + registrationPaths = append(registrationPaths, stagingPath) + } + anyRegistered := func() (bool, error) { + for _, path := range registrationPaths { + registered, err := repository.worktreeRegistered(ctx, path) + if err != nil { + return false, err + } + if registered { + return true, nil + } + } + return false, nil + } + registered, err := anyRegistered() + var result error worktreeRemoved := false if err != nil { result = errors.Join(result, err) } else { - pathRemoved := pathMissing + pathRemoved := !pathExists if pathExists { var removeErr error if activePath != target.Path { @@ -794,11 +806,7 @@ func (m *Manager) discardWithRepository( ) } else { if !registered { - registered, removeErr = - repository.worktreeRegistered( - ctx, - activePath, - ) + registered, removeErr = anyRegistered() if removeErr != nil { result = errors.Join(result, removeErr) } @@ -816,10 +824,7 @@ func (m *Manager) discardWithRepository( } } if pathRemoved { - stillRegistered, err := repository.worktreeRegistered( - ctx, - activePath, - ) + stillRegistered, err := anyRegistered() if err != nil { result = errors.Join(result, err) } else if stillRegistered { From e93b1d8f1aac6742adb081cf9107402e9214df9f Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:39:15 +0800 Subject: [PATCH 011/104] fix: validate help topics after flag terminator --- cmd/drove/main.go | 17 +++++++++++++---- cmd/drove/main_test.go | 2 ++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index 303fa3a..a09b229 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -105,12 +105,21 @@ func validateDefaultHelpArgs(root *cobra.Command, args []string) error { if len(args) < 2 || args[0] != "help" { return nil } - topic := args[1:] - for index, argument := range topic { + topic := make([]string, 0, len(args)-1) + afterTerminator := false + for _, argument := range args[1:] { + if afterTerminator { + topic = append(topic, argument) + continue + } + if argument == "--" { + afterTerminator = true + continue + } if strings.HasPrefix(argument, "-") { - topic = topic[:index] - break + continue } + topic = append(topic, argument) } if len(topic) == 0 { return nil diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 8e29d56..407f4be 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -288,6 +288,8 @@ func TestExecuteRootTreatsDefaultCommandArgumentErrorsAsUsageErrors( ) { tests := [][]string{ {"help", "definitely-not-a-command"}, + {"help", "--", "definitely-not-a-command"}, + {"help", "worktree", "--", "definitely-not-a-command"}, {"completion", "bash", "extra"}, } for _, args := range tests { From 2203233b5b6ad252d0bca5c63a7fb66b41dc7758 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 23:00:14 +0800 Subject: [PATCH 012/104] fix: rename read-only sidecars on Windows --- internal/workspace/record_rename_windows.go | 6 ------ internal/workspace/record_rename_windows_test.go | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index f31c6bb..139d5e4 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -53,11 +53,6 @@ func renameRecordFile( if err != nil { return installed, err } - if err := windows.FlushFileBuffers( - windows.Handle(renaming.Fd()), - ); err != nil { - return true, fmt.Errorf("flush renamed record: %w", err) - } return true, nil } @@ -128,7 +123,6 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { err = windows.NtCreateFile( &handle, windows.FILE_READ_ATTRIBUTES| - windows.FILE_WRITE_DATA| windows.DELETE| windows.SYNCHRONIZE, attributes, diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 182cdbc..5addfc6 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -197,7 +197,7 @@ func TestRenameWindowsHandleAcceptsReadOnlyFile(t *testing.T) { assertFileContents(t, filepath.Join(rootPath, "target"), "source\n") } -func TestMoveRecordFileFlushesThroughRenameHandle(t *testing.T) { +func TestMoveRecordFileAcceptsReadOnlySourceHandle(t *testing.T) { rootPath := t.TempDir() if err := os.WriteFile( filepath.Join(rootPath, "source"), From cc588664c94c9e9c7f9015de97034c622691cd0b Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 23:19:05 +0800 Subject: [PATCH 013/104] fix: use writable directory for ack alias moves --- internal/workspace/record.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workspace/record.go b/internal/workspace/record.go index c2c19cf..db5be8f 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -1260,7 +1260,7 @@ func coalesceRecordAcknowledgementsAfterValidation( ) } for _, duplicate := range candidates[1:] { - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return recordAcknowledgement{}, err } From 59cd271d9bd55afcfae1b0f32d7be2b4c79839b4 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 23:41:47 +0800 Subject: [PATCH 014/104] fix: preserve startup resume retries --- internal/session/projection.go | 16 +++++++--- internal/session/projection_test.go | 48 +++++++++++++++++++++++++++++ internal/session/session.go | 6 ++-- internal/session/session_test.go | 40 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/internal/session/projection.go b/internal/session/projection.go index afb4dcb..55cf89f 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -301,6 +301,7 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { ) } draft.workspaceRemoved = true + draft.restartStopped = false return nil } @@ -355,11 +356,18 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { p.report.PartialHistory++ } draft.state = to + isRestartStop := to == agent.StateStopped && + from != agent.StateDone && + from != agent.StateStopped && + row.Reason == restartStopReason + isFailedRestartResume := draft.restartStopped && + from == agent.StateStarting && + to == agent.StateStopped + isRestartResumeStarting := draft.restartStopped && + from == agent.StateStopped && + to == agent.StateStarting draft.restartStopped = - to == agent.StateStopped && - from != agent.StateDone && - from != agent.StateStopped && - row.Reason == restartStopReason + isRestartStop || isFailedRestartResume || isRestartResumeStarting if known { draft.lastTransition = evidence } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index f4a849e..b466f9e 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -398,6 +398,54 @@ func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( restartedPlan.Reconciliation, ) } + + nextSeq := uint64(len(rows) + 1) + rows = append( + rows, + store.EventRow{ + Seq: nextSeq, + Timestamp: base.Add(3 * time.Hour), + Type: string(event.TypeAgentResumed), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + store.EventRow{ + Seq: nextSeq + 1, + Timestamp: base.Add(3*time.Hour + time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "stopped", + To: "starting", + }, + store.EventRow{ + Seq: nextSeq + 2, + Timestamp: base.Add(3*time.Hour + 2*time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "stopped", + Reason: "startup failed", + Payload: `{"version":1,"source":"process","event":"process_start_failed",` + + `"confidence":1}`, + }, + ) + failedResume := newRecoveryProjector() + for _, row := range rows { + if err := failedResume.Apply(row); err != nil { + t.Fatalf("apply failed resume seq %d: %v", row.Seq, err) + } + } + failedPlan, err := failedResume.Finish(base.Add(4 * time.Hour)) + if err != nil { + t.Fatalf("finish failed resume projection: %v", err) + } + if !failedPlan.ResumeOnStart["agent-1"] { + t.Fatal("failed startup resume lost automatic resume intent") + } } func TestRecoveryProjectorDoesNotResumeCompletedSessionAfterRestartStop( diff --git a/internal/session/session.go b/internal/session/session.go index 3252ea8..897f47d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1187,9 +1187,6 @@ func (m *Manager) resumeOnStart( } } managed, entry, ref, err := m.reserveResumeLocked(id) - if err == nil { - managed.consumeResumeOnStart() - } m.mu.Unlock() if err != nil { m.endStart() @@ -1197,6 +1194,9 @@ func (m *Manager) resumeOnStart( } _, err = m.resumeReserved(ctx, id, managed, entry, ref) + if err == nil { + managed.consumeResumeOnStart() + } m.endStart() return true, err } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 67ecae9..02865e4 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1164,6 +1164,46 @@ func TestResumeOnStartRunsEligibleAgentsInCreationOrderOnce(t *testing.T) { } } +func TestResumeOnStartRetriesFailedCandidate(t *testing.T) { + manager, _ := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + attempts := 0 + manager.startPTY = func(pty.Config) (launchedSession, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("exec unavailable") + } + return &fakeProcessSession{}, nil + } + + first := manager.ResumeOnStart(context.Background()) + if len(first) != 1 || + first[0].AgentID != managed.agent.ID() || + first[0].Err == nil { + t.Fatalf("first startup resume results = %+v", first) + } + if !managed.shouldResumeOnStart() { + t.Fatal("failed startup resume consumed candidate") + } + + second := manager.ResumeOnStart(context.Background()) + if len(second) != 1 || + second[0].AgentID != managed.agent.ID() || + second[0].Err != nil { + t.Fatalf("second startup resume results = %+v", second) + } + if managed.shouldResumeOnStart() { + t.Fatal("successful startup resume retained candidate") + } + if third := manager.ResumeOnStart(context.Background()); len(third) != 0 { + t.Fatalf("third startup resume results = %+v, want none", third) + } +} + func TestNormalizeRunMode(t *testing.T) { tests := []struct { name string From d73c88f281ef9f23700f9e657e7c303d72ac0df2 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 00:26:08 +0800 Subject: [PATCH 015/104] fix: make recovery completion durable --- internal/session/AGENTS.md | 9 +- internal/session/projection.go | 46 ++++++-- internal/session/projection_test.go | 107 +++++++++++++++++++ internal/session/session.go | 62 ++++++++--- internal/session/session_test.go | 106 ++++++++++++++++++ internal/workspace/AGENTS.md | 4 +- internal/workspace/recovery_security_test.go | 51 +++++++++ internal/workspace/removal.go | 12 ++- internal/workspace/workspace.go | 35 +++--- 9 files changed, 392 insertions(+), 40 deletions(-) diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index e5933e8..6b56655 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -119,9 +119,12 @@ 只更新已有会话的事件事实。 - 恢复投影只接受紧邻同 Agent `agent.resumed` 的 `Stopped -> Starting`;启动自动恢复 只消费重启前非终态且已有 ref 的一次性候选,并按创建时间排序。候选等待同 Agent - cleanup completion,只有成功安装 resume reservation 后才消费;取消等待保留候选。 - daemon 重启生成并持久化的 recovery `Stopped` 在真正尝试 resume 前仍保留该资格, - 连续启动失败不能把候选静默降级为普通停止会话。 + cleanup completion;自动或普通 `Resume` 消费此候选时,恢复进程及 required hook + 均成功后必须用后台上下文持久化 `session_lifecycle(startup_resume_completed)`,只有 + 该事件 durable 后才清除内存资格并释放 resume reservation,避免短命进程退出后 + workspace tombstone 抢先提交。取消等待、PTY 启动失败、required hook 失败及完成事件 + 未落库都保留候选。daemon 重启生成并持久化的 recovery `Stopped` 在投影读到完成事件 + 前始终保留该资格,连续启动失败不能把候选静默降级为普通停止会话。 - 信号与状态证据 reader 同时接受 v1、v2、typed screen v3 和 typed terminal v4;v2 的 notify 只在 fallback 下确认 Idle。未知补充版本按既有计数策略跳过,已知畸形版本报错。 adapter 标记为忽略的厂商内部通知不提交事件。 diff --git a/internal/session/projection.go b/internal/session/projection.go index 55cf89f..5a8e22d 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -165,6 +165,8 @@ func (p *recoveryProjector) applyLifecycle(row store.EventRow) error { switch row.Reason { case "created": return p.applyCreated(row) + case startupResumeCompletedReason: + return p.applyStartupResumeCompleted(row) case workspaceRemovedReason: return p.applyWorkspaceRemoved(row) default: @@ -281,6 +283,41 @@ func (p *recoveryProjector) applyCreated(row store.EventRow) error { return nil } +func (p *recoveryProjector) applyStartupResumeCompleted( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "startup resume completion has no session history", + ) + } + var payload startupResumeCompletedPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return projectionWrapError( + row, + "decode startup resume completion", + err, + ) + } + if payload.Version != 1 { + return projectionError( + row, + "unsupported startup resume completion version %d", + payload.Version, + ) + } + if !draft.restartStopped { + return projectionError( + row, + "startup resume completion has no restart resume candidate", + ) + } + draft.restartStopped = false + return nil +} + func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { draft := p.draft(row) if !draft.hasCreated && !draft.hasState { @@ -360,14 +397,7 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { from != agent.StateDone && from != agent.StateStopped && row.Reason == restartStopReason - isFailedRestartResume := draft.restartStopped && - from == agent.StateStarting && - to == agent.StateStopped - isRestartResumeStarting := draft.restartStopped && - from == agent.StateStopped && - to == agent.StateStarting - draft.restartStopped = - isRestartStop || isFailedRestartResume || isRestartResumeStarting + draft.restartStopped = draft.restartStopped || isRestartStop if known { draft.lastTransition = evidence } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index b466f9e..0d3379e 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -448,6 +448,113 @@ func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( } } +func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 13, 0, 0, 0, time.UTC) + baseRows := []store.EventRow{ + { + Seq: 1, Timestamp: base, Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, Timestamp: base.Add(time.Second), Type: string(event.TypeAgentSignal), + SessionID: "agent-1", AgentID: "agent-1", Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-05T13:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, Timestamp: base.Add(2 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "pending", To: "starting", + }, + { + Seq: 4, Timestamp: base.Add(3 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 5, Timestamp: base.Add(4 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: restartStopReason, + }, + { + Seq: 6, Timestamp: base.Add(5 * time.Second), Type: string(event.TypeAgentResumed), + SessionID: "agent-1", AgentID: "agent-1", Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + { + Seq: 7, Timestamp: base.Add(6 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "stopped", To: "starting", + }, + { + Seq: 8, Timestamp: base.Add(7 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + } + tests := []struct { + name string + finalRows []store.EventRow + wantResume bool + }{ + { + name: "required hook failure", + finalRows: []store.EventRow{{ + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: "required hook activation failed", + }}, + wantResume: true, + }, + { + name: "durable completion", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCompletedReason, + Payload: `{"version":1}`, + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "stopped", + Reason: "process exited", + }, + }, + wantResume: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + projector := newRecoveryProjector() + rows := append( + append([]store.EventRow(nil), baseRows...), + test.finalRows..., + ) + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if got := plan.ResumeOnStart["agent-1"]; got != test.wantResume { + t.Fatalf( + "startup resume eligibility = %t, want %t", + got, + test.wantResume, + ) + } + }) + } +} + func TestRecoveryProjectorDoesNotResumeCompletedSessionAfterRestartStop( t *testing.T, ) { diff --git a/internal/session/session.go b/internal/session/session.go index 897f47d..6f62508 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -90,10 +90,15 @@ type inputAuditPayload struct { const MaxInputBytes = 64 * 1024 const ( - initialTerminalRows = 40 - initialTerminalColumns = 120 + initialTerminalRows = 40 + initialTerminalColumns = 120 + startupResumeCompletedReason = "startup_resume_completed" ) +type startupResumeCompletedPayload struct { + Version int `json:"version"` +} + var ( // ErrUnknownAgent 表示目标 Agent 不存在。 ErrUnknownAgent = errors.New("session: unknown agent") @@ -696,12 +701,7 @@ func (m *Manager) resumeReserved( entry adapter.Entry, ref string, ) (*Status, error) { - reserved := true - defer func() { - if reserved { - m.releaseResume(id) - } - }() + defer m.releaseResume(id) target := managed.agent command, err := entry.ResumeCommand( @@ -790,9 +790,7 @@ func (m *Manager) resumeReserved( m.mu.Lock() m.sessions[id] = running - delete(m.resuming, id) m.mu.Unlock() - reserved = false if err := m.activate(ctx, activation{ id: id, @@ -808,6 +806,11 @@ func (m *Manager) resumeReserved( }); err != nil { return nil, err } + if managed.shouldResumeOnStart() { + if err := m.completeStartupResume(id, managed); err != nil { + return nil, err + } + } return m.Status(id) } @@ -1194,12 +1197,45 @@ func (m *Manager) resumeOnStart( } _, err = m.resumeReserved(ctx, id, managed, entry, ref) - if err == nil { - managed.consumeResumeOnStart() + if err != nil { + m.endStart() + return true, err } m.endStart() - return true, err + return true, nil + } +} + +func (m *Manager) completeStartupResume( + id agent.ID, + managed *managedAgent, +) error { + payload, err := json.Marshal(startupResumeCompletedPayload{Version: 1}) + if err != nil { + return fmt.Errorf( + "session: encode startup resume completion: %w", + err, + ) } + receipt, commitErr := m.committer.CommitEvents( + context.Background(), + []event.Draft{event.NewSessionLifecycleDraft( + string(id), + string(id), + startupResumeCompletedReason, + string(payload), + )}, + ) + if receipt.Durable { + managed.consumeResumeOnStart() + } + if commitErr != nil { + return fmt.Errorf( + "session: persist startup resume completion: %w", + commitErr, + ) + } + return nil } // Replay 返回某会话的事件流(来自 store,按 seq 升序)。 diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 02865e4..a0d4645 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1204,6 +1204,82 @@ func TestResumeOnStartRetriesFailedCandidate(t *testing.T) { } } +func TestResumeOnStartPersistsCompletionAfterContextCancellation(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + ctx, cancel := context.WithCancel(context.Background()) + manager.startPTY = func(pty.Config) (launchedSession, error) { + cancel() + return &fakeProcessSession{}, nil + } + + results := manager.ResumeOnStart(ctx) + if len(results) != 1 || + results[0].AgentID != managed.agent.ID() || + results[0].Err != nil { + t.Fatalf("startup resume results = %+v", results) + } + if managed.shouldResumeOnStart() { + t.Fatal("durably completed startup resume retained candidate") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay startup resume: %v", err) + } + last := rows[len(rows)-1] + if last.Type != string(event.TypeSessionLifecycle) || + last.Reason != startupResumeCompletedReason || + last.Payload != `{"version":1}` { + t.Fatalf("startup resume completion = %+v", last) + } +} + +func TestManualResumeCompletesStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + manager.committer.Close() + commitStore := &resumeReservationCommitStore{ + commitStore: st, + manager: manager, + id: managed.agent.ID(), + } + manager.committer = newCommitter(0, commitStore, manager.hub) + manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + + status, err := manager.Resume(context.Background(), managed.agent.ID()) + if err != nil { + t.Fatalf("manual resume: %v", err) + } + if status.State != agent.StateWorking { + t.Fatalf("manual resume status = %+v", status) + } + if managed.shouldResumeOnStart() { + t.Fatal("manual resume retained startup resume intent") + } + if !commitStore.observedCompletion { + t.Fatal("startup resume completion was not committed") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay manual resume: %v", err) + } + last := rows[len(rows)-1] + if last.Type != string(event.TypeSessionLifecycle) || + last.Reason != startupResumeCompletedReason || + last.Payload != `{"version":1}` { + t.Fatalf("startup resume completion = %+v", last) + } +} + func TestNormalizeRunMode(t *testing.T) { tests := []struct { name string @@ -2344,6 +2420,36 @@ type cancelAfterAppendStore struct { once sync.Once } +type resumeReservationCommitStore struct { + commitStore + manager *Manager + id agent.ID + observedCompletion bool +} + +func (s *resumeReservationCommitStore) AppendEvents( + ctx context.Context, + expectedLastSeq uint64, + rows []store.EventRow, +) (uint64, error) { + for _, row := range rows { + if row.Type != string(event.TypeSessionLifecycle) || + row.Reason != startupResumeCompletedReason { + continue + } + s.manager.mu.RLock() + _, reserved := s.manager.resuming[s.id] + s.manager.mu.RUnlock() + if !reserved { + return expectedLastSeq, errors.New( + "startup resume completion committed without reservation", + ) + } + s.observedCompletion = true + } + return s.commitStore.AppendEvents(ctx, expectedLastSeq, rows) +} + func (s *cancelAfterAppendStore) AppendEvents( ctx context.Context, expectedLastSeq uint64, diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 3309729..1b4ca30 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -112,7 +112,9 @@ root/bucket 句柄。sidecar 的原子写、确认读取和删除必须在受约束 bucket 句柄内完成。 初次 sidecar 安装必须使用 no-replace 原语;removal acknowledgement 先把匹配 token 的 sidecar 原子移动到 operation ID 隔离名,校验后再移动到同格式的新随机私有名并按已打开 - 文件身份删除,崩溃后从任一隔离名恢复。 + 文件身份删除,崩溃后从任一隔离名恢复。sidecar 与确认隔离名均缺失时必须 fail-closed; + 只有当前 Manager 已成功删除同一 Agent ID 与 operation ID 的 sidecar 后,进程内重复确认 + 才可幂等成功。 sidecar 安装在原子改名前后都要确认已打开的 repository bucket 仍位于规范 hash 路径, 文件改名先把已校验源链接到内部随机别名,再从别名安装最终目标;公开临时路径被替换 只能导致失败,不得覆盖最终记录。清理临时名时也必须确认它仍指向已打开文件。缺少 diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index dcb0fd3..9c29095 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -451,6 +451,57 @@ func TestAcknowledgeRemovalRetryDoesNotRequireDeletedRepository( } } +func TestAcknowledgeRemovalRejectsMissingRecordWithReplacementWorkspace( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + if err := os.Remove(workspaceRecordPath(prepared.Path)); err != nil { + t.Fatalf("remove pending acknowledgement record: %v", err) + } + if err := os.Mkdir(prepared.Path, 0o700); err != nil { + t.Fatalf("create replacement workspace: %v", err) + } + replacementMarker := filepath.Join(prepared.Path, "replacement") + if err := os.WriteFile(replacementMarker, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement marker: %v", err) + } + + if err := manager.AcknowledgeRemoval(result.Removal); err == nil { + t.Fatal("acknowledgement accepted a missing record") + } + contents, err := os.ReadFile(replacementMarker) + if err != nil { + t.Fatalf("read replacement marker: %v", err) + } + if string(contents) != "replacement\n" { + t.Fatalf("replacement marker = %q", contents) + } +} + func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 0d8e8c9..8be8154 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -210,12 +210,21 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { "workspace: removal acknowledgement token is invalid", ) } + receipt := removalAcknowledgementReceipt{ + agentID: removal.Workspace.AgentID, + operationID: removal.operationID, + } pending, err := m.removalAcknowledgementPending(removal) if err != nil { return err } if !pending { - return nil + if _, acknowledged := m.acknowledgedRemovals[receipt]; acknowledged { + return nil + } + return errors.New( + "workspace: removal acknowledgement record is missing", + ) } record := newWorkspaceRecord(removal.Workspace, nil) record.Removal = &workspaceRemovalRecord{ @@ -237,6 +246,7 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { if err := m.removeAcknowledgedWorkspaceRecord(removal); err != nil { return err } + m.acknowledgedRemovals[receipt] = struct{}{} return nil } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 51d197c..aea8351 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -59,13 +59,19 @@ type Workspace struct { // Manager owns worktrees below one Drove data directory. type Manager struct { - root string - git string - dataDirInfo os.FileInfo - worktreeInfo os.FileInfo - bucketInfo map[string]os.FileInfo - rootErr error - mu *sync.Mutex + root string + git string + dataDirInfo os.FileInfo + worktreeInfo os.FileInfo + bucketInfo map[string]os.FileInfo + acknowledgedRemovals map[removalAcknowledgementReceipt]struct{} + rootErr error + mu *sync.Mutex +} + +type removalAcknowledgementReceipt struct { + agentID string + operationID string } // New creates a worktree manager without changing the filesystem. @@ -120,13 +126,14 @@ func New(dataDir string) (*Manager, error) { rootErr = fmt.Errorf("workspace: inspect worktree root: %w", err) } manager := &Manager{ - root: rootPath, - git: "git", - dataDirInfo: dataDirInfo, - worktreeInfo: worktreeInfo, - bucketInfo: bucketInfo, - rootErr: rootErr, - mu: workspaceManagerLock(rootPath), + root: rootPath, + git: "git", + dataDirInfo: dataDirInfo, + worktreeInfo: worktreeInfo, + bucketInfo: bucketInfo, + acknowledgedRemovals: make(map[removalAcknowledgementReceipt]struct{}), + rootErr: rootErr, + mu: workspaceManagerLock(rootPath), } if worktreeInfo == nil || rootErr != nil { return manager, nil From 99dda0611225f7e58ad51b45491002dcc980048b Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 00:28:09 +0800 Subject: [PATCH 016/104] fix: bind workspace preparation to opened roots --- internal/workspace/preparation.go | 5 + internal/workspace/preparation_test.go | 4 +- internal/workspace/prepared_worktree.go | 190 +++++++++++++++- internal/workspace/record.go | 13 ++ internal/workspace/repository_capability.go | 190 +++++++++++++++- internal/workspace/rooted_git_linux.go | 43 ++++ internal/workspace/rooted_git_other.go | 15 ++ internal/workspace/rooted_git_unix.go | 57 +++++ internal/workspace/rooted_git_windows.go | 38 ++++ internal/workspace/source_swap_test.go | 234 +++++++++++++++++++- internal/workspace/workspace.go | 52 +++-- internal/workspace/workspace_test.go | 3 + 12 files changed, 796 insertions(+), 48 deletions(-) diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index de68ffb..8c735ad 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -277,6 +277,11 @@ func sameWorkspace(left Workspace, right Workspace) bool { left.directoryIdentity != right.directoryIdentity { return false } + if left.expectedHeadOID != "" && + right.expectedHeadOID != "" && + left.expectedHeadOID != right.expectedHeadOID { + return false + } return left.AgentID == right.AgentID && left.Repository == right.Repository && left.Path == right.Path && diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 763679e..1cadc32 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -341,7 +341,7 @@ func TestReconcilePreparationsDiscardsGitSuffixedWorktreeAfterRestart( t *testing.T, ) { repository := newTestRepository(t) - stalePath := filepath.Join(t.TempDir(), testAgentID) + stalePath := filepath.Join(t.TempDir(), "-") runGit( t, repository, @@ -371,7 +371,7 @@ func TestReconcilePreparationsDiscardsGitSuffixedWorktreeAfterRestart( t.Fatalf("prepare workspace: %v", err) } gitDirectoryName := filepath.Base(prepared.gitDirectory) - suffix := strings.TrimPrefix(gitDirectoryName, testAgentID) + suffix := strings.TrimPrefix(gitDirectoryName, "-") if suffix == "" || strings.Trim(suffix, "0123456789") != "" { t.Fatalf( "prepared Git directory %q has no collision suffix", diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index dfe6850..2d05375 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/google/uuid" ) @@ -191,6 +192,7 @@ func (m *Manager) initializePreparedWorktree( ctx context.Context, target *Workspace, prepared *preparedWorktreeTarget, + repository repositoryCapability, ) (result error) { root := prepared.root directoryIdentity, err := openedDirectoryIdentity(root) @@ -210,6 +212,21 @@ func (m *Manager) initializePreparedWorktree( "workspace: prepared worktree target identity changed", ) } + registered, exists, err := repository.worktreeRegistration( + ctx, + prepared.path, + ) + if err != nil { + return err + } + if !exists || + !sameRegisteredWorktreePath(registered.path, prepared.path) || + !registered.detached || + registered.head != target.expectedHeadOID { + return errors.New( + "workspace: detached worktree registration does not match intent", + ) + } gitDirectory, err := m.worktreeGitDirectoryAtRoot( ctx, prepared.path, @@ -221,6 +238,117 @@ func (m *Manager) initializePreparedWorktree( if err := verifyRealPathRoot(prepared.path, root); err != nil { return err } + if _, err := repository.boundGitDirectory( + gitDirectory, + target.AgentID, + ); err != nil { + return err + } + gitRoot, err := openRealPathRoot(gitDirectory) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + gitGuard, err := openRepositoryGuard(gitDirectory, gitRoot) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, gitGuard.Close()) + }() + privateRepository := repository.withPrivateGitBinding( + gitDirectory, + gitRoot, + ) + privateHead, err := privateRepository.runPrivateGit( + ctx, + "", + "rev-parse", + "--verify", + "HEAD", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared private HEAD: %w", + err, + ) + } + if strings.TrimSpace(string(privateHead)) != target.expectedHeadOID { + return errors.New( + "workspace: prepared private HEAD does not match intent", + ) + } + if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { + return err + } + if _, err := privateRepository.runWorktreeAt( + ctx, + prepared.path, + root, + "", + "symbolic-ref", + "HEAD", + "refs/heads/"+target.Branch, + ); err != nil { + return fmt.Errorf("workspace: attach prepared worktree branch: %w", err) + } + if _, err := privateRepository.runWorktreeAt( + ctx, + prepared.path, + root, + "", + "read-tree", + "--reset", + "-u", + target.expectedHeadOID, + ); err != nil { + return fmt.Errorf("workspace: checkout prepared worktree: %w", err) + } + if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { + return err + } + registered, exists, err = repository.worktreeRegistration( + ctx, + prepared.path, + ) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: prepared worktree registration disappeared", + ) + } + preparedIntent := *target + preparedIntent.Path = prepared.path + if err := privateRepository.verifyBoundPreparedWorktree( + ctx, + preparedIntent, + registered, + ); err != nil { + return err + } + confirmedGitDirectory, err := m.worktreeGitDirectoryAtRoot( + ctx, + prepared.path, + root, + ) + if err != nil { + return err + } + if confirmedGitDirectory != gitDirectory { + return errors.New( + "workspace: prepared worktree Git pointer changed", + ) + } + if err := verifyRealPathRoot(gitDirectory, gitRoot); err != nil { + return err + } target.gitDirectory = gitDirectory record, exists, err := m.readWorkspaceRecord(target.Path) @@ -241,15 +369,6 @@ func (m *Manager) initializePreparedWorktree( if err != nil { return fmt.Errorf("workspace: persist worktree identity: %w", err) } - if _, err := m.runRootedGit( - ctx, - prepared.path, - root, - "reset", - "--hard", - ); err != nil { - return fmt.Errorf("workspace: checkout prepared worktree: %w", err) - } return nil } @@ -521,6 +640,42 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( "workspace: prepared worktree Git directory changed", ) } + if target.expectedHeadOID == "" || + target.expectedHeadOID != record.ExpectedHeadOID { + return errors.New( + "workspace: prepared worktree expected HEAD changed", + ) + } + if _, err := repository.boundGitDirectory( + record.GitDirectory, + target.AgentID, + ); err != nil { + return err + } + gitRoot, err := openRealPathRoot(record.GitDirectory) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + gitGuard, err := openRepositoryGuard(record.GitDirectory, gitRoot) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, gitGuard.Close()) + }() + if err := repository.verifyPreparationRefs( + ctx, + target, + !record.PreparationCommitted, + ); err != nil { + return err + } registered, exists, err := repository.worktreeRegistration( ctx, target.Path, @@ -533,10 +688,13 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( "workspace: prepared worktree registration disappeared", ) } - if err := repository.verifyPreparedWorktree( + privateRepository := repository.withPrivateGitBinding( + record.GitDirectory, + gitRoot, + ) + if err := privateRepository.verifyBoundPreparedWorktree( ctx, target, - record.GitDirectory, registered, ); err != nil { return err @@ -557,6 +715,16 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( ); err != nil { return err } + if err := verifyRealPathRoot(record.GitDirectory, gitRoot); err != nil { + return err + } + if err := repository.verifyPreparationRefs( + ctx, + target, + !record.PreparationCommitted, + ); err != nil { + return err + } return repository.verifyBinding(ctx) } diff --git a/internal/workspace/record.go b/internal/workspace/record.go index db5be8f..530a556 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -35,6 +35,7 @@ type workspaceRecord struct { PreparationCommitted bool `json:"preparation_committed"` CreatedBranch bool `json:"created_branch"` BranchOperationID string `json:"branch_operation_id,omitempty"` + ExpectedHeadOID string `json:"expected_head_oid,omitempty"` GitDirectory string `json:"git_directory,omitempty"` DirectoryIdentity string `json:"directory_identity,omitempty"` RepositoryEvidence *repositoryEvidence `json:"repository_evidence,omitempty"` @@ -90,6 +91,7 @@ func newWorkspaceRecord(target Workspace, includedPaths []string) workspaceRecor IncludedPaths: append([]string{}, includedPaths...), CreatedBranch: target.createdBranch, BranchOperationID: target.branchOperationID, + ExpectedHeadOID: target.expectedHeadOID, GitDirectory: target.gitDirectory, DirectoryIdentity: target.directoryIdentity, RepositoryEvidence: cloneRepositoryEvidence( @@ -106,6 +108,7 @@ func (r workspaceRecord) workspace() Workspace { Branch: r.Branch, createdBranch: r.CreatedBranch, branchOperationID: r.BranchOperationID, + expectedHeadOID: r.ExpectedHeadOID, gitDirectory: r.GitDirectory, directoryIdentity: r.DirectoryIdentity, repositoryEvidence: cloneRepositoryEvidence( @@ -130,6 +133,7 @@ func upgradeWorkspaceRecord(record *workspaceRecord) { } record.Version = workspaceRecordVersion record.RepositoryEvidence = nil + record.ExpectedHeadOID = "" if record.PreparationCommitted { record.BranchOperationID = "" } @@ -505,6 +509,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.PreparationCommitted || record.CreatedBranch || record.BranchOperationID != "" || + record.ExpectedHeadOID != "" || record.GitDirectory != "" || record.DirectoryIdentity != "" || record.RepositoryEvidence != nil || @@ -525,6 +530,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( if record.PreparationCommitted || record.CreatedBranch || record.BranchOperationID != "" || + record.ExpectedHeadOID != "" || record.GitDirectory != "" || record.DirectoryIdentity != "" || record.RepositoryEvidence != nil { @@ -662,6 +668,13 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { ) } } + if record.ExpectedHeadOID != "" && + (len(record.ExpectedHeadOID) > 128 || + strings.ContainsAny(record.ExpectedHeadOID, " \t\r\n")) { + return errors.New( + "workspace: expected HEAD object ID is invalid", + ) + } if record.GitDirectory != "" && (!filepath.IsAbs(record.GitDirectory) || filepath.Clean(record.GitDirectory) != record.GitDirectory) { diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index d16aefa..d7f5b42 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -59,6 +59,15 @@ func (r repositoryCapability) withRepositoryBinding( return r } +func (r repositoryCapability) withPrivateGitBinding( + gitPath string, + gitRoot *os.Root, +) repositoryCapability { + r.gitPath = gitPath + r.gitRoot = gitRoot + return r +} + func (r repositoryCapability) verifyBinding(ctx context.Context) error { if r.root == nil || r.gitRoot == nil || @@ -158,6 +167,44 @@ func (r repositoryCapability) runForward( return r.run(ctx, input, arguments...) } +func (r repositoryCapability) addPreparedWorktree( + ctx context.Context, + path string, + root *os.Root, + expectedHeadOID string, +) ([]byte, error) { + if err := r.verifyBinding(ctx); err != nil { + return nil, err + } + command, cleanup, err := rootedPreparedWorktreeGitCommand( + ctx, + r.manager.git, + r.path, + r.commonPath, + r.commonRoot, + path, + root, + []string{ + "worktree", + "add", + "--quiet", + "--detach", + "--no-checkout", + ".", + expectedHeadOID, + }, + ) + if err != nil { + return nil, err + } + output, commandErr := runGitCommand(command, "") + verifyErr := errors.Join( + r.verifyBinding(ctx), + verifyRealPathRoot(path, root), + ) + return output, errors.Join(commandErr, verifyErr, cleanup()) +} + func (r repositoryCapability) runPrivateGit( ctx context.Context, input string, @@ -171,6 +218,26 @@ func (r repositoryCapability) runPrivateGit( return output, errors.Join(commandErr, cleanup()) } +func (r repositoryCapability) runWorktreeAt( + ctx context.Context, + worktreePath string, + worktreeRoot *os.Root, + input string, + arguments ...string, +) ([]byte, error) { + command, cleanup, err := r.worktreeCommandAt( + ctx, + worktreePath, + worktreeRoot, + arguments..., + ) + if err != nil { + return nil, err + } + output, commandErr := runGitCommand(command, input) + return output, errors.Join(commandErr, cleanup()) +} + func (r repositoryCapability) repositoryDirectories( ctx context.Context, ) (string, string, error) { @@ -283,13 +350,22 @@ func (r repositoryCapability) command( func (r repositoryCapability) worktreeCommand( ctx context.Context, arguments ...string, +) (*exec.Cmd, func() error, error) { + return r.worktreeCommandAt(ctx, r.path, r.root, arguments...) +} + +func (r repositoryCapability) worktreeCommandAt( + ctx context.Context, + worktreePath string, + worktreeRoot *os.Root, + arguments ...string, ) (*exec.Cmd, func() error, error) { if r.root != nil && r.gitRoot != nil { return rootedWorktreeGitCommand( ctx, r.manager.git, - r.path, - r.root, + worktreePath, + worktreeRoot, r.gitPath, r.gitRoot, arguments, @@ -458,13 +534,6 @@ func (r repositoryCapability) verifyPreparedWorktree( gitDirectory string, registered registeredWorktree, ) error { - if err := r.verifyPreparedWorktreeRegistration( - target, - registered, - "", - ); err != nil { - return err - } gitDirectory, err := r.boundGitDirectory( gitDirectory, target.AgentID, @@ -472,6 +541,13 @@ func (r repositoryCapability) verifyPreparedWorktree( if err != nil { return err } + if err := r.verifyPreparedWorktreeRegistration( + target, + registered, + target.expectedHeadOID, + ); err != nil { + return err + } head, err := r.run( ctx, "", @@ -513,6 +589,91 @@ func (r repositoryCapability) verifyPreparedWorktree( return nil } +func (r repositoryCapability) verifyBoundPreparedWorktree( + ctx context.Context, + target Workspace, + registered registeredWorktree, +) error { + if err := r.verifyPreparedWorktreeRegistration( + target, + registered, + target.expectedHeadOID, + ); err != nil { + return err + } + head, err := r.runPrivateGit(ctx, "", "rev-parse", "--verify", "HEAD") + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared worktree HEAD: %w", + err, + ) + } + if strings.TrimSpace(string(head)) != registered.head { + return errors.New( + "workspace: prepared worktree HEAD does not match registration", + ) + } + branch, err := r.runPrivateGit( + ctx, + "", + "symbolic-ref", + "--quiet", + "HEAD", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared worktree branch: %w", + err, + ) + } + if strings.TrimSpace(string(branch)) != registered.branch { + return errors.New( + "workspace: prepared worktree branch does not match registration", + ) + } + return nil +} + +func (r repositoryCapability) verifyPreparationRefs( + ctx context.Context, + target Workspace, + requireOwnershipMarker bool, +) error { + if target.expectedHeadOID == "" { + return errors.New( + "workspace: prepared worktree expected HEAD is unavailable", + ) + } + branchOID, exists, err := r.refOID( + ctx, + "refs/heads/"+target.Branch, + ) + if err != nil { + return err + } + if !exists || branchOID != target.expectedHeadOID { + return errors.New( + "workspace: prepared branch moved from its expected HEAD", + ) + } + if !target.createdBranch || !requireOwnershipMarker { + return nil + } + markerOID, exists, err := r.refOID( + ctx, + branchOwnershipRef(target.branchOperationID), + ) + if err != nil { + return err + } + if !exists || markerOID != target.expectedHeadOID { + return errors.New( + "workspace: prepared branch ownership marker changed", + ) + } + return nil +} + func (repositoryCapability) verifyPreparedWorktreeRegistration( target Workspace, registered registeredWorktree, @@ -559,12 +720,19 @@ func (r repositoryCapability) boundGitDirectory( if strings.HasPrefix(name, agentID) && len(name) > len(agentID) { validName = strings.Trim(name[len(agentID):], "0123456789") == "" } + if name == "-" || + (strings.HasPrefix(name, "-") && + strings.Trim(name[1:], "0123456789") == "") { + validName = true + } if err != nil || !filepath.IsAbs(path) || filepath.Dir(name) != "." || !validName { - return "", errors.New( - "workspace: prepared Git directory does not match the managed worktree", + return "", fmt.Errorf( + "workspace: prepared Git directory %q does not match agent %q", + path, + agentID, ) } return filepath.Join("worktrees", name), nil diff --git a/internal/workspace/rooted_git_linux.go b/internal/workspace/rooted_git_linux.go index 51ad9fd..6c072db 100644 --- a/internal/workspace/rooted_git_linux.go +++ b/internal/workspace/rooted_git_linux.go @@ -131,3 +131,46 @@ func rootedWorktreeGitCommand( } return command, cleanup, nil } + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + commonPath string, + commonRoot *os.Root, + _ string, + worktreeRoot *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + worktree, err := worktreeRoot.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open prepared worktree target: %w", + err, + ) + } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + _ = worktree.Close() + return nil, nil, err + } + command := exec.CommandContext(ctx, git) + command.ExtraFiles = []*os.File{worktree} + command.Env = boundGitEnvironment( + command.Environ(), + commonPath, + repositoryPath, + ) + command.Args = append( + command.Args, + "-c", + "core.hooksPath=/dev/null", + "-C", + "/proc/self/fd/3", + ) + command.Args = append(command.Args, arguments...) + cleanup := func() error { + return errors.Join(commonGuard.Close(), worktree.Close()) + } + return command, cleanup, nil +} diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 0817629..18e310e 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -49,3 +49,18 @@ func rootedWorktreeGitCommand( "workspace: rooted worktree Git commands are unsupported on this platform", ) } + +func rootedPreparedWorktreeGitCommand( + _ context.Context, + _ string, + _ string, + _ string, + _ *os.Root, + _ string, + _ *os.Root, + _ []string, +) (*exec.Cmd, func() error, error) { + return nil, nil, errors.New( + "workspace: rooted prepared worktree Git commands are unsupported on this platform", + ) +} diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index 5625592..afa63dd 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -189,3 +189,60 @@ func rootedWorktreeGitCommand( } return command, cleanup, nil } + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + commonPath string, + commonRoot *os.Root, + _ string, + worktreeRoot *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + directory, err := worktreeRoot.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open prepared worktree target: %w", + err, + ) + } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + _ = directory.Close() + return nil, nil, err + } + executable, err := os.Executable() + if err != nil { + _ = commonGuard.Close() + _ = directory.Close() + return nil, nil, fmt.Errorf( + "workspace: locate rooted Git helper: %w", + err, + ) + } + git, err = exec.LookPath(git) + if err != nil { + _ = commonGuard.Close() + _ = directory.Close() + return nil, nil, err + } + helperArguments := []string{ + rootedGitHelperArgument, + git, + "-c", + "core.hooksPath=/dev/null", + } + helperArguments = append(helperArguments, arguments...) + command := exec.CommandContext(ctx, executable, helperArguments...) + command.ExtraFiles = []*os.File{directory} + command.Env = boundGitEnvironment( + command.Environ(), + commonPath, + repositoryPath, + ) + cleanup := func() error { + return errors.Join(commonGuard.Close(), directory.Close()) + } + return command, cleanup, nil +} diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 40be6e7..4e7bf24 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -101,3 +101,41 @@ func rootedWorktreeGitCommand( } return command, cleanup, nil } + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + commonPath string, + commonRoot *os.Root, + worktreePath string, + worktreeRoot *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + worktreeGuard, err := openRepositoryGuard(worktreePath, worktreeRoot) + if err != nil { + return nil, nil, err + } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join(err, worktreeGuard.Close()) + } + command := exec.CommandContext( + ctx, + git, + append( + []string{"-c", "core.hooksPath=NUL"}, + arguments..., + )..., + ) + command.Dir = worktreePath + command.Env = boundGitEnvironment( + command.Environ(), + commonPath, + repositoryPath, + ) + cleanup := func() error { + return errors.Join(commonGuard.Close(), worktreeGuard.Close()) + } + return command, cleanup, nil +} diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index a54d2b6..3bbc55e 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -259,7 +259,7 @@ next_target= for argument in "$@"; do if [ -n "$next_target" ]; then test -d "$argument" || exit 98 - printf '%s' "$argument" > "$DROVE_TEST_INVOKED" + printf '%s' "$PWD" > "$DROVE_TEST_INVOKED" exit 97 fi [ "$argument" = "--no-checkout" ] && next_target=1 @@ -329,7 +329,7 @@ target= next_target= for argument in "$@"; do if [ -n "$next_target" ]; then - target=$argument + target=$PWD next_target= fi [ "$argument" = "--no-checkout" ] && next_target=1 @@ -410,6 +410,64 @@ exit "$status" assertFileContents(t, sentinel, "replacement\n") } +func TestPrepareDoesNotWriteThroughReplacedStagingPath(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + outside := filepath.Join(parent, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside target: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-replace-staging") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +if [ -n "$target" ]; then + target=$PWD + mv "$target" "$target-opened" || exit 91 + ln -s "$DROVE_TEST_OUTSIDE" "$target" || exit 92 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_OUTSIDE", outside) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced staging path") + } + if _, err := os.Lstat(filepath.Join(outside, ".git")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("Git wrote through the replaced staging path: %v", err) + } +} + func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { parent := t.TempDir() source := filepath.Join(parent, "source") @@ -615,6 +673,87 @@ exit "$status" } } +func TestPrepareRejectsCreatedBranchMovedBeforeWorktreeAdd(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + startOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + if err := os.WriteFile( + filepath.Join(source, "replacement.txt"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement commit: %v", err) + } + runGit(t, source, "add", "replacement.txt") + runGit(t, source, "commit", "-m", "replacement") + replacementOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + runGit(t, source, "reset", "--hard", startOID) + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-move-created-branch") + script := `#!/bin/sh +matched= +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then + matched=1 + break + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +if [ -n "$matched" ]; then + "$DROVE_TEST_REAL_GIT" --git-dir="$DROVE_TEST_GIT_DIR" update-ref \ + "$DROVE_TEST_BRANCH_REF" "$DROVE_TEST_REPLACEMENT_OID" \ + "$DROVE_TEST_START_OID" || exit 96 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + branch := "drove/" + testAgentID + t.Setenv("DROVE_TEST_GIT_DIR", filepath.Join(source, ".git")) + t.Setenv("DROVE_TEST_BRANCH_REF", "refs/heads/"+branch) + t.Setenv("DROVE_TEST_REPLACEMENT_OID", replacementOID) + t.Setenv("DROVE_TEST_START_OID", startOID) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a moved created branch") + } + if got := strings.TrimSpace(runGit( + t, + source, + "rev-parse", + "refs/heads/"+branch, + )); got != replacementOID { + t.Fatalf("moved branch OID = %q, want %q", got, replacementOID) + } + if markers := strings.TrimSpace(runGit( + t, + source, + "for-each-ref", + "--format=%(refname)", + branchOwnershipRefPrefix, + )); markers != "" { + t.Fatalf("branch ownership marker remains: %q", markers) + } +} + func TestDiscardUsesRetainedRepositoryAfterPrepareSourceSwap( t *testing.T, ) { @@ -2051,6 +2190,97 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestPrepareDoesNotCheckoutThroughReplacedWorktreeGitPointer( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + replacement := filepath.Join(parent, "replacement-worktree") + runGit( + t, + source, + "worktree", + "add", + "-b", + "replacement-worktree", + replacement, + ) + + sentinel := filepath.Join(parent, "filter-ran") + filter := filepath.Join(parent, "filter") + script := "#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n" + if err := os.WriteFile(filter, []byte(script), 0o700); err != nil { + t.Fatalf("write filter: %v", err) + } + runGit(t, source, "config", "filter.review.smudge", filter) + runGit(t, source, "config", "filter.review.clean", "cat") + runGit(t, source, "config", "filter.review.required", "true") + if err := os.WriteFile( + filepath.Join(replacement, ".gitattributes"), + []byte("replacement.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement attributes: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, "replacement.txt"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement file: %v", err) + } + runGit(t, replacement, "add", ".gitattributes", "replacement.txt") + runGit(t, replacement, "commit", "-m", "replacement worktree") + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-swap-worktree-pointer") + wrapperScript := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + mv "$target/.git" "$target/.git-original" || exit 91 + cp "$DROVE_TEST_REPLACEMENT/.git" "$target/.git" || exit 92 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SENTINEL", sentinel) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced worktree Git pointer") + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement worktree checkout filter ran: %v", err) + } +} + func postSuccessSwapGitWrapper( t *testing.T, parent string, diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index aea8351..ca62529 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -49,6 +49,7 @@ type Workspace struct { createdBranch bool branchOperationID string + expectedHeadOID string gitDirectory string directoryIdentity string protectionKnown bool @@ -277,6 +278,22 @@ func (m *Manager) prepare( return Workspace{}, err } createdBranch := !exists + expectedHeadOID := sourceRepository.startOID + if exists { + var branchExists bool + expectedHeadOID, branchExists, err = sourceRepository.refOID( + ctx, + "refs/heads/"+branch, + ) + if err != nil { + return Workspace{}, err + } + if !branchExists { + return Workspace{}, errors.New( + "workspace: branch disappeared while preparing", + ) + } + } branchOperationID := uuid.NewString() result := Workspace{ AgentID: agentID, @@ -284,6 +301,7 @@ func (m *Manager) prepare( Path: path, Branch: branch, branchOperationID: branchOperationID, + expectedHeadOID: expectedHeadOID, repositoryEvidence: cloneRepositoryEvidence( &lease.evidence, ), @@ -396,18 +414,11 @@ func (m *Manager) prepare( ) } } - arguments := []string{ - "worktree", - "add", - "--quiet", - "--no-checkout", - preparedTarget.path, - branch, - } - if _, err := sourceRepository.runForward( + if _, err := sourceRepository.addPreparedWorktree( ctx, - "", - arguments..., + preparedTarget.path, + preparedTarget.root, + expectedHeadOID, ); err != nil { return Workspace{}, fmt.Errorf("workspace: create worktree: %w", err) } @@ -416,6 +427,7 @@ func (m *Manager) prepare( ctx, &result, preparedTarget, + sourceRepository, ); err != nil { return Workspace{}, err } @@ -661,8 +673,8 @@ func (m *Manager) discard( target Workspace, ) (result error) { lease := target.preparation - temporaryLease := false - if lease == nil { + liveLease := lease != nil && !lease.isClosed() + if !liveLease { record, exists, err := m.readWorkspaceRecord(target.Path) if err != nil { return err @@ -681,23 +693,19 @@ func (m *Manager) discard( if err != nil { return err } - temporaryLease = true - defer func() { - result = errors.Join(result, lease.Close()) - }() } + defer func() { + result = errors.Join(result, lease.Close()) + }() if err := m.discardWithRepository( ctx, target, lease.repository, - target.preparation != nil, + liveLease, ); err != nil { return err } - if temporaryLease { - return nil - } - return lease.Close() + return nil } func (m *Manager) discardWithRepository( diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 8cf2591..64cfb64 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -665,6 +665,9 @@ func TestDiscardPreservesWorkspaceWhenRegistrationCheckFails(t *testing.T) { if err := manager.Discard(ctx, prepared); err == nil { t.Fatal("discard succeeded with a canceled registration check") } + if !prepared.preparation.isClosed() { + t.Fatal("failed discard retained its preparation lease") + } if _, err := os.Lstat(prepared.Path); err != nil { t.Fatalf("failed discard removed worktree: %v", err) } From 27c630d59505f5e6a51e300fdd8a5eb003c3c131 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 00:46:23 +0800 Subject: [PATCH 017/104] fix: bind workspace path mutations to opened identities --- .../workspace/directory_rename_bound_unix.go | 101 ++++++++++++++++++ internal/workspace/directory_rename_darwin.go | 24 +++-- internal/workspace/directory_rename_linux.go | 24 +++-- .../directory_rename_linux_darwin_test.go | 60 +++++++++++ internal/workspace/include.go | 14 ++- internal/workspace/include_manifest_other.go | 8 +- internal/workspace/include_manifest_unix.go | 10 +- .../workspace/include_manifest_windows.go | 61 ++++++++++- .../include_manifest_windows_test.go | 41 +++++++ internal/workspace/record.go | 78 +++++++++++++- .../workspace/record_removal_security_test.go | 67 ++++++++++++ internal/workspace/record_rename_unix.go | 63 ++++++++++- internal/workspace/record_rename_unix_test.go | 46 ++++++++ 13 files changed, 561 insertions(+), 36 deletions(-) create mode 100644 internal/workspace/directory_rename_bound_unix.go create mode 100644 internal/workspace/directory_rename_linux_darwin_test.go create mode 100644 internal/workspace/include_manifest_windows_test.go create mode 100644 internal/workspace/record_removal_security_test.go diff --git a/internal/workspace/directory_rename_bound_unix.go b/internal/workspace/directory_rename_bound_unix.go new file mode 100644 index 0000000..325a800 --- /dev/null +++ b/internal/workspace/directory_rename_bound_unix.go @@ -0,0 +1,101 @@ +//go:build darwin || linux + +package workspace + +import ( + "errors" + "fmt" + "os" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +type directoryRenameNoReplace func( + directoryFD int, + sourceName string, + targetName string, +) error + +func renameBoundDirectoryNoReplace( + directory *os.File, + expected os.FileInfo, + sourceName string, + targetName string, + rename directoryRenameNoReplace, +) (bool, error) { + fd := int(directory.Fd()) + isolatedName := ".drove-directory-" + uuid.NewString() + if err := rename(fd, sourceName, isolatedName); err != nil { + return false, err + } + restore := func() error { + if err := rename(fd, isolatedName, sourceName); err != nil { + return fmt.Errorf( + "restore isolated directory %q: %w", + sourceName, + err, + ) + } + return nil + } + isolated, err := statDirectoryAt(directory, isolatedName) + if err != nil { + return false, errors.Join( + fmt.Errorf( + "inspect isolated directory %q: %w", + sourceName, + err, + ), + restore(), + ) + } + if !isolated.IsDir() || !os.SameFile(expected, isolated) { + return false, errors.Join( + fmt.Errorf("directory %q changed before rename", sourceName), + restore(), + ) + } + if err := rename(fd, isolatedName, targetName); err != nil { + return false, errors.Join(err, restore()) + } + target, err := statDirectoryAt(directory, targetName) + if err != nil { + return true, fmt.Errorf( + "inspect renamed directory %q: %w", + targetName, + err, + ) + } + if !target.IsDir() || !os.SameFile(expected, target) { + return true, fmt.Errorf( + "renamed directory %q changed identity", + targetName, + ) + } + return true, nil +} + +func statDirectoryAt( + directory *os.File, + name string, +) (_ os.FileInfo, result error) { + fd, err := unix.Openat( + int(directory.Fd()), + name, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, + 0, + ) + if err != nil { + return nil, err + } + opened := os.NewFile(uintptr(fd), name) + if opened == nil { + _ = unix.Close(fd) + return nil, errors.New("wrap opened directory") + } + defer func() { + result = errors.Join(result, opened.Close()) + }() + return opened.Stat() +} diff --git a/internal/workspace/directory_rename_darwin.go b/internal/workspace/directory_rename_darwin.go index 16a936d..0e96543 100644 --- a/internal/workspace/directory_rename_darwin.go +++ b/internal/workspace/directory_rename_darwin.go @@ -10,19 +10,23 @@ import ( func renameDirectoryNoReplace( directory *os.File, - _ os.FileInfo, + expected os.FileInfo, sourceName string, targetName string, ) (bool, error) { - fd := int(directory.Fd()) - if err := unix.RenameatxNp( - fd, + return renameBoundDirectoryNoReplace( + directory, + expected, sourceName, - fd, targetName, - unix.RENAME_EXCL, - ); err != nil { - return false, err - } - return true, nil + func(fd int, source string, target string) error { + return unix.RenameatxNp( + fd, + source, + fd, + target, + unix.RENAME_EXCL, + ) + }, + ) } diff --git a/internal/workspace/directory_rename_linux.go b/internal/workspace/directory_rename_linux.go index 495759e..662ab21 100644 --- a/internal/workspace/directory_rename_linux.go +++ b/internal/workspace/directory_rename_linux.go @@ -10,19 +10,23 @@ import ( func renameDirectoryNoReplace( directory *os.File, - _ os.FileInfo, + expected os.FileInfo, sourceName string, targetName string, ) (bool, error) { - fd := int(directory.Fd()) - if err := unix.Renameat2( - fd, + return renameBoundDirectoryNoReplace( + directory, + expected, sourceName, - fd, targetName, - unix.RENAME_NOREPLACE, - ); err != nil { - return false, err - } - return true, nil + func(fd int, source string, target string) error { + return unix.Renameat2( + fd, + source, + fd, + target, + unix.RENAME_NOREPLACE, + ) + }, + ) } diff --git a/internal/workspace/directory_rename_linux_darwin_test.go b/internal/workspace/directory_rename_linux_darwin_test.go new file mode 100644 index 0000000..cc2cd55 --- /dev/null +++ b/internal/workspace/directory_rename_linux_darwin_test.go @@ -0,0 +1,60 @@ +//go:build darwin || linux + +package workspace + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRenameDirectoryNoReplaceRejectsReplacementSource(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("create source directory: %v", err) + } + expected, err := os.Lstat(sourcePath) + if err != nil { + t.Fatalf("inspect source directory: %v", err) + } + originalPath := filepath.Join(rootPath, "source-original") + if err := os.Rename(sourcePath, originalPath); err != nil { + t.Fatalf("move expected source directory: %v", err) + } + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("create replacement source directory: %v", err) + } + sentinel := filepath.Join(sourcePath, "replacement") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent directory: %v", err) + } + defer directory.Close() + + moved, err := renameDirectoryNoReplace( + directory, + expected, + "source", + "target", + ) + if err == nil || moved { + t.Fatalf("rename replacement = moved %v, err=%v", moved, err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("replacement source was not restored: %v", err) + } + if _, err := os.Stat(originalPath); err != nil { + t.Fatalf("expected source changed: %v", err) + } + if _, err := os.Lstat(filepath.Join(rootPath, "target")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("target exists after rejected replacement: %v", err) + } +} diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 6a56064..8f868f0 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -425,14 +425,18 @@ func (m *Manager) runIncludeManifest( defer func() { commandResult = errors.Join(commandResult, cleanup()) }() - excludePath, unlinkBeforeRun, err := configureIncludeManifestCommand( - command, - reader, - manifestPath, - ) + excludePath, unlinkBeforeRun, cleanupManifest, err := + configureIncludeManifestCommand( + command, + reader, + manifestPath, + ) if err != nil { return nil, err } + defer func() { + commandResult = errors.Join(commandResult, cleanupManifest()) + }() if unlinkBeforeRun && name != "" { if err := root.Remove(name); err != nil { return nil, fmt.Errorf( diff --git a/internal/workspace/include_manifest_other.go b/internal/workspace/include_manifest_other.go index a745e53..f7452d9 100644 --- a/internal/workspace/include_manifest_other.go +++ b/internal/workspace/include_manifest_other.go @@ -11,6 +11,10 @@ func configureIncludeManifestCommand( _ *exec.Cmd, _ *os.File, path string, -) (string, bool, error) { - return path, false, nil +) (string, bool, func() error, error) { + return path, false, noCleanup, nil +} + +func noCleanup() error { + return nil } diff --git a/internal/workspace/include_manifest_unix.go b/internal/workspace/include_manifest_unix.go index fcdf982..9b25bc3 100644 --- a/internal/workspace/include_manifest_unix.go +++ b/internal/workspace/include_manifest_unix.go @@ -13,11 +13,15 @@ func configureIncludeManifestCommand( command *exec.Cmd, manifest *os.File, _ string, -) (string, bool, error) { +) (string, bool, func() error, error) { fd := 3 + len(command.ExtraFiles) command.ExtraFiles = append(command.ExtraFiles, manifest) if runtime.GOOS == "linux" { - return fmt.Sprintf("/proc/self/fd/%d", fd), true, nil + return fmt.Sprintf("/proc/self/fd/%d", fd), true, noCleanup, nil } - return fmt.Sprintf("/dev/fd/%d", fd), true, nil + return fmt.Sprintf("/dev/fd/%d", fd), true, noCleanup, nil +} + +func noCleanup() error { + return nil } diff --git a/internal/workspace/include_manifest_windows.go b/internal/workspace/include_manifest_windows.go index 6ae4e5d..ad42c6d 100644 --- a/internal/workspace/include_manifest_windows.go +++ b/internal/workspace/include_manifest_windows.go @@ -3,14 +3,69 @@ package workspace import ( + "errors" + "fmt" "os" "os/exec" + "path/filepath" + + "golang.org/x/sys/windows" ) func configureIncludeManifestCommand( _ *exec.Cmd, - _ *os.File, + manifest *os.File, path string, -) (string, bool, error) { - return path, false, nil +) (string, bool, func() error, error) { + expected, err := manifest.Stat() + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: inspect opened include manifest: %w", + err, + ) + } + name, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: encode include manifest path: %w", + err, + ) + } + handle, err := windows.CreateFile( + name, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: lock include manifest: %w", + err, + ) + } + guard := os.NewFile(uintptr(handle), path) + if guard == nil { + _ = windows.CloseHandle(handle) + return "", false, nil, errors.New( + "workspace: wrap include manifest guard", + ) + } + locked, err := guard.Stat() + if err != nil { + _ = guard.Close() + return "", false, nil, fmt.Errorf( + "workspace: inspect locked include manifest: %w", + err, + ) + } + if !locked.Mode().IsRegular() || !os.SameFile(expected, locked) { + _ = guard.Close() + return "", false, nil, errors.New( + "workspace: include manifest changed while locking", + ) + } + return path, false, guard.Close, nil } diff --git a/internal/workspace/include_manifest_windows_test.go b/internal/workspace/include_manifest_windows_test.go new file mode 100644 index 0000000..b8e0783 --- /dev/null +++ b/internal/workspace/include_manifest_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestConfigureIncludeManifestCommandLocksManifestIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest") + if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + manifest, err := os.Open(path) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer manifest.Close() + _, _, cleanup, err := configureIncludeManifestCommand( + exec.Command("cmd"), + manifest, + path, + ) + if err != nil { + t.Fatalf("configure manifest command: %v", err) + } + movedPath := path + ".moved" + if err := os.Rename(path, movedPath); err == nil { + _ = cleanup() + t.Fatal("manifest could be replaced while command guard was held") + } + if err := cleanup(); err != nil { + t.Fatalf("release manifest guard: %v", err) + } + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("rename manifest after releasing guard: %v", err) + } +} diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 530a556..7fc0ebe 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -771,6 +771,13 @@ func validDirectoryIdentity(identity string) bool { func (m *Manager) removeWorkspaceRecord( worktreePath string, +) error { + return m.removeWorkspaceRecordAfterValidation(worktreePath, nil) +} + +func (m *Manager) removeWorkspaceRecordAfterValidation( + worktreePath string, + afterValidation func(), ) (result error) { recordPath := workspaceRecordPath(worktreePath) bucket, agentID, err := m.openRecordBucket(worktreePath) @@ -801,8 +808,75 @@ func (m *Manager) removeWorkspaceRecord( recordPath, ) } - if err := bucket.Remove(name); err != nil { - return fmt.Errorf("workspace: remove record %q: %w", recordPath, err) + opened, err := bucket.Open(name) + if err != nil { + return fmt.Errorf("workspace: open record %q: %w", recordPath, err) + } + defer func() { + result = errors.Join(result, opened.Close()) + }() + openedInfo, err := opened.Stat() + if err != nil { + return fmt.Errorf( + "workspace: inspect opened record %q: %w", + recordPath, + err, + ) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return fmt.Errorf( + "workspace: record %q changed while opening", + recordPath, + ) + } + if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + return err + } + directory, err := openRecordDirectory(bucket) + if err != nil { + return fmt.Errorf( + "workspace: open record directory %q: %w", + recordPath, + err, + ) + } + isolatedName := ".drove-record-remove-" + uuid.NewString() + isolated, isolateErr := isolateRecordPathIfSame( + bucket, + directory, + name, + isolatedName, + opened, + afterValidation, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(isolateErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: isolate record %q: %w", + recordPath, + err, + ) + } + if !isolated { + return fmt.Errorf( + "workspace: record %q was not isolated", + recordPath, + ) + } + if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + return err + } + if err := removeRecordPathIfSame( + bucket, + isolatedName, + opened, + ); err != nil { + return fmt.Errorf( + "workspace: remove isolated record %q: %w", + recordPath, + err, + ) } return syncRecordBucket(bucket, recordPath) } diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go new file mode 100644 index 0000000..b775c9b --- /dev/null +++ b/internal/workspace/record_removal_security_test.go @@ -0,0 +1,67 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveWorkspaceRecordPreservesReplacementAfterValidation(t *testing.T) { + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + repository := filepath.Join(t.TempDir(), "repository") + if err := manager.ensureManagedRoot(); err != nil { + t.Fatalf("ensure managed root: %v", err) + } + if err := manager.ensureManagedBucket(repository); err != nil { + t.Fatalf("ensure managed bucket: %v", err) + } + target := Workspace{ + AgentID: testAgentID, + Repository: repository, + Path: filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ), + Branch: "record-removal", + } + if err := manager.writeWorkspaceRecord(target, nil); err != nil { + t.Fatalf("write workspace record: %v", err) + } + recordPath := workspaceRecordPath(target.Path) + original, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read workspace record: %v", err) + } + originalPath := recordPath + ".original" + + err = manager.removeWorkspaceRecordAfterValidation( + target.Path, + func() { + if err := os.Rename(recordPath, originalPath); err != nil { + t.Fatalf("move validated record: %v", err) + } + if err := os.WriteFile( + recordPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement record: %v", err) + } + }, + ) + if err == nil { + t.Fatal("record removal accepted a replacement sidecar") + } + assertFileContents(t, recordPath, "replacement\n") + gotOriginal, err := os.ReadFile(originalPath) + if err != nil { + t.Fatalf("read original sidecar: %v", err) + } + if string(gotOriginal) != string(original) { + t.Fatal("original sidecar changed") + } +} diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index c54633a..e86ba70 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -175,6 +175,20 @@ func unlinkRecordPath( directory *os.File, expected *os.File, name string, +) error { + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + nil, + ) +} + +func unlinkRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + afterValidation func(), ) error { if err := verifyRecordPathIdentity( directory, @@ -183,7 +197,54 @@ func unlinkRecordPath( ); err != nil { return err } - return unix.Unlinkat(int(directory.Fd()), name, 0) + if afterValidation != nil { + afterValidation() + } + fd := int(directory.Fd()) + isolatedName := ".drove-remove-" + uuid.NewString() + if err := unix.Renameat(fd, name, fd, isolatedName); err != nil { + return fmt.Errorf("isolate record path %q: %w", name, err) + } + restore := func() error { + if err := unix.Linkat( + fd, + isolatedName, + fd, + name, + 0, + ); err != nil { + return fmt.Errorf( + "restore isolated record path %q: %w", + name, + err, + ) + } + if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { + return fmt.Errorf( + "remove restored record isolation %q: %w", + isolatedName, + err, + ) + } + return nil + } + if err := verifyRecordPathIdentity( + directory, + expected, + isolatedName, + ); err != nil { + return errors.Join( + fmt.Errorf("verify isolated record path %q: %w", name, err), + restore(), + ) + } + if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { + return errors.Join( + fmt.Errorf("remove isolated record path %q: %w", name, err), + restore(), + ) + } + return nil } func verifyRecordPathIdentity( diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 46bcd3a..b1a0fda 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -66,3 +66,49 @@ func TestRenameRecordRejectsSourceReplacementAfterValidation(t *testing.T) { assertFileContents(t, sourcePath, "replacement\n") assertFileContents(t, movedPath, "original\n") } + +func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + source, err := os.OpenFile( + sourcePath, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + t.Fatalf("create source: %v", err) + } + defer source.Close() + if _, err := source.WriteString("original\n"); err != nil { + t.Fatalf("write source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + movedPath := filepath.Join(rootPath, "source-original") + + err = unlinkRecordPathAfterValidation( + directory, + source, + "source", + func() { + if err := os.Rename(sourcePath, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + sourcePath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("unlink accepted a replacement source") + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") +} From 89f66a0752a560d268049bb5141e44bafd2ae181 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 05:46:54 +0800 Subject: [PATCH 018/104] fix: close workspace recovery race windows --- internal/session/projection.go | 4 +- internal/session/projection_test.go | 62 ++++++++ .../workspace/directory_rename_bound_unix.go | 29 +++- internal/workspace/directory_rename_darwin.go | 2 + .../workspace/directory_rename_legacy_unix.go | 1 + internal/workspace/directory_rename_linux.go | 2 + .../directory_rename_linux_darwin_test.go | 1 + internal/workspace/directory_rename_other.go | 1 + .../workspace/directory_rename_windows.go | 1 + internal/workspace/include.go | 44 +++--- .../include_manifest_windows_test.go | 7 +- internal/workspace/include_security_test.go | 89 +++++++++++ internal/workspace/preparation_test.go | 74 +++++++++ internal/workspace/prepared_worktree.go | 141 ++++++++++++------ internal/workspace/record.go | 19 ++- .../workspace/record_rename_windows_test.go | 1 + internal/workspace/recovery_security_test.go | 116 ++++++++++++++ internal/workspace/removal.go | 125 +++++++++++++++- internal/workspace/repository_capability.go | 22 ++- internal/workspace/rooted_git.go | 12 ++ .../rooted_git_descriptor_unix_test.go | 133 +++++++++++++++++ internal/workspace/rooted_git_linux.go | 59 ++++++-- internal/workspace/rooted_git_other.go | 5 + internal/workspace/rooted_git_test.go | 20 +++ internal/workspace/rooted_git_unix.go | 23 ++- internal/workspace/rooted_git_windows.go | 34 ++++- internal/workspace/secure_remove.go | 87 +++++++++++ internal/workspace/source_swap_test.go | 56 +++++++ internal/workspace/workspace.go | 14 +- internal/workspace/workspace_test.go | 29 ++++ 30 files changed, 1109 insertions(+), 104 deletions(-) create mode 100644 internal/workspace/rooted_git_descriptor_unix_test.go diff --git a/internal/session/projection.go b/internal/session/projection.go index 5a8e22d..000ea61 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -722,8 +722,8 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) plan.VendorSessionRefs[draft.id] = draft.vendorSessionRef plan.ResumeOnStart[draft.id] = !draft.workspaceRemoved && - (state != agent.StateDone && - state != agent.StateStopped || + state != agent.StateDone && + (state != agent.StateStopped || draft.restartStopped) } workingDir := draft.workingDir diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 0d3379e..7c799a3 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -448,6 +448,68 @@ func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( } } +func TestRecoveryProjectorDoesNotResumeDoneStartupResume(t *testing.T) { + base := time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, Timestamp: base, Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, Timestamp: base.Add(time.Second), Type: string(event.TypeAgentSignal), + SessionID: "agent-1", AgentID: "agent-1", Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-06T01:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, Timestamp: base.Add(2 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "pending", To: "starting", + }, + { + Seq: 4, Timestamp: base.Add(3 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 5, Timestamp: base.Add(4 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: restartStopReason, + }, + { + Seq: 6, Timestamp: base.Add(5 * time.Second), Type: string(event.TypeAgentResumed), + SessionID: "agent-1", AgentID: "agent-1", Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + { + Seq: 7, Timestamp: base.Add(6 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "stopped", To: "starting", + }, + { + Seq: 8, Timestamp: base.Add(7 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "done", + }, + } + projector := newRecoveryProjector() + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if plan.ResumeOnStart["agent-1"] { + t.Fatal("completed startup resume remained eligible without completion marker") + } +} + func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( t *testing.T, ) { diff --git a/internal/workspace/directory_rename_bound_unix.go b/internal/workspace/directory_rename_bound_unix.go index 325a800..9405943 100644 --- a/internal/workspace/directory_rename_bound_unix.go +++ b/internal/workspace/directory_rename_bound_unix.go @@ -7,7 +7,6 @@ import ( "fmt" "os" - "github.com/google/uuid" "golang.org/x/sys/unix" ) @@ -21,11 +20,18 @@ func renameBoundDirectoryNoReplace( directory *os.File, expected os.FileInfo, sourceName string, + isolatedName string, targetName string, rename directoryRenameNoReplace, ) (bool, error) { + if sourceName == isolatedName || + sourceName == targetName || + isolatedName == targetName { + return false, errors.New( + "directory rename source, isolation, and target names must differ", + ) + } fd := int(directory.Fd()) - isolatedName := ".drove-directory-" + uuid.NewString() if err := rename(fd, sourceName, isolatedName); err != nil { return false, err } @@ -37,7 +43,17 @@ func renameBoundDirectoryNoReplace( err, ) } - return nil + return syncRecordDirectory(directory) + } + if err := syncRecordDirectory(directory); err != nil { + return false, errors.Join( + fmt.Errorf( + "sync isolated directory %q: %w", + sourceName, + err, + ), + restore(), + ) } isolated, err := statDirectoryAt(directory, isolatedName) if err != nil { @@ -59,6 +75,13 @@ func renameBoundDirectoryNoReplace( if err := rename(fd, isolatedName, targetName); err != nil { return false, errors.Join(err, restore()) } + if err := syncRecordDirectory(directory); err != nil { + return true, fmt.Errorf( + "sync renamed directory %q: %w", + targetName, + err, + ) + } target, err := statDirectoryAt(directory, targetName) if err != nil { return true, fmt.Errorf( diff --git a/internal/workspace/directory_rename_darwin.go b/internal/workspace/directory_rename_darwin.go index 0e96543..250c160 100644 --- a/internal/workspace/directory_rename_darwin.go +++ b/internal/workspace/directory_rename_darwin.go @@ -12,12 +12,14 @@ func renameDirectoryNoReplace( directory *os.File, expected os.FileInfo, sourceName string, + isolatedName string, targetName string, ) (bool, error) { return renameBoundDirectoryNoReplace( directory, expected, sourceName, + isolatedName, targetName, func(fd int, source string, target string) error { return unix.RenameatxNp( diff --git a/internal/workspace/directory_rename_legacy_unix.go b/internal/workspace/directory_rename_legacy_unix.go index 0b730d4..17af160 100644 --- a/internal/workspace/directory_rename_legacy_unix.go +++ b/internal/workspace/directory_rename_legacy_unix.go @@ -12,6 +12,7 @@ func renameDirectoryNoReplace( _ os.FileInfo, _ string, _ string, + _ string, ) (bool, error) { return false, errors.New( "atomic no-replace directory rename is unsupported", diff --git a/internal/workspace/directory_rename_linux.go b/internal/workspace/directory_rename_linux.go index 662ab21..05cb910 100644 --- a/internal/workspace/directory_rename_linux.go +++ b/internal/workspace/directory_rename_linux.go @@ -12,12 +12,14 @@ func renameDirectoryNoReplace( directory *os.File, expected os.FileInfo, sourceName string, + isolatedName string, targetName string, ) (bool, error) { return renameBoundDirectoryNoReplace( directory, expected, sourceName, + isolatedName, targetName, func(fd int, source string, target string) error { return unix.Renameat2( diff --git a/internal/workspace/directory_rename_linux_darwin_test.go b/internal/workspace/directory_rename_linux_darwin_test.go index cc2cd55..01c92b6 100644 --- a/internal/workspace/directory_rename_linux_darwin_test.go +++ b/internal/workspace/directory_rename_linux_darwin_test.go @@ -40,6 +40,7 @@ func TestRenameDirectoryNoReplaceRejectsReplacementSource(t *testing.T) { directory, expected, "source", + "source-isolated", "target", ) if err == nil || moved { diff --git a/internal/workspace/directory_rename_other.go b/internal/workspace/directory_rename_other.go index 0867f86..a2ba055 100644 --- a/internal/workspace/directory_rename_other.go +++ b/internal/workspace/directory_rename_other.go @@ -12,6 +12,7 @@ func renameDirectoryNoReplace( _ os.FileInfo, _ string, _ string, + _ string, ) (bool, error) { return false, errors.New( "atomic no-replace directory rename is unsupported", diff --git a/internal/workspace/directory_rename_windows.go b/internal/workspace/directory_rename_windows.go index 02c056b..6b00402 100644 --- a/internal/workspace/directory_rename_windows.go +++ b/internal/workspace/directory_rename_windows.go @@ -14,6 +14,7 @@ func renameDirectoryNoReplace( directory *os.File, expected os.FileInfo, sourceName string, + _ string, targetName string, ) (moved bool, result error) { renaming, err := openDirectoryForRename(directory, sourceName) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 8f868f0..21019e6 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -123,18 +123,20 @@ func (m *Manager) copyIncludedFiles( ) } - copiedPaths := make([]string, 0, len(paths)) - for _, relative := range paths { - tracked, err := m.worktreePathTracked( + trackedPaths := map[string]struct{}{} + if len(paths) > 0 { + trackedPaths, err = m.worktreeTrackedPaths( ctx, target.Path, destination, - relative, ) if err != nil { return nil, err } - if tracked { + } + copiedPaths := make([]string, 0, len(paths)) + for _, relative := range paths { + if _, tracked := trackedPaths[filepath.ToSlash(relative)]; tracked { continue } if err := copyIncludedPath( @@ -177,34 +179,34 @@ func (m *Manager) copyIncludedFiles( return copiedPaths, nil } -func (m *Manager) worktreePathTracked( +func (m *Manager) worktreeTrackedPaths( ctx context.Context, path string, root *os.Root, - relative string, -) (bool, error) { - _, err := m.runRootedGit( +) (map[string]struct{}, error) { + output, err := m.runRootedGit( ctx, path, root, "--literal-pathspecs", "ls-files", - "--error-unmatch", - "--", - filepath.ToSlash(relative), + "--cached", + "--full-name", + "-z", ) - switch { - case err == nil: - return true, nil - case isExitCode(err, 1): - return false, nil - default: - return false, fmt.Errorf( - "workspace: inspect target include path %q: %w", - relative, + if err != nil { + return nil, fmt.Errorf( + "workspace: inspect target tracked paths: %w", err, ) } + tracked := make(map[string]struct{}) + for _, rawPath := range strings.Split(string(output), "\x00") { + if rawPath != "" { + tracked[rawPath] = struct{}{} + } + } + return tracked, nil } func (m *Manager) includedPaths( diff --git a/internal/workspace/include_manifest_windows_test.go b/internal/workspace/include_manifest_windows_test.go index b8e0783..4435ea8 100644 --- a/internal/workspace/include_manifest_windows_test.go +++ b/internal/workspace/include_manifest_windows_test.go @@ -14,7 +14,12 @@ func TestConfigureIncludeManifestCommandLocksManifestIdentity(t *testing.T) { if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { t.Fatalf("write manifest: %v", err) } - manifest, err := os.Open(path) + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + t.Fatalf("open manifest parent: %v", err) + } + defer root.Close() + manifest, err := root.Open(filepath.Base(path)) if err != nil { t.Fatalf("open manifest: %v", err) } diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 1327dcd..afaaf66 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -616,3 +616,92 @@ func TestPrepareCopiesIncludedLiteralPathspecName(t *testing.T) { t.Fatalf("included literal path was not copied: %v", err) } } + +func TestPrepareReadsTargetTrackedPathsOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files") + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + for _, name := range []string{"first.local", "second.local", "third.local"} { + if err := os.WriteFile( + filepath.Join(repository, name), + []byte(name+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write include candidate %q: %v", name, err) + } + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + countPath := filepath.Join(t.TempDir(), "tracked-count") + wrapper := filepath.Join(t.TempDir(), "git-wrapper") + script := `#!/bin/sh +saw_ls= +saw_cached= +for argument in "$@"; do + [ "$argument" = "ls-files" ] && saw_ls=1 + [ "$argument" = "--cached" ] && saw_cached=1 + [ "$argument" = "--error-unmatch" ] && exit 97 +done +if [ -n "$saw_ls" ] && [ -n "$saw_cached" ]; then + printf 'x\n' >> "$DROVE_TEST_COUNT" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_COUNT", countPath) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + t.Cleanup(func() { + _ = manager.Discard(context.Background(), prepared) + }) + count, err := os.ReadFile(countPath) + if err != nil { + t.Fatalf("read tracked-path query count: %v", err) + } + if string(count) != "x\n" { + t.Fatalf("target tracked-path queries = %q, want one", count) + } + for _, name := range []string{"first.local", "second.local", "third.local"} { + assertFileContents( + t, + filepath.Join(prepared.Path, name), + name+"\n", + ) + } +} diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 1cadc32..1fafebc 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -214,6 +214,80 @@ func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( } } +func TestReconcilePreparationsDiscardsRenameIsolatedStagingWorktree( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + parent := filepath.Dir(prepared.Path) + stagingPath := filepath.Join(parent, stagingName) + isolationPath := filepath.Join( + parent, + preparedWorktreeIsolationName(stagingName), + ) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("restore staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := os.Rename(stagingPath, isolationPath); err != nil { + t.Fatalf("isolate staging path: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile isolated preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + isolationPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) || + strings.Contains(listing, isolationPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } +} + func TestReconcilePreparationsRepairsPromotionBeforeDiscard(t *testing.T) { repository := newTestRepository(t) dataDir := filepath.Join(t.TempDir(), "data") diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 2d05375..337c62a 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -34,6 +34,10 @@ func preparedWorktreeStagingName( return name, nil } +func preparedWorktreeIsolationName(stagingName string) string { + return stagingName + ".rename" +} + func (m *Manager) createPreparedWorktreeTarget( target *Workspace, ) (_ *preparedWorktreeTarget, result error) { @@ -219,14 +223,20 @@ func (m *Manager) initializePreparedWorktree( if err != nil { return err } - if !exists || - !sameRegisteredWorktreePath(registered.path, prepared.path) || - !registered.detached || - registered.head != target.expectedHeadOID { + preparedIntent := *target + preparedIntent.Path = prepared.path + if !exists { return errors.New( - "workspace: detached worktree registration does not match intent", + "workspace: prepared worktree registration is missing", ) } + if err := repository.verifyPreparedWorktreeRegistration( + preparedIntent, + registered, + target.expectedHeadOID, + ); err != nil { + return err + } gitDirectory, err := m.worktreeGitDirectoryAtRoot( ctx, prepared.path, @@ -286,17 +296,6 @@ func (m *Manager) initializePreparedWorktree( if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { return err } - if _, err := privateRepository.runWorktreeAt( - ctx, - prepared.path, - root, - "", - "symbolic-ref", - "HEAD", - "refs/heads/"+target.Branch, - ); err != nil { - return fmt.Errorf("workspace: attach prepared worktree branch: %w", err) - } if _, err := privateRepository.runWorktreeAt( ctx, prepared.path, @@ -309,6 +308,36 @@ func (m *Manager) initializePreparedWorktree( ); err != nil { return fmt.Errorf("workspace: checkout prepared worktree: %w", err) } + expectedTree, err := privateRepository.runPrivateGit( + ctx, + "", + "rev-parse", + "--verify", + target.expectedHeadOID+"^{tree}", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared expected tree: %w", + err, + ) + } + indexTree, err := privateRepository.runPrivateGit( + ctx, + "", + "write-tree", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared index tree: %w", + err, + ) + } + if strings.TrimSpace(string(indexTree)) != + strings.TrimSpace(string(expectedTree)) { + return errors.New( + "workspace: prepared index does not match expected tree", + ) + } if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { return err } @@ -324,8 +353,6 @@ func (m *Manager) initializePreparedWorktree( "workspace: prepared worktree registration disappeared", ) } - preparedIntent := *target - preparedIntent.Path = prepared.path if err := privateRepository.verifyBoundPreparedWorktree( ctx, preparedIntent, @@ -417,6 +444,7 @@ func (m *Manager) promotePreparedWorktreeTarget( directory, opened, prepared.name, + preparedWorktreeIsolationName(prepared.name), target.AgentID, ) if moved { @@ -489,38 +517,51 @@ func (prepared *preparedWorktreeTarget) Close() error { return err } -func (m *Manager) preparedWorktreeStagingPath( +func (m *Manager) preparedWorktreeStagingPaths( target Workspace, record workspaceRecord, -) (_ string, _ bool, result error) { +) (_ string, _ string, _ bool, result error) { name, err := preparedWorktreeStagingName( target.AgentID, record.BranchOperationID, ) if err != nil { - return "", false, err + return "", "", false, err } bucket, err := m.openManagedBucketRoot(target) if err != nil { - return "", false, err + return "", "", false, err } defer func() { result = errors.Join(result, bucket.Close()) }() - info, err := bucket.Lstat(name) - path := filepath.Join(filepath.Dir(target.Path), name) - switch { - case errors.Is(err, os.ErrNotExist): - return path, false, nil - case err != nil: - return "", false, err - case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: - return "", false, errors.New( - "workspace: prepared worktree staging path is not a real directory", - ) - default: - return path, true, nil + names := []string{name, preparedWorktreeIsolationName(name)} + active := "" + for _, candidate := range names { + info, err := bucket.Lstat(candidate) + switch { + case errors.Is(err, os.ErrNotExist): + continue + case err != nil: + return "", "", false, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return "", "", false, errors.New( + "workspace: prepared worktree staging path is not a real directory", + ) + case active != "": + return "", "", false, errors.New( + "workspace: preparation has multiple staging worktree paths", + ) + default: + active = candidate + } } + parent := filepath.Dir(target.Path) + registrationPath := filepath.Join(parent, name) + if active == "" { + return registrationPath, registrationPath, false, nil + } + return filepath.Join(parent, active), registrationPath, true, nil } func (m *Manager) removeStagedPreparedWorktree( @@ -528,6 +569,8 @@ func (m *Manager) removeStagedPreparedWorktree( target Workspace, record workspaceRecord, path string, + registrationPath string, + repository repositoryCapability, ) (result error) { bucket, err := m.openManagedBucketRoot(target) if err != nil { @@ -557,25 +600,33 @@ func (m *Manager) removeStagedPreparedWorktree( "workspace: prepared worktree staging identity changed", ) } - repository, err := m.repositoryRootAtRoot(ctx, path, opened) + registered, exists, err := repository.worktreeRegistration( + ctx, + registrationPath, + ) if err != nil { return err } - if repository != record.Repository { + if !exists { return errors.New( - "workspace: prepared worktree staging repository changed", + "workspace: prepared worktree staging registration disappeared", ) } - gitDirectory, err := m.worktreeGitDirectoryAtRoot(ctx, path, opened) - if err != nil { - return err - } - if record.GitDirectory == "" || - gitDirectory != record.GitDirectory { + if record.GitDirectory == "" { return errors.New( - "workspace: prepared worktree staging Git directory changed", + "workspace: prepared worktree staging Git identity is incomplete", ) } + stagingTarget := target + stagingTarget.Path = registrationPath + if err := repository.verifyPreparedWorktree( + ctx, + stagingTarget, + record.GitDirectory, + registered, + ); err != nil { + return err + } if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { return err } diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 7fc0ebe..f1391ff 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -52,12 +52,13 @@ type repositoryEvidence struct { } type workspaceRemovalRecord struct { - OperationID string `json:"operation_id"` - DirectoryToken string `json:"directory_token,omitempty"` - Force bool `json:"force"` - Started bool `json:"started"` - Quarantined bool `json:"quarantined,omitempty"` - PathAbsent bool `json:"path_absent,omitempty"` + OperationID string `json:"operation_id"` + DirectoryToken string `json:"directory_token,omitempty"` + Force bool `json:"force"` + Started bool `json:"started"` + Quarantined bool `json:"quarantined,omitempty"` + ContentsCleared bool `json:"contents_cleared,omitempty"` + PathAbsent bool `json:"path_absent,omitempty"` } type recordAcknowledgement struct { @@ -645,6 +646,12 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: quarantined removal has not been started", ) } + if record.Removal.ContentsCleared && + (!record.Removal.Started || !record.Removal.Quarantined) { + return errors.New( + "workspace: cleared removal contents require a started quarantine", + ) + } if record.Removal.PathAbsent && record.Removal.Quarantined { return errors.New( "workspace: absent-path removal cannot be quarantined", diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 5addfc6..ab5fca2 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -133,6 +133,7 @@ func TestNoReplaceRenamesPreserveWindowsTargets(t *testing.T) { directory, sourceInfo, "source-dir", + "source-dir-isolated", "target-dir", ) closeErr = directory.Close() diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 9c29095..92f5367 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1689,6 +1689,122 @@ func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { } } +func TestReconcileContentsClearedRemovalAcceptsMissingMarker(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "53535353-5353-4353-8353-535353535353", + DirectoryToken: "54545454-5454-4454-8454-545454545454", + Force: true, + Started: true, + Quarantined: true, + ContentsCleared: true, + } + quarantineName := removalQuarantinePrefix(record) + + "55555555-5555-4555-8555-555555555555" + quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine workspace: %v", err) + } + entries, err := os.ReadDir(quarantinePath) + if err != nil { + t.Fatalf("read quarantine: %v", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(quarantinePath, entry.Name())); err != nil { + t.Fatalf("clear quarantine entry %q: %v", entry.Name(), err) + } + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist cleared removal: %v", err) + } + + removals, err := manager.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile cleared removal: %v", err) + } + if len(removals) != 1 || removals[0].Workspace.Path != prepared.Path { + t.Fatalf("reconciled removals = %+v", removals) + } + if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cleared quarantine remains or inspect failed: %v", err) + } +} + +func TestReconcileContentsClearedRemovalRejectsUnexpectedEntry(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "56565656-5656-4656-8656-565656565656", + DirectoryToken: "57575757-5757-4757-8757-575757575757", + Force: true, + Started: true, + Quarantined: true, + ContentsCleared: true, + } + quarantineName := removalQuarantinePrefix(record) + + "58585858-5858-4858-8858-585858585858" + quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine workspace: %v", err) + } + entries, err := os.ReadDir(quarantinePath) + if err != nil { + t.Fatalf("read quarantine: %v", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(quarantinePath, entry.Name())); err != nil { + t.Fatalf("clear quarantine entry %q: %v", entry.Name(), err) + } + } + unexpected := filepath.Join(quarantinePath, "unexpected") + if err := os.WriteFile(unexpected, []byte("preserve\n"), 0o600); err != nil { + t.Fatalf("write unexpected entry: %v", err) + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist cleared removal: %v", err) + } + + if _, err := manager.ReconcileRemovals( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "unexpected entry") { + t.Fatalf("reconcile unexpected cleared entry error = %v", err) + } + assertFileContents(t, unexpected, "preserve\n") +} + func TestReconcileAbsentPathRemovalPreservesReplacementPath(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 8be8154..951bf05 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -590,10 +590,11 @@ func (m *Manager) resumePendingRemoval( filepath.Dir(record.Path), facts.quarantineName, ) - identityErr := m.validateRemovalIdentity( + identityErr := m.validateRemovalQuarantineIdentity( ctx, record, quarantinePath, + quarantined, ) var markerErr error if identityErr == nil { @@ -804,6 +805,33 @@ func (m *Manager) persistRemovalStart( return record, nil } +func (m *Manager) persistRemovalContentsCleared( + record workspaceRecord, +) (workspaceRecord, error) { + if record.Removal == nil || + !record.Removal.Started || + !record.Removal.Quarantined { + return workspaceRecord{}, errors.New( + "workspace: removal quarantine is not ready to clear contents", + ) + } + if record.Removal.ContentsCleared { + return record, nil + } + cleared := *record.Removal + cleared.ContentsCleared = true + upgradeWorkspaceRecord(&record) + record.Removal = &cleared + _, err := m.replaceWorkspaceRecordState(record) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: persist cleared removal contents: %w", + err, + ) + } + return record, nil +} + func (m *Manager) validateRemovalIdentity( ctx context.Context, record workspaceRecord, @@ -868,6 +896,38 @@ func validateRemovalMarkerPath( return verifyRemovalMarker(root, record) } +func (m *Manager) validateRemovalQuarantineIdentity( + ctx context.Context, + record workspaceRecord, + path string, + root *os.Root, +) error { + identity, err := openedDirectoryIdentity(root) + if err != nil { + return err + } + if record.DirectoryIdentity == "" || + identity != record.DirectoryIdentity { + return errors.New( + "workspace: removal quarantine directory identity changed", + ) + } + if record.Removal != nil && record.Removal.ContentsCleared { + return validateClearedRemovalDirectory(root, record, false) + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(root, record) + if err != nil { + return err + } + if onlyMarker && + record.Removal != nil && + record.Removal.Started && + record.Removal.Quarantined { + return nil + } + return m.validateRemovalIdentity(ctx, record, path) +} + func (m *Manager) removeRemovalQuarantine( ctx context.Context, record workspaceRecord, @@ -892,14 +952,71 @@ func (m *Manager) removeRemovalQuarantine( }() path := filepath.Join(filepath.Dir(record.Path), name) if err := errors.Join( - m.validateRemovalIdentity(ctx, record, path), - verifyRemovalMarker(opened, record), + m.validateRemovalQuarantineIdentity(ctx, record, path, opened), + validateRemovalMarkerPhase(opened, record), verifyRootEntryUnchanged(bucket, name, opened), ); err != nil { return err } + if !record.Removal.ContentsCleared { + onlyMarker, err := removalDirectoryContainsOnlyMarker(opened, record) + if err != nil { + return err + } + if !onlyMarker { + entries, err := readRootDirectory(opened) + if err != nil { + return err + } + if err := removeRootEntriesExcept( + opened, + entries, + removalMarkerName(record), + ); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + opened, + record, + true, + ); err != nil { + return err + } + } + record, err = m.persistRemovalContentsCleared(record) + if err != nil { + return err + } + } + if err := validateClearedRemovalDirectory( + opened, + record, + false, + ); err != nil { + return err + } + if err := removeRemovalMarkerIfPresent(opened, record); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + opened, + record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } + closeErr := opened.Close() openedOwned = false - return removeOpenedDirectoryFromRoot(bucket, name, opened) + if closeErr != nil { + return closeErr + } + if err := bucket.Remove(name); err != nil { + return err + } + return syncRecordBucket(bucket, name) } func (m *Manager) rejectQuarantinedRemoval( diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index d7f5b42..8157ac2 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -171,15 +171,29 @@ func (r repositoryCapability) addPreparedWorktree( ctx context.Context, path string, root *os.Root, + branch string, expectedHeadOID string, ) ([]byte, error) { - if err := r.verifyBinding(ctx); err != nil { + verifyBranch := func() error { + oid, exists, err := r.refOID(ctx, "refs/heads/"+branch) + if err != nil { + return err + } + if !exists || oid != expectedHeadOID { + return errors.New( + "workspace: prepared branch moved from its expected HEAD", + ) + } + return nil + } + if err := errors.Join(r.verifyBinding(ctx), verifyBranch()); err != nil { return nil, err } command, cleanup, err := rootedPreparedWorktreeGitCommand( ctx, r.manager.git, r.path, + r.root, r.commonPath, r.commonRoot, path, @@ -188,10 +202,9 @@ func (r repositoryCapability) addPreparedWorktree( "worktree", "add", "--quiet", - "--detach", "--no-checkout", ".", - expectedHeadOID, + branch, }, ) if err != nil { @@ -201,6 +214,7 @@ func (r repositoryCapability) addPreparedWorktree( verifyErr := errors.Join( r.verifyBinding(ctx), verifyRealPathRoot(path, root), + verifyBranch(), ) return output, errors.Join(commandErr, verifyErr, cleanup()) } @@ -368,6 +382,8 @@ func (r repositoryCapability) worktreeCommandAt( worktreeRoot, r.gitPath, r.gitRoot, + r.commonPath, + r.commonRoot, arguments, ) } diff --git a/internal/workspace/rooted_git.go b/internal/workspace/rooted_git.go index 19022b3..63fc3ba 100644 --- a/internal/workspace/rooted_git.go +++ b/internal/workspace/rooted_git.go @@ -148,3 +148,15 @@ func boundGitEnvironment( "GIT_WORK_TREE="+worktree, ) } + +func boundGitEnvironmentWithCommon( + environment []string, + gitDirectory string, + worktree string, + commonDirectory string, +) []string { + return append( + boundGitEnvironment(environment, gitDirectory, worktree), + "GIT_COMMON_DIR="+commonDirectory, + ) +} diff --git a/internal/workspace/rooted_git_descriptor_unix_test.go b/internal/workspace/rooted_git_descriptor_unix_test.go new file mode 100644 index 0000000..c4ab018 --- /dev/null +++ b/internal/workspace/rooted_git_descriptor_unix_test.go @@ -0,0 +1,133 @@ +//go:build linux + +package workspace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "testing" +) + +func inheritedDirectoryPath(fd int) string { + return "/proc/self/fd/" + fmt.Sprint(fd) +} + +func TestRootedPreparedWorktreeGitCommandBindsAllDirectories(t *testing.T) { + parent := t.TempDir() + repositoryPath := filepath.Join(parent, "repository") + commonPath := filepath.Join(parent, "common") + worktreePath := filepath.Join(parent, "worktree") + for _, path := range []string{repositoryPath, commonPath, worktreePath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + repositoryRoot, err := openRealPathRoot(repositoryPath) + if err != nil { + t.Fatalf("open repository root: %v", err) + } + defer repositoryRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + + command, cleanup, err := rootedPreparedWorktreeGitCommand( + context.Background(), + "git", + repositoryPath, + repositoryRoot, + commonPath, + commonRoot, + worktreePath, + worktreeRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted prepared command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted prepared command: %v", err) + } + }() + if len(command.ExtraFiles) != 3 { + t.Fatalf("prepared command inherited files = %d, want 3", len(command.ExtraFiles)) + } + for _, want := range []string{ + "GIT_DIR=" + inheritedDirectoryPath(4), + "GIT_WORK_TREE=" + inheritedDirectoryPath(5), + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("prepared command environment lacks %q: %q", want, command.Env) + } + } +} + +func TestRootedWorktreeGitCommandBindsAllDirectories(t *testing.T) { + parent := t.TempDir() + worktreePath := filepath.Join(parent, "worktree") + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{worktreePath, gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedWorktreeGitCommand( + context.Background(), + "git", + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted worktree command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted worktree command: %v", err) + } + }() + if len(command.ExtraFiles) != 3 { + t.Fatalf("worktree command inherited files = %d, want 3", len(command.ExtraFiles)) + } + for _, want := range []string{ + "GIT_DIR=" + inheritedDirectoryPath(4), + "GIT_WORK_TREE=" + inheritedDirectoryPath(3), + "GIT_COMMON_DIR=" + inheritedDirectoryPath(5), + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("worktree command environment lacks %q: %q", want, command.Env) + } + } +} diff --git a/internal/workspace/rooted_git_linux.go b/internal/workspace/rooted_git_linux.go index 6c072db..6b82808 100644 --- a/internal/workspace/rooted_git_linux.go +++ b/internal/workspace/rooted_git_linux.go @@ -94,29 +94,41 @@ func rootedWorktreeGitCommand( root *os.Root, _ string, gitRoot *os.Root, + _ string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { + worktree, err := root.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open rooted Git worktree: %w", + err, + ) + } gitDirectory, err := gitRoot.Open(".") if err != nil { + _ = worktree.Close() return nil, nil, fmt.Errorf( "workspace: open rooted private Git directory: %w", err, ) } - worktree, err := root.Open(".") + commonDirectory, err := commonRoot.Open(".") if err != nil { + _ = worktree.Close() _ = gitDirectory.Close() return nil, nil, fmt.Errorf( - "workspace: open rooted Git worktree: %w", + "workspace: open rooted common Git directory: %w", err, ) } command := exec.CommandContext(ctx, git) - command.ExtraFiles = []*os.File{gitDirectory, worktree} - command.Env = boundGitEnvironment( + command.ExtraFiles = []*os.File{worktree, gitDirectory, commonDirectory} + command.Env = boundGitEnvironmentWithCommon( command.Environ(), - ".", "/proc/self/fd/4", + "/proc/self/fd/3", + "/proc/self/fd/5", ) command.Args = append( command.Args, @@ -127,7 +139,11 @@ func rootedWorktreeGitCommand( ) command.Args = append(command.Args, arguments...) cleanup := func() error { - return errors.Join(worktree.Close(), gitDirectory.Close()) + return errors.Join( + commonDirectory.Close(), + gitDirectory.Close(), + worktree.Close(), + ) } return command, cleanup, nil } @@ -136,6 +152,7 @@ func rootedPreparedWorktreeGitCommand( ctx context.Context, git string, repositoryPath string, + repositoryRoot *os.Root, commonPath string, commonRoot *os.Root, _ string, @@ -149,17 +166,29 @@ func rootedPreparedWorktreeGitCommand( err, ) } - commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + commonDirectory, err := commonRoot.Open(".") + if err != nil { + _ = worktree.Close() + return nil, nil, fmt.Errorf( + "workspace: open rooted common Git directory: %w", + err, + ) + } + repository, err := repositoryRoot.Open(".") if err != nil { + _ = commonDirectory.Close() _ = worktree.Close() - return nil, nil, err + return nil, nil, fmt.Errorf( + "workspace: open rooted source repository: %w", + err, + ) } command := exec.CommandContext(ctx, git) - command.ExtraFiles = []*os.File{worktree} + command.ExtraFiles = []*os.File{worktree, commonDirectory, repository} command.Env = boundGitEnvironment( command.Environ(), - commonPath, - repositoryPath, + "/proc/self/fd/4", + "/proc/self/fd/5", ) command.Args = append( command.Args, @@ -170,7 +199,13 @@ func rootedPreparedWorktreeGitCommand( ) command.Args = append(command.Args, arguments...) cleanup := func() error { - return errors.Join(commonGuard.Close(), worktree.Close()) + return errors.Join( + verifyRealPathRoot(repositoryPath, repositoryRoot), + verifyRealPathRoot(commonPath, commonRoot), + repository.Close(), + commonDirectory.Close(), + worktree.Close(), + ) } return command, cleanup, nil } diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 18e310e..82616c7 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -16,6 +16,8 @@ func rootedGitCommand( _ *os.Root, _ string, _ *os.Root, + _ string, + _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( @@ -43,6 +45,8 @@ func rootedWorktreeGitCommand( _ *os.Root, _ string, _ *os.Root, + _ string, + _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( @@ -54,6 +58,7 @@ func rootedPreparedWorktreeGitCommand( _ context.Context, _ string, _ string, + _ *os.Root, _ string, _ *os.Root, _ string, diff --git a/internal/workspace/rooted_git_test.go b/internal/workspace/rooted_git_test.go index 8b7614d..5627c7f 100644 --- a/internal/workspace/rooted_git_test.go +++ b/internal/workspace/rooted_git_test.go @@ -36,3 +36,23 @@ func TestBoundGitEnvironmentRemovesInheritedGitConfiguration(t *testing.T) { } } } + +func TestBoundGitEnvironmentWithCommonSetsExplicitCommonDirectory( + t *testing.T, +) { + environment := boundGitEnvironmentWithCommon( + []string{"GIT_COMMON_DIR=/replacement"}, + "/git", + "/worktree", + "/common", + ) + for _, want := range []string{ + "GIT_DIR=/git", + "GIT_WORK_TREE=/worktree", + "GIT_COMMON_DIR=/common", + } { + if !slices.Contains(environment, want) { + t.Fatalf("bound environment %q does not contain %q", environment, want) + } + } +} diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index afa63dd..9585f36 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -139,6 +139,8 @@ func rootedWorktreeGitCommand( root *os.Root, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { if err := verifyRealPathRoot(gitPath, gitRoot); err != nil { @@ -147,6 +149,12 @@ func rootedWorktreeGitCommand( err, ) } + if err := verifyRealPathRoot(commonPath, commonRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted common Git directory: %w", + err, + ) + } directory, err := root.Open(".") if err != nil { return nil, nil, fmt.Errorf( @@ -184,6 +192,7 @@ func rootedWorktreeGitCommand( cleanup := func() error { return errors.Join( verifyRealPathRoot(gitPath, gitRoot), + verifyRealPathRoot(commonPath, commonRoot), directory.Close(), ) } @@ -194,12 +203,19 @@ func rootedPreparedWorktreeGitCommand( ctx context.Context, git string, repositoryPath string, + repositoryRoot *os.Root, commonPath string, commonRoot *os.Root, _ string, worktreeRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { + if err := verifyRealPathRoot(repositoryPath, repositoryRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted source repository: %w", + err, + ) + } directory, err := worktreeRoot.Open(".") if err != nil { return nil, nil, fmt.Errorf( @@ -242,7 +258,12 @@ func rootedPreparedWorktreeGitCommand( repositoryPath, ) cleanup := func() error { - return errors.Join(commonGuard.Close(), directory.Close()) + return errors.Join( + verifyRealPathRoot(repositoryPath, repositoryRoot), + verifyRealPathRoot(commonPath, commonRoot), + commonGuard.Close(), + directory.Close(), + ) } return command, cleanup, nil } diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 4e7bf24..0ed1743 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -78,6 +78,8 @@ func rootedWorktreeGitCommand( root *os.Root, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { command, closeWorktree, err := rootedGitCommand( @@ -96,8 +98,20 @@ func rootedWorktreeGitCommand( if err != nil { return nil, nil, errors.Join(err, closeWorktree()) } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join( + err, + gitGuard.Close(), + closeWorktree(), + ) + } cleanup := func() error { - return errors.Join(gitGuard.Close(), closeWorktree()) + return errors.Join( + commonGuard.Close(), + gitGuard.Close(), + closeWorktree(), + ) } return command, cleanup, nil } @@ -106,6 +120,7 @@ func rootedPreparedWorktreeGitCommand( ctx context.Context, git string, repositoryPath string, + repositoryRoot *os.Root, commonPath string, commonRoot *os.Root, worktreePath string, @@ -120,6 +135,17 @@ func rootedPreparedWorktreeGitCommand( if err != nil { return nil, nil, errors.Join(err, worktreeGuard.Close()) } + repositoryGuard, err := openRepositoryGuard( + repositoryPath, + repositoryRoot, + ) + if err != nil { + return nil, nil, errors.Join( + err, + commonGuard.Close(), + worktreeGuard.Close(), + ) + } command := exec.CommandContext( ctx, git, @@ -135,7 +161,11 @@ func rootedPreparedWorktreeGitCommand( repositoryPath, ) cleanup := func() error { - return errors.Join(commonGuard.Close(), worktreeGuard.Close()) + return errors.Join( + repositoryGuard.Close(), + commonGuard.Close(), + worktreeGuard.Close(), + ) } return command, cleanup, nil } diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index 7ac6698..98a6d00 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -280,10 +280,21 @@ func removalMarkerName(record workspaceRecord) string { return ".drove-removal-" + record.Removal.OperationID } +func removalQuarantineIsolationName(name string) string { + const suffix = ".rename" + if strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(name, suffix) + } + return name + suffix +} + func validateRemovalMarkerPhase( root *os.Root, record workspaceRecord, ) error { + if record.Removal != nil && record.Removal.ContentsCleared { + return validateClearedRemovalDirectory(root, record, false) + } if record.Removal != nil && record.Removal.Started { return verifyRemovalMarker(root, record) } @@ -406,6 +417,80 @@ func verifyRemovalMarker( return nil } +func removalDirectoryContainsOnlyMarker( + root *os.Root, + record workspaceRecord, +) (bool, error) { + entries, err := readRootDirectory(root) + if err != nil { + return false, err + } + if len(entries) != 1 || entries[0].Name() != removalMarkerName(record) { + return false, nil + } + if err := verifyRemovalMarker(root, record); err != nil { + return false, err + } + return true, nil +} + +func validateClearedRemovalDirectory( + root *os.Root, + record workspaceRecord, + requireMarker bool, +) error { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + markerPresent := false + for _, entry := range entries { + if entry.Name() != removalMarkerName(record) { + return fmt.Errorf( + "workspace: cleared removal quarantine contains unexpected entry %q", + entry.Name(), + ) + } + if markerPresent { + return errors.New( + "workspace: cleared removal quarantine contains duplicate markers", + ) + } + markerPresent = true + } + if !markerPresent { + if requireMarker { + return errors.New( + "workspace: cleared removal quarantine marker is missing", + ) + } + return nil + } + return verifyRemovalMarker(root, record) +} + +func removeRootEntriesExcept( + root *os.Root, + entries []os.DirEntry, + preserved string, +) error { + for _, entry := range entries { + if entry.Name() == preserved { + continue + } + if err := removeAllFromRoot(root, entry.Name()); err != nil { + return err + } + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + func removeRemovalMarkerIfPresent( root *os.Root, record workspaceRecord, @@ -546,6 +631,7 @@ func quarantineManagedPath( directory, openedInfo, record.AgentID, + removalQuarantineIsolationName(name), name, ) syncErr := syncRecordDirectory(directory) @@ -591,6 +677,7 @@ func restoreManagedQuarantine( directory, openedInfo, name, + removalQuarantineIsolationName(name), record.AgentID, ) syncErr := syncRecordDirectory(directory) diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 3bbc55e..e9a3941 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -2281,6 +2282,61 @@ exit "$status" } } +func TestPrepareRejectsCheckoutThroughTransientPrivateGitReplacement( + t *testing.T, +) { + if runtime.GOOS == "linux" { + t.Skip("Linux binds the private Git directory through /proc/self/fd") + } + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + marker := filepath.Join(parent, "swapped-private-git") + wrapper := filepath.Join(parent, "git-wrapper-transient-private-git") + script := `#!/bin/sh +matched= +for argument in "$@"; do + [ "$argument" = "read-tree" ] && matched=1 +done +if [ -n "$matched" ] && [ ! -e "$DROVE_TEST_MARKER" ]; then + original="$GIT_DIR.drove-original" + mv "$GIT_DIR" "$original" || exit 91 + cp -R "$original" "$GIT_DIR" || exit 92 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + rm -rf "$GIT_DIR" || exit 93 + mv "$original" "$GIT_DIR" || exit 94 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_MARKER", marker) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil || + !strings.Contains(err.Error(), "prepared index does not match") { + t.Fatalf("prepare through transient private Git replacement error = %v", err) + } +} + func postSuccessSwapGitWrapper( t *testing.T, parent string, diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index ca62529..02f4781 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -418,6 +418,7 @@ func (m *Manager) prepare( ctx, preparedTarget.path, preparedTarget.root, + result.Branch, expectedHeadOID, ); err != nil { return Workspace{}, fmt.Errorf("workspace: create worktree: %w", err) @@ -734,8 +735,8 @@ func (m *Manager) discardWithRepository( "workspace: preparation record does not match discard request", ) } - stagingPath, stagingExists, err := - m.preparedWorktreeStagingPath(target, record) + stagingPath, stagingRegistrationPath, stagingExists, err := + m.preparedWorktreeStagingPaths(target, record) if err != nil { return err } @@ -765,8 +766,11 @@ func (m *Manager) discardWithRepository( ) } registrationPaths := []string{target.Path} - if stagingPath != target.Path { - registrationPaths = append(registrationPaths, stagingPath) + if stagingRegistrationPath != target.Path { + registrationPaths = append( + registrationPaths, + stagingRegistrationPath, + ) } anyRegistered := func() (bool, error) { for _, path := range registrationPaths { @@ -795,6 +799,8 @@ func (m *Manager) discardWithRepository( target, record, activePath, + stagingRegistrationPath, + repository, ) } else if repository.root != nil { removeErr = m.removePreparedWorktreeAtRoot( diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 64cfb64..9a3146e 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1560,6 +1560,35 @@ func TestPrepareUsesExistingBranchAndDiscardPreservesIt(t *testing.T) { runGit(t, repository, "show-ref", "--verify", "refs/heads/existing") } +func TestPrepareRejectsBranchCheckedOutInSourceWorktree(t *testing.T) { + repository := newTestRepository(t) + branch := strings.TrimSpace( + runGit(t, repository, "symbolic-ref", "--short", "HEAD"), + ) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + if prepared, err := manager.Prepare( + context.Background(), + repository, + branch, + testAgentID, + ); err == nil { + _ = manager.Discard(context.Background(), prepared) + t.Fatal("prepare reused the source worktree branch") + } + if got := strings.TrimSpace( + runGit(t, repository, "symbolic-ref", "--short", "HEAD"), + ); got != branch { + t.Fatalf("source branch = %q, want %q", got, branch) + } + if status := runGit(t, repository, "status", "--porcelain"); status != "" { + t.Fatalf("source worktree changed after rejected prepare:\n%s", status) + } +} + func TestPrepareSkipsIncludeTrackedByTargetBranch(t *testing.T) { repository := newTestRepository(t) if err := os.WriteFile( From 2eabb59f7b0d69d70011d45b468b95f50f509106 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 13:19:18 +0800 Subject: [PATCH 019/104] fix: close remaining recovery race windows --- internal/session/projection.go | 6 +- internal/session/projection_test.go | 14 + internal/workspace/AGENTS.md | 8 +- .../workspace/include_manifest_windows.go | 2 +- .../include_manifest_windows_test.go | 12 + internal/workspace/prepared_checkout_bound.go | 28 + internal/workspace/prepared_checkout_other.go | 21 + internal/workspace/prepared_checkout_unix.go | 790 ++++++++++++++++++ .../workspace/prepared_checkout_unix_test.go | 202 +++++ internal/workspace/prepared_worktree.go | 7 +- internal/workspace/record.go | 8 + internal/workspace/recovery_security_test.go | 54 ++ internal/workspace/removal.go | 37 + internal/workspace/repository_capability.go | 62 +- .../workspace/repository_guard_windows.go | 20 +- .../repository_guard_windows_test.go | 41 + internal/workspace/rooted_git.go | 15 +- internal/workspace/rooted_git_other.go | 2 - internal/workspace/source_swap_test.go | 97 ++- internal/workspace/workspace_test.go | 13 +- 20 files changed, 1407 insertions(+), 32 deletions(-) create mode 100644 internal/workspace/prepared_checkout_bound.go create mode 100644 internal/workspace/prepared_checkout_other.go create mode 100644 internal/workspace/prepared_checkout_unix.go create mode 100644 internal/workspace/prepared_checkout_unix_test.go create mode 100644 internal/workspace/repository_guard_windows_test.go diff --git a/internal/session/projection.go b/internal/session/projection.go index 000ea61..15ebf72 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -397,7 +397,11 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { from != agent.StateDone && from != agent.StateStopped && row.Reason == restartStopReason - draft.restartStopped = draft.restartStopped || isRestartStop + if to == agent.StateDone { + draft.restartStopped = false + } else { + draft.restartStopped = draft.restartStopped || isRestartStop + } if known { draft.lastTransition = evidence } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 7c799a3..470ae32 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -508,6 +508,20 @@ func TestRecoveryProjectorDoesNotResumeDoneStartupResume(t *testing.T) { if plan.ResumeOnStart["agent-1"] { t.Fatal("completed startup resume remained eligible without completion marker") } + rows = append(rows, plan.Reconciliation...) + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("completed startup resume became eligible after reconciliation") + } } func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 1b4ca30..265b635 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -25,8 +25,12 @@ 各自固定一侧的视角交叉验证。 `git worktree add` 使用 `--no-checkout`,只接收 bucket 内随机且与 Agent ID 同前缀的 staging 目录,禁止接触公开 Agent 路径。新 worktree 打开后先核对预先持久化的 - 文件系统目录身份,再持久化 Git 私有目录并从固定目标根执行 `git reset --hard`; - 初始化完成后把 staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 + 文件系统目录身份,再持久化 Git 私有目录。Linux 和 Windows 从同时固定的目标根与 + Git 根执行 `read-tree --reset -u`;macOS/BSD 只从固定私有 Git 根更新 index, + 用 `checkout-index --temp` 导出,并由 Drove 通过固定目标根安装普通文件、符号链接 + 和 gitlink。macOS/BSD 遇到普通文件的 `filter` 属性必须 fail-stop,因为 filter + 无法同时取得固定 worktree cwd 与描述符绑定的私有 Git 上下文。初始化完成后把 + staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 `git worktree repair .`。仓库根目录的 `.worktreeinclude` 使用 gitignore 语义,并从已打开文件读取一次。Unix 通过继承的 只读文件描述符交给 Git,其余平台通过受复核的私有临时副本交给 Git,禁止 Git 再按 diff --git a/internal/workspace/include_manifest_windows.go b/internal/workspace/include_manifest_windows.go index ad42c6d..36c909a 100644 --- a/internal/workspace/include_manifest_windows.go +++ b/internal/workspace/include_manifest_windows.go @@ -34,7 +34,7 @@ func configureIncludeManifestCommand( handle, err := windows.CreateFile( name, windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, diff --git a/internal/workspace/include_manifest_windows_test.go b/internal/workspace/include_manifest_windows_test.go index 4435ea8..d405242 100644 --- a/internal/workspace/include_manifest_windows_test.go +++ b/internal/workspace/include_manifest_windows_test.go @@ -37,9 +37,21 @@ func TestConfigureIncludeManifestCommandLocksManifestIdentity(t *testing.T) { _ = cleanup() t.Fatal("manifest could be replaced while command guard was held") } + if file, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil { + _ = file.Close() + _ = cleanup() + t.Fatal("manifest could be rewritten while command guard was held") + } if err := cleanup(); err != nil { t.Fatalf("release manifest guard: %v", err) } + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open manifest for writing after releasing guard: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close writable manifest: %v", err) + } if err := os.Rename(path, movedPath); err != nil { t.Fatalf("rename manifest after releasing guard: %v", err) } diff --git a/internal/workspace/prepared_checkout_bound.go b/internal/workspace/prepared_checkout_bound.go new file mode 100644 index 0000000..f34b8e2 --- /dev/null +++ b/internal/workspace/prepared_checkout_bound.go @@ -0,0 +1,28 @@ +//go:build linux || windows + +package workspace + +import ( + "context" + "os" +) + +func checkoutPreparedWorktree( + ctx context.Context, + repository repositoryCapability, + worktreePath string, + worktreeRoot *os.Root, + expectedHeadOID string, +) error { + _, err := repository.runWorktreeAt( + ctx, + worktreePath, + worktreeRoot, + "", + "read-tree", + "--reset", + "-u", + expectedHeadOID, + ) + return err +} diff --git a/internal/workspace/prepared_checkout_other.go b/internal/workspace/prepared_checkout_other.go new file mode 100644 index 0000000..5b14989 --- /dev/null +++ b/internal/workspace/prepared_checkout_other.go @@ -0,0 +1,21 @@ +//go:build aix || illumos || js || plan9 || solaris || wasip1 + +package workspace + +import ( + "context" + "errors" + "os" +) + +func checkoutPreparedWorktree( + context.Context, + repositoryCapability, + string, + *os.Root, + string, +) error { + return errors.New( + "workspace: prepared worktree checkout is unsupported on this platform", + ) +} diff --git a/internal/workspace/prepared_checkout_unix.go b/internal/workspace/prepared_checkout_unix.go new file mode 100644 index 0000000..b6c8e96 --- /dev/null +++ b/internal/workspace/prepared_checkout_unix.go @@ -0,0 +1,790 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + preparedCheckoutRegularMode = "100644" + preparedCheckoutExecutableMode = "100755" + preparedCheckoutSymlinkMode = "120000" + preparedCheckoutGitlinkMode = "160000" +) + +type preparedCheckoutEntry struct { + mode string + oid string + path string + temp string +} + +func checkoutPreparedWorktree( + ctx context.Context, + repository repositoryCapability, + _ string, + worktreeRoot *os.Root, + expectedHeadOID string, +) (result error) { + if _, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "read-tree", + "--reset", + expectedHeadOID, + ); err != nil { + return err + } + index, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "ls-files", + "--stage", + "-z", + "--", + ) + if err != nil { + return err + } + entries, err := parsePreparedCheckoutIndex(index) + if err != nil { + return err + } + if err := rejectPreparedCheckoutFilters(ctx, repository, entries); err != nil { + return err + } + + var paths strings.Builder + for _, entry := range entries { + if entry.mode == preparedCheckoutGitlinkMode { + continue + } + paths.WriteString(entry.path) + paths.WriteByte(0) + } + if paths.Len() != 0 { + output, checkoutErr := repository.runPrivateGitAtKeepingOutput( + ctx, + ".", + paths.String(), + "checkout-index", + "--temp", + "--stdin", + "-z", + ) + tempNames := preparedCheckoutTempNames(output) + defer func() { + result = errors.Join( + result, + removePreparedCheckoutTemps(repository.gitRoot, tempNames), + ) + }() + if checkoutErr != nil { + return checkoutErr + } + if err := bindPreparedCheckoutTemps(entries, output); err != nil { + return err + } + } + + for _, entry := range entries { + switch entry.mode { + case preparedCheckoutRegularMode, preparedCheckoutExecutableMode: + if err := installPreparedCheckoutFile( + repository.gitRoot, + worktreeRoot, + entry, + ); err != nil { + return err + } + case preparedCheckoutSymlinkMode: + if err := installPreparedCheckoutSymlink( + ctx, + repository, + worktreeRoot, + entry, + ); err != nil { + return err + } + case preparedCheckoutGitlinkMode: + if err := installPreparedCheckoutGitlink( + worktreeRoot, + entry.path, + ); err != nil { + return err + } + } + } + directory, err := openRecordDirectory(worktreeRoot) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func parsePreparedCheckoutIndex(raw []byte) ([]*preparedCheckoutEntry, error) { + if len(raw) == 0 { + return nil, nil + } + if raw[len(raw)-1] != 0 { + return nil, errors.New( + "workspace: prepared index returned an incomplete entry", + ) + } + records := bytes.Split(raw[:len(raw)-1], []byte{0}) + entries := make([]*preparedCheckoutEntry, 0, len(records)) + seen := make(map[string]struct{}, len(records)) + for _, record := range records { + header, rawPath, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + return nil, errors.New( + "workspace: prepared index returned an invalid entry", + ) + } + fields := strings.Fields(string(header)) + if len(fields) != 3 || fields[2] != "0" { + return nil, errors.New( + "workspace: prepared index returned an unsupported stage", + ) + } + switch fields[0] { + case preparedCheckoutRegularMode, + preparedCheckoutExecutableMode, + preparedCheckoutSymlinkMode, + preparedCheckoutGitlinkMode: + default: + return nil, fmt.Errorf( + "workspace: prepared index mode %q is unsupported", + fields[0], + ) + } + if err := validatePreparedCheckoutOID(fields[1]); err != nil { + return nil, err + } + path, err := validatePreparedCheckoutPath(string(rawPath)) + if err != nil { + return nil, err + } + if _, exists := seen[path]; exists { + return nil, fmt.Errorf( + "workspace: prepared index path %q is duplicated", + path, + ) + } + seen[path] = struct{}{} + entries = append(entries, &preparedCheckoutEntry{ + mode: fields[0], + oid: fields[1], + path: path, + }) + } + return entries, nil +} + +func rejectPreparedCheckoutFilters( + ctx context.Context, + repository repositoryCapability, + entries []*preparedCheckoutEntry, +) error { + expected := make(map[string]struct{}) + var paths strings.Builder + for _, entry := range entries { + if entry.mode != preparedCheckoutRegularMode && + entry.mode != preparedCheckoutExecutableMode { + continue + } + expected[entry.path] = struct{}{} + paths.WriteString(entry.path) + paths.WriteByte(0) + } + if paths.Len() == 0 { + return nil + } + output, err := repository.runPrivateGitAt( + ctx, + ".", + paths.String(), + "check-attr", + "--cached", + "-z", + "--stdin", + "filter", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout filters: %w", + err, + ) + } + if len(output) == 0 || output[len(output)-1] != 0 { + return errors.New( + "workspace: prepared checkout filter query returned incomplete output", + ) + } + fields := bytes.Split(output[:len(output)-1], []byte{0}) + if len(fields)%3 != 0 || len(fields)/3 != len(expected) { + return errors.New( + "workspace: prepared checkout filter query returned invalid output", + ) + } + seen := make(map[string]struct{}, len(expected)) + for index := 0; index < len(fields); index += 3 { + path, err := validatePreparedCheckoutPath(string(fields[index])) + if err != nil { + return err + } + if _, exists := expected[path]; !exists { + return fmt.Errorf( + "workspace: prepared checkout filter query returned unexpected path %q", + path, + ) + } + if _, exists := seen[path]; exists { + return fmt.Errorf( + "workspace: prepared checkout filter query returned path %q twice", + path, + ) + } + seen[path] = struct{}{} + if string(fields[index+1]) != "filter" { + return errors.New( + "workspace: prepared checkout filter query returned an invalid attribute", + ) + } + value := string(fields[index+2]) + if value != "unspecified" && value != "unset" { + return fmt.Errorf( + "workspace: working-tree filter %q for %q is unsupported on this platform", + value, + path, + ) + } + } + return nil +} + +func validatePreparedCheckoutOID(oid string) error { + if len(oid) != 40 && len(oid) != 64 { + return errors.New( + "workspace: prepared index returned an invalid object ID", + ) + } + if _, err := hex.DecodeString(oid); err != nil { + return errors.New( + "workspace: prepared index returned an invalid object ID", + ) + } + return nil +} + +func validatePreparedCheckoutPath(path string) (string, error) { + clean, err := validateIncludedPath(path) + if err != nil || filepath.ToSlash(clean) != path { + return "", fmt.Errorf( + "workspace: prepared index path %q is invalid", + path, + ) + } + first, _, _ := strings.Cut(path, "/") + if strings.EqualFold(first, ".git") { + return "", fmt.Errorf( + "workspace: prepared index path %q targets Git metadata", + path, + ) + } + return clean, nil +} + +func preparedCheckoutTempNames(raw []byte) []string { + seen := make(map[string]struct{}) + var names []string + for _, record := range bytes.Split(raw, []byte{0}) { + rawName, _, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + continue + } + name := string(rawName) + if !validPreparedCheckoutTempName(name) { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + return names +} + +func validPreparedCheckoutTempName(name string) bool { + return filepath.Base(name) == name && + name != "." && + (strings.HasPrefix(name, ".merge_file_") || + strings.HasPrefix(name, ".merge_link_")) +} + +func bindPreparedCheckoutTemps( + entries []*preparedCheckoutEntry, + raw []byte, +) error { + if len(raw) == 0 || raw[len(raw)-1] != 0 { + return errors.New( + "workspace: prepared checkout returned incomplete output", + ) + } + byPath := make(map[string]*preparedCheckoutEntry, len(entries)) + for _, entry := range entries { + byPath[entry.path] = entry + } + seenTemps := make(map[string]struct{}, len(entries)) + for _, record := range bytes.Split(raw[:len(raw)-1], []byte{0}) { + rawTemp, rawPath, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + return errors.New( + "workspace: prepared checkout returned an invalid entry", + ) + } + temp := string(rawTemp) + if !validPreparedCheckoutTempName(temp) { + return errors.New( + "workspace: prepared checkout returned an invalid temporary path", + ) + } + if _, exists := seenTemps[temp]; exists { + return errors.New( + "workspace: prepared checkout reused a temporary path", + ) + } + seenTemps[temp] = struct{}{} + path, err := validatePreparedCheckoutPath(string(rawPath)) + if err != nil { + return err + } + entry, exists := byPath[path] + if !exists || entry.mode == preparedCheckoutGitlinkMode { + return fmt.Errorf( + "workspace: prepared checkout returned unexpected path %q", + path, + ) + } + if entry.temp != "" { + return fmt.Errorf( + "workspace: prepared checkout returned path %q twice", + path, + ) + } + if entry.mode == preparedCheckoutSymlinkMode && + !strings.HasPrefix(temp, ".merge_link_") { + return fmt.Errorf( + "workspace: prepared checkout path %q is not a symlink", + path, + ) + } + if entry.mode != preparedCheckoutSymlinkMode && + !strings.HasPrefix(temp, ".merge_file_") { + return fmt.Errorf( + "workspace: prepared checkout path %q is not a regular file", + path, + ) + } + entry.temp = temp + } + for _, entry := range entries { + if entry.mode != preparedCheckoutGitlinkMode && entry.temp == "" { + return fmt.Errorf( + "workspace: prepared checkout omitted path %q", + entry.path, + ) + } + } + return nil +} + +func removePreparedCheckoutTemps(root *os.Root, names []string) error { + var result error + for _, name := range names { + info, err := root.Lstat(name) + switch { + case errors.Is(err, os.ErrNotExist): + continue + case err != nil: + result = errors.Join(result, err) + continue + case !info.Mode().IsRegular() && + info.Mode()&os.ModeSymlink == 0: + result = errors.Join( + result, + fmt.Errorf( + "workspace: prepared checkout temporary path %q changed type", + name, + ), + ) + continue + } + result = errors.Join(result, root.Remove(name)) + } + return result +} + +func installPreparedCheckoutFile( + gitRoot *os.Root, + worktreeRoot *os.Root, + entry *preparedCheckoutEntry, +) (result error) { + sourceInfo, err := gitRoot.Lstat(entry.temp) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout source for %q: %w", + entry.path, + err, + ) + } + if !sourceInfo.Mode().IsRegular() || + sourceInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: prepared checkout source for %q is not a regular file", + entry.path, + ) + } + input, err := gitRoot.Open(entry.temp) + if err != nil { + return fmt.Errorf( + "workspace: open prepared checkout source for %q: %w", + entry.path, + err, + ) + } + defer func() { + result = errors.Join(result, input.Close()) + }() + openedInfo, err := input.Stat() + if err != nil { + return err + } + if !openedInfo.Mode().IsRegular() || + !os.SameFile(sourceInfo, openedInfo) { + return fmt.Errorf( + "workspace: prepared checkout source for %q changed while opening", + entry.path, + ) + } + + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(entry.path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + name := filepath.Base(entry.path) + mode := os.FileMode(0o666) + if entry.mode == preparedCheckoutExecutableMode { + mode = 0o777 + } + output, err := parent.OpenFile( + name, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + mode, + ) + if err != nil { + return fmt.Errorf( + "workspace: create prepared checkout path %q: %w", + entry.path, + err, + ) + } + outputOpen := true + installed := false + defer func() { + if !installed { + result = errors.Join( + result, + removeRecordPathIfSame(parent, name, output), + ) + } + if outputOpen { + result = errors.Join(result, output.Close()) + } + }() + + copiedDigest := sha256.New() + copied, err := io.Copy(io.MultiWriter(output, copiedDigest), input) + if err != nil { + return fmt.Errorf( + "workspace: copy prepared checkout path %q: %w", + entry.path, + err, + ) + } + afterCopy, err := input.Stat() + if err != nil { + return err + } + if copied != openedInfo.Size() || + afterCopy.Size() != openedInfo.Size() || + afterCopy.Mode() != openedInfo.Mode() || + !afterCopy.ModTime().Equal(openedInfo.ModTime()) { + return fmt.Errorf( + "workspace: prepared checkout source for %q changed while copying", + entry.path, + ) + } + if _, err := input.Seek(0, io.SeekStart); err != nil { + return err + } + verifiedDigest := sha256.New() + verified, err := io.Copy(verifiedDigest, input) + if err != nil { + return err + } + afterVerification, err := input.Stat() + if err != nil { + return err + } + if verified != openedInfo.Size() || + afterVerification.Size() != openedInfo.Size() || + afterVerification.Mode() != openedInfo.Mode() || + !afterVerification.ModTime().Equal(openedInfo.ModTime()) || + !bytes.Equal(copiedDigest.Sum(nil), verifiedDigest.Sum(nil)) { + return fmt.Errorf( + "workspace: prepared checkout source for %q changed while verifying", + entry.path, + ) + } + if err := output.Sync(); err != nil { + return err + } + outputInfo, err := output.Stat() + if err != nil { + return err + } + gotExecutable := outputInfo.Mode().Perm()&0o111 != 0 + wantExecutable := entry.mode == preparedCheckoutExecutableMode + if gotExecutable != wantExecutable { + return fmt.Errorf( + "workspace: prepared checkout mode for %q does not match the index", + entry.path, + ) + } + if err := output.Close(); err != nil { + outputOpen = false + installed = true + return err + } + outputOpen = false + installed = true + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func installPreparedCheckoutSymlink( + ctx context.Context, + repository repositoryCapability, + worktreeRoot *os.Root, + entry *preparedCheckoutEntry, +) (result error) { + tempInfo, err := repository.gitRoot.Lstat(entry.temp) + if err != nil { + return err + } + if !tempInfo.Mode().IsRegular() && + tempInfo.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf( + "workspace: prepared checkout source for %q has an invalid type", + entry.path, + ) + } + target, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "cat-file", + "blob", + entry.oid, + ) + if err != nil { + return err + } + if bytes.IndexByte(target, 0) >= 0 { + return fmt.Errorf( + "workspace: prepared checkout symlink %q contains a null byte", + entry.path, + ) + } + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(entry.path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + if err := unix.Symlinkat( + string(target), + int(directory.Fd()), + filepath.Base(entry.path), + ); err != nil { + _ = directory.Close() + return fmt.Errorf( + "workspace: create prepared checkout symlink %q: %w", + entry.path, + err, + ) + } + info, inspectErr := parent.Lstat(filepath.Base(entry.path)) + if inspectErr == nil && info.Mode()&os.ModeSymlink == 0 { + inspectErr = errors.New("installed path is not a symlink") + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(inspectErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: verify prepared checkout symlink %q: %w", + entry.path, + err, + ) + } + return nil +} + +func installPreparedCheckoutGitlink( + worktreeRoot *os.Root, + path string, +) (result error) { + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + name := filepath.Base(path) + if err := parent.Mkdir(name, 0o777); err != nil { + return fmt.Errorf( + "workspace: create prepared checkout gitlink %q: %w", + path, + err, + ) + } + info, err := parent.Lstat(name) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: prepared checkout gitlink %q is not a real directory", + path, + ) + } + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func openPreparedCheckoutParent( + root *os.Root, + path string, +) (_ *os.Root, _ bool, result error) { + current := root + owned := false + defer func() { + if result != nil && owned { + result = errors.Join(result, current.Close()) + } + }() + for _, component := range strings.Split( + path, + string(filepath.Separator), + ) { + if component == "." || component == "" { + continue + } + info, err := current.Lstat(component) + switch { + case errors.Is(err, os.ErrNotExist): + if err := current.Mkdir(component, 0o777); err != nil { + return nil, false, fmt.Errorf( + "workspace: create prepared checkout directory %q: %w", + component, + err, + ) + } + directory, openErr := openRecordDirectory(current) + if openErr != nil { + return nil, false, openErr + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return nil, false, err + } + info, err = current.Lstat(component) + if err != nil { + return nil, false, err + } + case err != nil: + return nil, false, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return nil, false, fmt.Errorf( + "workspace: prepared checkout parent %q is not a real directory", + component, + ) + } + next, err := openRealRootFromRoot(current, component) + if err != nil { + return nil, false, err + } + if owned { + if err := current.Close(); err != nil { + _ = next.Close() + return nil, false, err + } + } + current = next + owned = true + } + return current, owned, nil +} diff --git a/internal/workspace/prepared_checkout_unix_test.go b/internal/workspace/prepared_checkout_unix_test.go new file mode 100644 index 0000000..6b912ff --- /dev/null +++ b/internal/workspace/prepared_checkout_unix_test.go @@ -0,0 +1,202 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPreparedCheckoutMaterializesTrackedEntryTypes(t *testing.T) { + repository := newTestRepository(t) + executable := filepath.Join(repository, "run.sh") + if err := os.WriteFile( + executable, + []byte("#!/bin/sh\nexit 0\n"), + 0o700, + ); err != nil { + t.Fatalf("write executable: %v", err) + } + if err := os.Symlink( + "tracked.txt", + filepath.Join(repository, "relative-link"), + ); err != nil { + t.Skipf("create relative symlink: %v", err) + } + absoluteTarget := filepath.Join(t.TempDir(), "outside") + if err := os.Symlink( + absoluteTarget, + filepath.Join(repository, "absolute-link"), + ); err != nil { + t.Skipf("create absolute symlink: %v", err) + } + runGit( + t, + repository, + "add", + "run.sh", + "relative-link", + "absolute-link", + ) + runGit(t, repository, "update-index", "--chmod=+x", "run.sh") + runGit(t, repository, "commit", "-m", "add tracked entry types") + head := strings.TrimSpace(runGitOutput(t, repository, "rev-parse", "HEAD")) + runGit( + t, + repository, + "update-index", + "--add", + "--cacheinfo", + "160000,"+head+",modules/sub", + ) + runGit(t, repository, "commit", "-m", "add gitlink") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + content, err := os.ReadFile(filepath.Join(prepared.Path, "tracked.txt")) + if err != nil { + t.Fatalf("read tracked file: %v", err) + } + if string(content) != "tracked\n" { + t.Fatalf("tracked file = %q", content) + } + info, err := os.Stat(filepath.Join(prepared.Path, "run.sh")) + if err != nil { + t.Fatalf("inspect executable: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("executable mode = %v", info.Mode()) + } + relative, err := os.Readlink( + filepath.Join(prepared.Path, "relative-link"), + ) + if err != nil { + t.Fatalf("read relative symlink: %v", err) + } + if relative != "tracked.txt" { + t.Fatalf("relative symlink = %q", relative) + } + absolute, err := os.Readlink( + filepath.Join(prepared.Path, "absolute-link"), + ) + if err != nil { + t.Fatalf("read absolute symlink: %v", err) + } + if absolute != absoluteTarget { + t.Fatalf("absolute symlink = %q", absolute) + } + gitlink, err := os.Lstat( + filepath.Join(prepared.Path, "modules", "sub"), + ) + if err != nil { + t.Fatalf("inspect gitlink: %v", err) + } + if !gitlink.IsDir() || gitlink.Mode()&os.ModeSymlink != 0 { + t.Fatalf("gitlink mode = %v", gitlink.Mode()) + } +} + +func TestPreparedCheckoutRejectsWorkingTreeFilter(t *testing.T) { + repository := newTestRepository(t) + sentinel := filepath.Join(t.TempDir(), "filter-ran") + filter := filepath.Join(t.TempDir(), "filter") + if err := os.WriteFile( + filter, + []byte("#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n"), + 0o700, + ); err != nil { + t.Fatalf("write smudge filter: %v", err) + } + t.Setenv("DROVE_TEST_SENTINEL", sentinel) + runGit(t, repository, "config", "filter.review.smudge", filter) + runGit(t, repository, "config", "filter.review.clean", "cat") + runGit(t, repository, "config", "filter.review.required", "true") + if err := os.WriteFile( + filepath.Join(repository, ".gitattributes"), + []byte("tracked.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, repository, "add", ".gitattributes") + runGit(t, repository, "commit", "-m", "add checkout filter") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil || !strings.Contains( + err.Error(), + "working-tree filter", + ) { + t.Fatalf("prepare filtered worktree error = %v", err) + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected smudge filter ran: %v", err) + } +} + +func TestParsePreparedCheckoutIndexRejectsInvalidEntries(t *testing.T) { + validOID := strings.Repeat("0", 40) + tests := map[string]string{ + "incomplete": preparedCheckoutRegularMode + " " + validOID + " 0\tfile", + "nonzero stage": preparedCheckoutRegularMode + " " + validOID + " 1\tfile\x00", + "unsupported mode": "100600 " + validOID + " 0\tfile\x00", + "invalid oid": preparedCheckoutRegularMode + " xyz 0\tfile\x00", + "escaping path": preparedCheckoutRegularMode + " " + validOID + " 0\t../file\x00", + "git metadata": preparedCheckoutRegularMode + " " + validOID + " 0\t.GIT/config\x00", + "duplicate path": preparedCheckoutRegularMode + " " + validOID + " 0\tfile\x00" + + preparedCheckoutRegularMode + " " + validOID + " 0\tfile\x00", + } + for name, raw := range tests { + t.Run(name, func(t *testing.T) { + if _, err := parsePreparedCheckoutIndex([]byte(raw)); err == nil { + t.Fatal("invalid prepared index entry was accepted") + } + }) + } +} + +func runGitOutput( + t *testing.T, + repository string, + arguments ...string, +) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + t.Fatalf( + "git %v: %v\n%s", + arguments, + err, + exitErr.Stderr, + ) + } + t.Fatalf("git %v: %v", arguments, err) + } + return string(output) +} diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 337c62a..8e907c0 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -296,14 +296,11 @@ func (m *Manager) initializePreparedWorktree( if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { return err } - if _, err := privateRepository.runWorktreeAt( + if err := checkoutPreparedWorktree( ctx, + privateRepository, prepared.path, root, - "", - "read-tree", - "--reset", - "-u", target.expectedHeadOID, ); err != nil { return fmt.Errorf("workspace: checkout prepared worktree: %w", err) diff --git a/internal/workspace/record.go b/internal/workspace/record.go index f1391ff..392c0f5 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -646,6 +646,14 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: quarantined removal has not been started", ) } + if record.Version >= workspaceRecordVersion && + record.Removal.Started && + !record.Removal.Quarantined && + !record.Removal.PathAbsent { + return errors.New( + "workspace: started removal has no quarantined or absent path", + ) + } if record.Removal.ContentsCleared && (!record.Removal.Started || !record.Removal.Quarantined) { return errors.New( diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 92f5367..36f8c63 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1805,6 +1805,59 @@ func TestReconcileContentsClearedRemovalRejectsUnexpectedEntry(t *testing.T) { assertFileContents(t, unexpected, "preserve\n") } +func TestPersistRemovalStartRecordsAbsentPath(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "59595959-5959-4959-8959-595959595959", + DirectoryToken: "60606060-6060-4060-8060-606060606060", + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist removal intent: %v", err) + } + savedPath := filepath.Join(t.TempDir(), "saved-worktree") + if err := os.Rename(prepared.Path, savedPath); err != nil { + t.Fatalf("move workspace out of managed path: %v", err) + } + started, err := manager.persistRemovalStart(record, false) + if err != nil { + t.Fatalf("persist absent-path removal start: %v", err) + } + if !started.Removal.Started || !started.Removal.PathAbsent { + t.Fatalf("started removal = %+v", started.Removal) + } + if err := os.Rename(savedPath, prepared.Path); err != nil { + t.Fatalf("restore workspace path: %v", err) + } + if _, err := manager.ReconcileRemovals( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "appeared after removal began") { + t.Fatalf("reconcile restored absent path error = %v", err) + } + if _, err := os.Lstat(prepared.Path); err != nil { + t.Fatalf("restored workspace was removed: %v", err) + } +} + func TestReconcileAbsentPathRemovalPreservesReplacementPath(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -2142,6 +2195,7 @@ func TestStaleAcknowledgementPreservesNewWorkspaceRecord(t *testing.T) { DirectoryToken: "90909090-9090-4090-8090-909090909090", Force: true, Started: true, + PathAbsent: true, } if err := manager.installWorkspaceRecord(replacement, true); err != nil { t.Fatalf("install replacement record: %v", err) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 951bf05..5217727 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -793,6 +793,9 @@ func (m *Manager) persistRemovalStart( started := *record.Removal started.Started = true started.Quarantined = started.Quarantined || quarantined + if !quarantined { + started.PathAbsent = true + } upgradeWorkspaceRecord(&record) record.Removal = &started _, err := m.replaceWorkspaceRecordState(record) @@ -1008,6 +1011,40 @@ func (m *Manager) removeRemovalQuarantine( if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { return err } + openedInfo, err := opened.Stat(".") + if err != nil { + return err + } + deleteName := removalQuarantinePrefix(record) + uuid.NewString() + directory, err := openRecordDirectory(bucket) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + openedInfo, + name, + removalQuarantineIsolationName(deleteName), + deleteName, + ) + closeDirectoryErr := directory.Close() + if moved { + name = deleteName + } + if err := errors.Join(renameErr, closeDirectoryErr); err != nil { + return fmt.Errorf( + "workspace: isolate cleared removal quarantine: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: cleared removal quarantine was not isolated", + ) + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } closeErr := opened.Close() openedOwned = false if closeErr != nil { diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 8157ac2..88d88e1 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -224,11 +224,59 @@ func (r repositoryCapability) runPrivateGit( input string, arguments ...string, ) ([]byte, error) { - command, cleanup, err := r.privateGitCommand(ctx, arguments...) + return r.runPrivateGitAt(ctx, r.path, input, arguments...) +} + +func (r repositoryCapability) runPrivateGitAt( + ctx context.Context, + worktreePath string, + input string, + arguments ...string, +) ([]byte, error) { + return r.runPrivateGitAtWithOutputPolicy( + ctx, + worktreePath, + input, + false, + arguments..., + ) +} + +func (r repositoryCapability) runPrivateGitAtKeepingOutput( + ctx context.Context, + worktreePath string, + input string, + arguments ...string, +) ([]byte, error) { + return r.runPrivateGitAtWithOutputPolicy( + ctx, + worktreePath, + input, + true, + arguments..., + ) +} + +func (r repositoryCapability) runPrivateGitAtWithOutputPolicy( + ctx context.Context, + worktreePath string, + input string, + keepOutputOnError bool, + arguments ...string, +) ([]byte, error) { + command, cleanup, err := r.privateGitCommandAt( + ctx, + worktreePath, + arguments..., + ) if err != nil { return nil, err } - output, commandErr := runGitCommand(command, input) + run := runGitCommand + if keepOutputOnError { + run = runGitCommandKeepingOutput + } + output, commandErr := run(command, input) return output, errors.Join(commandErr, cleanup()) } @@ -393,12 +441,20 @@ func (r repositoryCapability) worktreeCommandAt( func (r repositoryCapability) privateGitCommand( ctx context.Context, arguments ...string, +) (*exec.Cmd, func() error, error) { + return r.privateGitCommandAt(ctx, r.path, arguments...) +} + +func (r repositoryCapability) privateGitCommandAt( + ctx context.Context, + worktreePath string, + arguments ...string, ) (*exec.Cmd, func() error, error) { if r.root != nil && r.gitRoot != nil { return rootedPrivateGitCommand( ctx, r.manager.git, - r.path, + worktreePath, r.gitPath, r.gitRoot, arguments, diff --git a/internal/workspace/repository_guard_windows.go b/internal/workspace/repository_guard_windows.go index aaff520..f807cbf 100644 --- a/internal/workspace/repository_guard_windows.go +++ b/internal/workspace/repository_guard_windows.go @@ -39,7 +39,8 @@ func openRepositoryGuard( windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS, + windows.FILE_FLAG_BACKUP_SEMANTICS| + windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, ) if err != nil { @@ -48,6 +49,23 @@ func openRepositoryGuard( err, ) } + var handleInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + handle, + &handleInfo, + ); err != nil { + _ = windows.CloseHandle(handle) + return nil, fmt.Errorf( + "workspace: inspect rooted Git directory handle: %w", + err, + ) + } + if handleInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return nil, errors.New( + "workspace: rooted Git directory is a reparse point", + ) + } lock := os.NewFile(uintptr(handle), path) if lock == nil { _ = windows.CloseHandle(handle) diff --git a/internal/workspace/repository_guard_windows_test.go b/internal/workspace/repository_guard_windows_test.go new file mode 100644 index 0000000..5b00e37 --- /dev/null +++ b/internal/workspace/repository_guard_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestOpenRepositoryGuardRejectsDirectoryJunction(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "target") + junction := filepath.Join(parent, "junction") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("create target directory: %v", err) + } + command := exec.Command("cmd", "/c", "mklink", "/J", junction, target) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf( + "create directory junction: %v: %s", + err, + strings.TrimSpace(string(output)), + ) + } + root, err := openRealPathRoot(target) + if err != nil { + t.Fatalf("open target root: %v", err) + } + defer root.Close() + + guard, err := openRepositoryGuard(junction, root) + if guard != nil { + _ = guard.Close() + } + if err == nil || !strings.Contains(err.Error(), "reparse point") { + t.Fatalf("guard directory junction error = %v", err) + } +} diff --git a/internal/workspace/rooted_git.go b/internal/workspace/rooted_git.go index 63fc3ba..6fd6a0f 100644 --- a/internal/workspace/rooted_git.go +++ b/internal/workspace/rooted_git.go @@ -104,6 +104,17 @@ func (m *Manager) runRootedGitInputWithPolicy( } func runGitCommand(command *exec.Cmd, input string) ([]byte, error) { + output, err := runGitCommandKeepingOutput(command, input) + if err != nil { + return nil, err + } + return output, nil +} + +func runGitCommandKeepingOutput( + command *exec.Cmd, + input string, +) ([]byte, error) { command.Env = append(command.Environ(), "LC_ALL=C", "LANG=C") if input != "" { command.Stdin = strings.NewReader(input) @@ -119,10 +130,10 @@ func runGitCommand(command *exec.Cmd, input string) ([]byte, error) { detail = detail[:4096] } if detail != "" { - return nil, fmt.Errorf("%s: %w", detail, err) + return output, fmt.Errorf("%s: %w", detail, err) } } - return nil, err + return output, err } func rootedGitEnvironment(environment []string) []string { diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 82616c7..8bde990 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -16,8 +16,6 @@ func rootedGitCommand( _ *os.Root, _ string, _ *os.Root, - _ string, - _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index e9a3941..a23f68f 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -2282,34 +2282,89 @@ exit "$status" } } -func TestPrepareRejectsCheckoutThroughTransientPrivateGitReplacement( +func TestPrepareIgnoresTransientPrivateGitReplacementDuringCheckout( t *testing.T, ) { - if runtime.GOOS == "linux" { - t.Skip("Linux binds the private Git directory through /proc/self/fd") + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + default: + t.Skip("test covers descriptor-rooted BSD checkout") } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) + if err := os.WriteFile( + filepath.Join(source, ".gitattributes"), + []byte("tracked.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, source, "add", ".gitattributes") + runGit(t, source, "commit", "-m", "add checkout filter attributes") realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("find Git: %v", err) } + sentinel := filepath.Join(parent, "replacement-filter-ran") + filter := filepath.Join(parent, "replacement-filter") + filterScript := "#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n" + if err := os.WriteFile(filter, []byte(filterScript), 0o700); err != nil { + t.Fatalf("write replacement filter: %v", err) + } + maliciousCommon := filepath.Join(parent, "malicious-common") + if err := os.CopyFS(maliciousCommon, os.DirFS(filepath.Join(source, ".git"))); err != nil { + t.Fatalf("copy malicious common Git directory: %v", err) + } + for _, arguments := range [][]string{ + {"--git-dir=" + maliciousCommon, "config", "filter.review.smudge", filter}, + {"--git-dir=" + maliciousCommon, "config", "filter.review.clean", "cat"}, + {"--git-dir=" + maliciousCommon, "config", "filter.review.required", "true"}, + } { + command := exec.Command(realGit, arguments...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf( + "configure malicious common Git directory: %v\n%s", + err, + output, + ) + } + } marker := filepath.Join(parent, "swapped-private-git") + gitPath := filepath.Join(parent, "prepared-private-git-path") wrapper := filepath.Join(parent, "git-wrapper-transient-private-git") script := `#!/bin/sh -matched= +worktree= +add= +no_checkout= +checkout= for argument in "$@"; do - [ "$argument" = "read-tree" ] && matched=1 + [ "$argument" = "worktree" ] && worktree=1 + [ "$argument" = "add" ] && add=1 + [ "$argument" = "--no-checkout" ] && no_checkout=1 + [ "$argument" = "read-tree" ] && checkout=1 done -if [ -n "$matched" ] && [ ! -e "$DROVE_TEST_MARKER" ]; then - original="$GIT_DIR.drove-original" - mv "$GIT_DIR" "$original" || exit 91 - cp -R "$original" "$GIT_DIR" || exit 92 +if [ -n "$worktree" ] && [ -n "$add" ] && [ -n "$no_checkout" ]; then + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + if [ "$status" -eq 0 ]; then + sed 's/^gitdir: //' .git > "$DROVE_TEST_GIT_PATH" || exit 90 + fi + exit "$status" +fi +if [ -n "$checkout" ] && [ -s "$DROVE_TEST_GIT_PATH" ] && [ ! -e "$DROVE_TEST_MARKER" ]; then + public=$(cat "$DROVE_TEST_GIT_PATH") || exit 91 + original="$public.drove-original" + mv "$public" "$original" || exit 92 + cp -R "$original" "$public" || exit 93 + printf '%s\n' "$DROVE_TEST_MALICIOUS_COMMON" > "$public/commondir" || exit 94 "$DROVE_TEST_REAL_GIT" "$@" status=$? - rm -rf "$GIT_DIR" || exit 93 - mv "$original" "$GIT_DIR" || exit 94 + if [ "$GIT_DIR" != "." ] && [ -f "$public/index" ]; then + cp "$public/index" "$original/index" || exit 95 + fi + rm -rf "$public" || exit 96 + mv "$original" "$public" || exit 97 : > "$DROVE_TEST_MARKER" exit "$status" fi @@ -2320,6 +2375,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } t.Setenv("DROVE_TEST_MARKER", marker) t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_GIT_PATH", gitPath) + t.Setenv("DROVE_TEST_MALICIOUS_COMMON", maliciousCommon) + t.Setenv("DROVE_TEST_SENTINEL", sentinel) manager, err := New(filepath.Join(parent, "data")) if err != nil { @@ -2331,9 +2389,20 @@ exec "$DROVE_TEST_REAL_GIT" "$@" source, "", testAgentID, - ); err == nil || - !strings.Contains(err.Error(), "prepared index does not match") { - t.Fatalf("prepare through transient private Git replacement error = %v", err) + ); err == nil || !strings.Contains( + err.Error(), + "working-tree filter", + ) { + t.Fatalf( + "prepare through transient private Git replacement error = %v", + err, + ) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("transient private Git replacement did not run: %v", err) + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement checkout filter ran: %v", err) } } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 9a3146e..862511f 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1909,6 +1909,7 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( OperationID: "12121212-1212-4212-8212-121212121212", DirectoryToken: "13131313-1313-4313-8313-131313131313", Started: true, + Quarantined: true, } root, err := openRealPathRoot(prepared.Path) if err != nil { @@ -1921,11 +1922,18 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if err := root.Close(); err != nil { t.Fatalf("close prepared workspace: %v", err) } + quarantinePath := filepath.Join( + filepath.Dir(prepared.Path), + removalQuarantinePrefix(record)+"14141414-1414-4414-8414-141414141414", + ) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine prepared workspace: %v", err) + } if err := manager.replaceWorkspaceRecord(record); err != nil { t.Fatalf("write started removal: %v", err) } if err := os.WriteFile( - filepath.Join(prepared.Path, "tracked.txt"), + filepath.Join(quarantinePath, "tracked.txt"), []byte("changed after physical removal started\n"), 0o600, ); err != nil { @@ -1943,6 +1951,9 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("started removal retained path or inspect failed: %v", err) } + if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("started removal retained quarantine or inspect failed: %v", err) + } if err := manager.AcknowledgeRemoval(removals[0]); err != nil { t.Fatalf("acknowledge started removal: %v", err) } From a87c002fb7cde9448e2a598b46d829cc26cecb17 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 14:14:44 +0800 Subject: [PATCH 020/104] fix: close final workspace identity races --- internal/workspace/include.go | 22 +-- internal/workspace/include_security_test.go | 136 +++++++++++++++++- internal/workspace/prepared_worktree.go | 11 ++ internal/workspace/record.go | 53 +++++-- .../workspace/record_removal_security_test.go | 46 ++++++ .../workspace/record_rename_legacy_unix.go | 21 +++ internal/workspace/record_rename_other.go | 21 +++ internal/workspace/record_rename_windows.go | 70 +++++++++ internal/workspace/recovery_security_test.go | 57 ++++++++ internal/workspace/workspace.go | 3 + 10 files changed, 411 insertions(+), 29 deletions(-) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 21019e6..6d47f54 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -46,7 +46,6 @@ func (m *Manager) copyIncludedSelection( return nil, err } copiedPaths, err := m.copyIncludedFiles( - ctx, sourceRoot, target, selection.paths, @@ -67,7 +66,6 @@ func (m *Manager) copyIncludedSelection( } func (m *Manager) copyIncludedFiles( - ctx context.Context, source *os.Root, target Workspace, paths []string, @@ -123,20 +121,14 @@ func (m *Manager) copyIncludedFiles( ) } - trackedPaths := map[string]struct{}{} - if len(paths) > 0 { - trackedPaths, err = m.worktreeTrackedPaths( - ctx, - target.Path, - destination, + if len(paths) > 0 && target.trackedPaths == nil { + return nil, errors.New( + "workspace: target tracked-path snapshot is unavailable", ) - if err != nil { - return nil, err - } } copiedPaths := make([]string, 0, len(paths)) for _, relative := range paths { - if _, tracked := trackedPaths[filepath.ToSlash(relative)]; tracked { + if _, tracked := target.trackedPaths[filepath.ToSlash(relative)]; tracked { continue } if err := copyIncludedPath( @@ -182,12 +174,12 @@ func (m *Manager) copyIncludedFiles( func (m *Manager) worktreeTrackedPaths( ctx context.Context, path string, - root *os.Root, + repository repositoryCapability, ) (map[string]struct{}, error) { - output, err := m.runRootedGit( + output, err := repository.runPrivateGitAt( ctx, path, - root, + "", "--literal-pathspecs", "ls-files", "--cached", diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index afaaf66..a68a09b 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -181,7 +181,8 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { repositoryHash(repository), testAgentID, ), - Branch: "pinned-source", + Branch: "pinned-source", + trackedPaths: map[string]struct{}{}, } if err := os.Mkdir(target.Path, 0o700); err != nil { t.Fatalf("create destination: %v", err) @@ -215,7 +216,6 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { } if _, err := manager.copyIncludedFiles( - context.Background(), source, target, []string{"secret"}, @@ -262,7 +262,8 @@ func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { repositoryHash(repository), testAgentID, ), - Branch: "replacement-target", + Branch: "replacement-target", + trackedPaths: map[string]struct{}{}, } if err := os.Mkdir(target.Path, 0o700); err != nil { t.Fatalf("create destination: %v", err) @@ -286,7 +287,6 @@ func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { } if _, err := manager.copyIncludedFiles( - context.Background(), source, target, []string{"secret"}, @@ -705,3 +705,131 @@ exec "$DROVE_TEST_REAL_GIT" "$@" ) } } + +func TestPrepareSnapshotsTrackedPathsFromPrivateGitDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + parent := t.TempDir() + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files") + + replacement := filepath.Join(parent, "replacement") + runGit(t, repository, "branch", "tracked-replacement", "HEAD") + runGit( + t, + repository, + "worktree", + "add", + "--quiet", + replacement, + "tracked-replacement", + ) + const includedPath = "secret.local" + if err := os.WriteFile( + filepath.Join(replacement, includedPath), + []byte("tracked replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement tracked file: %v", err) + } + runGit(t, replacement, "add", "-f", includedPath) + runGit(t, replacement, "commit", "-m", "track replacement local file") + + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte(includedPath+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + if err := os.WriteFile( + filepath.Join(repository, includedPath), + []byte("source local\n"), + 0o600, + ); err != nil { + t.Fatalf("write source included file: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + marker := filepath.Join(parent, "tracked-path-swap-ran") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +saw_ls= +saw_cached= +saw_full_name= +for argument in "$@"; do + [ "$argument" = "ls-files" ] && saw_ls=1 + [ "$argument" = "--cached" ] && saw_cached=1 + [ "$argument" = "--full-name" ] && saw_full_name=1 +done +if [ -n "$saw_ls" ] && [ -n "$saw_cached" ] && + [ -n "$saw_full_name" ]; then + worktree=$GIT_WORK_TREE + if [ -z "$worktree" ]; then + if [ -d /proc/self/fd/3 ]; then + worktree=$(readlink /proc/self/fd/3) || exit 90 + else + worktree=$(pwd -P) || exit 91 + fi + fi + pointer="$worktree/.git" + opened="$worktree/.git.drove-test-original" + mv "$pointer" "$opened" || exit 92 + cp "$DROVE_TEST_REPLACEMENT_POINTER" "$pointer" || exit 93 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + rm -f "$pointer" || exit 94 + mv "$opened" "$pointer" || exit 95 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv( + "DROVE_TEST_REPLACEMENT_POINTER", + filepath.Join(replacement, ".git"), + ) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_MARKER", marker) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + t.Cleanup(func() { + _ = manager.Discard(context.Background(), prepared) + }) + if _, err := os.Stat(marker); err != nil { + t.Fatalf("tracked-path pointer swap did not run: %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, includedPath), + "source local\n", + ) +} diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 8e907c0..0ca58c8 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -197,6 +197,7 @@ func (m *Manager) initializePreparedWorktree( target *Workspace, prepared *preparedWorktreeTarget, repository repositoryCapability, + includedPaths []string, ) (result error) { root := prepared.root directoryIdentity, err := openedDirectoryIdentity(root) @@ -335,6 +336,16 @@ func (m *Manager) initializePreparedWorktree( "workspace: prepared index does not match expected tree", ) } + if len(includedPaths) > 0 { + target.trackedPaths, err = m.worktreeTrackedPaths( + ctx, + prepared.path, + privateRepository, + ) + if err != nil { + return err + } + } if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { return err } diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 392c0f5..a84915e 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -342,24 +342,40 @@ func removeRecordPathIfSame( name string, expected *os.File, ) error { - current, err := root.Lstat(name) + return removeRecordPathIfSameAfterValidation( + root, + name, + expected, + nil, + ) +} + +func removeRecordPathIfSameAfterValidation( + root *os.Root, + name string, + expected *os.File, + afterValidation func(), +) (result error) { + _, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } - opened, err := expected.Stat() + directory, err := openRecordDirectory(root) if err != nil { return err } - if !os.SameFile(opened, current) { - return fmt.Errorf( - "workspace: temporary path %q changed identity", - name, - ) - } - return root.Remove(name) + defer func() { + result = errors.Join(result, directory.Close()) + }() + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + afterValidation, + ) } func isolateRecordPathIfSame( @@ -898,6 +914,16 @@ func (m *Manager) removeWorkspaceRecordAfterValidation( func (m *Manager) removeAcknowledgedWorkspaceRecord( removal Removal, +) error { + return m.removeAcknowledgedWorkspaceRecordAfterValidation( + removal, + nil, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordAfterValidation( + removal Removal, + afterValidation func(), ) (result error) { recordPath := workspaceRecordPath(removal.Workspace.Path) bucket, agentID, err := m.openRecordBucket(removal.Workspace.Path) @@ -912,8 +938,15 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( ) } defer func() { - result = errors.Join(result, bucket.Close()) + result = errors.Join( + result, + m.verifyRecordBucket(removal.Workspace.Path, bucket), + bucket.Close(), + ) }() + if afterValidation != nil { + afterValidation() + } quarantineName, exists, err := findRecordAcknowledgementQuarantine( bucket, diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go index b775c9b..c813347 100644 --- a/internal/workspace/record_removal_security_test.go +++ b/internal/workspace/record_removal_security_test.go @@ -6,6 +6,52 @@ import ( "testing" ) +func TestRemoveRecordPathPreservesReplacementAfterValidation(t *testing.T) { + rootPath := t.TempDir() + recordPath := filepath.Join(rootPath, "temporary") + if err := os.WriteFile( + recordPath, + []byte("original\n"), + 0o600, + ); err != nil { + t.Fatalf("write temporary record: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + expected, err := root.Open("temporary") + if err != nil { + t.Fatalf("open temporary record: %v", err) + } + defer expected.Close() + originalPath := recordPath + ".original" + + err = removeRecordPathIfSameAfterValidation( + root, + "temporary", + expected, + func() { + if err := os.Rename(recordPath, originalPath); err != nil { + t.Fatalf("move validated temporary record: %v", err) + } + if err := os.WriteFile( + recordPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement temporary record: %v", err) + } + }, + ) + if err == nil { + t.Fatal("temporary record removal accepted a replacement path") + } + assertFileContents(t, recordPath, "replacement\n") + assertFileContents(t, originalPath, "original\n") +} + func TestRemoveWorkspaceRecordPreservesReplacementAfterValidation(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { diff --git a/internal/workspace/record_rename_legacy_unix.go b/internal/workspace/record_rename_legacy_unix.go index c7edc76..28cf2f8 100644 --- a/internal/workspace/record_rename_legacy_unix.go +++ b/internal/workspace/record_rename_legacy_unix.go @@ -30,6 +30,27 @@ func moveRecordFile( ) } +func unlinkRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func unlinkRecordPathAfterValidation( + _ *os.File, + _ *os.File, + _ string, + _ func(), +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + func openRecordDirectory(root *os.Root) (*os.File, error) { return root.Open(".") } diff --git a/internal/workspace/record_rename_other.go b/internal/workspace/record_rename_other.go index d294c32..3e18538 100644 --- a/internal/workspace/record_rename_other.go +++ b/internal/workspace/record_rename_other.go @@ -30,6 +30,27 @@ func moveRecordFile( ) } +func unlinkRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func unlinkRecordPathAfterValidation( + _ *os.File, + _ *os.File, + _ string, + _ func(), +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + func openRecordDirectory(root *os.Root) (*os.File, error) { return root.Open(".") } diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 139d5e4..f6fb0c6 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -71,6 +71,76 @@ func moveRecordFile( ) } +func unlinkRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + nil, + ) +} + +func unlinkRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + afterValidation func(), +) (result error) { + deleting, err := openRecordForRename(directory, name) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, deleting.Close()) + }() + expectedInfo, err := expected.Stat() + if err != nil { + return fmt.Errorf("inspect opened record: %w", err) + } + deletingInfo, err := deleting.Stat() + if err != nil { + return fmt.Errorf("inspect record path %q: %w", name, err) + } + if !deletingInfo.Mode().IsRegular() || + !os.SameFile(expectedInfo, deletingInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + if afterValidation != nil { + afterValidation() + } + + current, err := openRecordForRename(directory, name) + if err != nil { + return fmt.Errorf("reopen record path %q: %w", name, err) + } + currentInfo, statErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return fmt.Errorf("reinspect record path %q: %w", name, err) + } + if !currentInfo.Mode().IsRegular() || + !os.SameFile(expectedInfo, currentInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + + deleteFile := byte(1) + var status windows.IO_STATUS_BLOCK + if err := windows.NtSetInformationFile( + windows.Handle(deleting.Fd()), + &status, + &deleteFile, + 1, + windows.FileDispositionInformation, + ); err != nil { + return fmt.Errorf("remove record path %q: %w", name, err) + } + return nil +} + func renameWindowsHandle( directory *os.File, renaming *os.File, diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 36f8c63..c044a3c 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -355,6 +355,63 @@ func TestAcknowledgeRemovalRejectsReplacedRepositoryBucket(t *testing.T) { } } +func TestAcknowledgedRecordRemovalDetectsBucketReplacementAfterOpening( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + bucketPath := filepath.Dir(prepared.Path) + openedBucketPath := bucketPath + "-opened" + replacementSentinel := filepath.Join(bucketPath, "must-remain") + t.Cleanup(func() { + _ = os.RemoveAll(bucketPath) + _ = os.Rename(openedBucketPath, bucketPath) + }) + err = manager.removeAcknowledgedWorkspaceRecordAfterValidation( + result.Removal, + func() { + if err := os.Rename(bucketPath, openedBucketPath); err != nil { + t.Fatalf("move opened repository bucket: %v", err) + } + if err := os.Mkdir(bucketPath, 0o700); err != nil { + t.Fatalf("create replacement repository bucket: %v", err) + } + if err := os.WriteFile( + replacementSentinel, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + }, + ) + if err == nil { + t.Fatal("acknowledged record removal missed a replaced repository bucket") + } + assertFileContents(t, replacementSentinel, "replacement\n") +} + func TestAcknowledgeRemovalPreservesRecordWhenRepositoryDisappears( t *testing.T, ) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 02f4781..7ecdea0 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -54,6 +54,7 @@ type Workspace struct { directoryIdentity string protectionKnown bool includedPaths []string + trackedPaths map[string]struct{} preparation *preparationLease repositoryEvidence *repositoryEvidence } @@ -429,6 +430,7 @@ func (m *Manager) prepare( &result, preparedTarget, sourceRepository, + includedPaths, ); err != nil { return Workspace{}, err } @@ -454,6 +456,7 @@ func (m *Manager) prepare( result, includeSelection, ) + result.trackedPaths = nil if err != nil { return Workspace{}, err } From 7a900a288e1144d8ae69d1b9de4150cafdc463cd Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 14:59:56 +0800 Subject: [PATCH 021/104] fix: harden recovery and git bindings --- cmd/drove/main.go | 3 + cmd/drove/main_test.go | 19 +++++ internal/session/projection.go | 12 ++- internal/session/projection_test.go | 20 +++++ internal/workspace/include.go | 4 + internal/workspace/include_security_test.go | 7 ++ internal/workspace/repository_capability.go | 7 ++ .../rooted_git_descriptor_unix_test.go | 50 +++++++++++++ internal/workspace/rooted_git_linux.go | 25 ++++++- internal/workspace/rooted_git_other.go | 2 + internal/workspace/rooted_git_test.go | 74 +++++++++++++++++++ internal/workspace/rooted_git_unix.go | 25 ++++++- internal/workspace/rooted_git_windows.go | 19 ++++- 13 files changed, 253 insertions(+), 14 deletions(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index a09b229..97369df 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -116,6 +116,9 @@ func validateDefaultHelpArgs(root *cobra.Command, args []string) error { afterTerminator = true continue } + if argument == "-h" || argument == "--help" { + return nil + } if strings.HasPrefix(argument, "-") { continue } diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 407f4be..0e60ddb 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -331,6 +331,25 @@ func TestExecuteRootSupportsDefaultCommands(t *testing.T) { args: []string{"help", "worktree", "--help"}, wantOutput: "Usage:", }, + { + name: "help flag overrides invalid topic", + args: []string{ + "help", + "--help", + "definitely-not-a-command", + }, + wantOutput: "Usage:", + }, + { + name: "target help flag overrides invalid topic", + args: []string{ + "help", + "worktree", + "-h", + "definitely-not-a-command", + }, + wantOutput: "Usage:", + }, { name: "completion", args: []string{"completion", "bash"}, diff --git a/internal/session/projection.go b/internal/session/projection.go index 15ebf72..d22aca5 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -60,6 +60,7 @@ type sessionDraft struct { workspace *workspaceMetadata workspaceRemoved bool restartStopped bool + startupResumePending bool hasCreated bool hasState bool } @@ -308,13 +309,14 @@ func (p *recoveryProjector) applyStartupResumeCompleted( payload.Version, ) } - if !draft.restartStopped { + if !draft.startupResumePending { return projectionError( row, "startup resume completion has no restart resume candidate", ) } draft.restartStopped = false + draft.startupResumePending = false return nil } @@ -339,6 +341,7 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { } draft.workspaceRemoved = true draft.restartStopped = false + draft.startupResumePending = false return nil } @@ -397,10 +400,11 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { from != agent.StateDone && from != agent.StateStopped && row.Reason == restartStopReason - if to == agent.StateDone { + if isRestartStop { + draft.restartStopped = true + draft.startupResumePending = true + } else if to == agent.StateDone { draft.restartStopped = false - } else { - draft.restartStopped = draft.restartStopped || isRestartStop } if known { draft.lastTransition = evidence diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 470ae32..1b65363 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -603,6 +603,26 @@ func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( }, wantResume: false, }, + { + name: "terminal exit before durable completion", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "done", + Reason: "process exited", + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCompletedReason, + Payload: `{"version":1}`, + }, + }, + wantResume: false, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 6d47f54..9a677ec 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -13,6 +13,7 @@ import ( "slices" "sort" "strings" + "unicode/utf8" "github.com/google/uuid" ) @@ -641,6 +642,9 @@ func readWorktreeInclude( } func validateIncludedPath(path string) (string, error) { + if !utf8.ValidString(path) { + return "", fmt.Errorf("workspace: invalid included path %q", path) + } path = filepath.FromSlash(path) clean := filepath.Clean(path) if clean == "." || diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index a68a09b..3f26ded 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -11,6 +11,13 @@ import ( "testing" ) +func TestValidateIncludedPathRejectsInvalidUTF8(t *testing.T) { + path := string([]byte{'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 0xff}) + if _, err := validateIncludedPath(path); err == nil { + t.Fatal("included path validation accepted invalid UTF-8") + } +} + func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { sourcePath := filepath.Join(t.TempDir(), "source") if err := os.Mkdir(sourcePath, 0o700); err != nil { diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 88d88e1..64ea6da 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -451,12 +451,19 @@ func (r repositoryCapability) privateGitCommandAt( arguments ...string, ) (*exec.Cmd, func() error, error) { if r.root != nil && r.gitRoot != nil { + if r.commonPath == "" || r.commonRoot == nil { + return nil, nil, errors.New( + "workspace: private Git binding has no common directory", + ) + } return rootedPrivateGitCommand( ctx, r.manager.git, worktreePath, r.gitPath, r.gitRoot, + r.commonPath, + r.commonRoot, arguments, ) } diff --git a/internal/workspace/rooted_git_descriptor_unix_test.go b/internal/workspace/rooted_git_descriptor_unix_test.go index c4ab018..2472a5e 100644 --- a/internal/workspace/rooted_git_descriptor_unix_test.go +++ b/internal/workspace/rooted_git_descriptor_unix_test.go @@ -15,6 +15,56 @@ func inheritedDirectoryPath(fd int) string { return "/proc/self/fd/" + fmt.Sprint(fd) } +func TestRootedPrivateGitCommandBindsCommonDirectory(t *testing.T) { + parent := t.TempDir() + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedPrivateGitCommand( + context.Background(), + "git", + filepath.Join(parent, "worktree"), + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted private command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted private command: %v", err) + } + }() + if len(command.ExtraFiles) != 2 { + t.Fatalf( + "private command inherited files = %d, want 2", + len(command.ExtraFiles), + ) + } + want := "GIT_COMMON_DIR=" + inheritedDirectoryPath(4) + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q: %q", want, command.Env) + } +} + func TestRootedPreparedWorktreeGitCommandBindsAllDirectories(t *testing.T) { parent := t.TempDir() repositoryPath := filepath.Join(parent, "repository") diff --git a/internal/workspace/rooted_git_linux.go b/internal/workspace/rooted_git_linux.go index 6b82808..c186147 100644 --- a/internal/workspace/rooted_git_linux.go +++ b/internal/workspace/rooted_git_linux.go @@ -60,6 +60,8 @@ func rootedPrivateGitCommand( worktreePath string, _ string, gitRoot *os.Root, + _ string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { gitDirectory, err := gitRoot.Open(".") @@ -69,12 +71,21 @@ func rootedPrivateGitCommand( err, ) } + commonDirectory, err := commonRoot.Open(".") + if err != nil { + _ = gitDirectory.Close() + return nil, nil, fmt.Errorf( + "workspace: open rooted common Git directory: %w", + err, + ) + } command := exec.CommandContext(ctx, git) - command.ExtraFiles = []*os.File{gitDirectory} - command.Env = boundGitEnvironment( + command.ExtraFiles = []*os.File{gitDirectory, commonDirectory} + command.Env = boundGitEnvironmentWithCommon( command.Environ(), - ".", + "/proc/self/fd/3", worktreePath, + "/proc/self/fd/4", ) command.Args = append( command.Args, @@ -84,7 +95,13 @@ func rootedPrivateGitCommand( "/proc/self/fd/3", ) command.Args = append(command.Args, arguments...) - return command, gitDirectory.Close, nil + cleanup := func() error { + return errors.Join( + commonDirectory.Close(), + gitDirectory.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 8bde990..72c652c 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -29,6 +29,8 @@ func rootedPrivateGitCommand( string, string, *os.Root, + string, + *os.Root, []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( diff --git a/internal/workspace/rooted_git_test.go b/internal/workspace/rooted_git_test.go index 5627c7f..f530118 100644 --- a/internal/workspace/rooted_git_test.go +++ b/internal/workspace/rooted_git_test.go @@ -1,11 +1,85 @@ package workspace import ( + "context" + "os" + "path/filepath" + "runtime" "slices" "strings" "testing" ) +func TestRootedPrivateGitCommandBindsCommonDirectoryForPlatform( + t *testing.T, +) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "linux", "netbsd", "openbsd", + "windows": + default: + t.Skip("rooted private Git execution is unsupported") + } + parent := t.TempDir() + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedPrivateGitCommand( + context.Background(), + "git", + filepath.Join(parent, "worktree"), + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted private command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted private command: %v", err) + } + }() + wantExtraFiles := 1 + if runtime.GOOS == "linux" { + wantExtraFiles = 2 + } else if runtime.GOOS == "windows" { + wantExtraFiles = 0 + } + if len(command.ExtraFiles) != wantExtraFiles { + t.Fatalf( + "private command inherited files = %d, want %d", + len(command.ExtraFiles), + wantExtraFiles, + ) + } + commonDirectory := commonPath + switch runtime.GOOS { + case "linux": + commonDirectory = "/proc/self/fd/4" + } + want := "GIT_COMMON_DIR=" + commonDirectory + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q: %q", want, command.Env) + } +} + func TestBoundGitEnvironmentRemovesInheritedGitConfiguration(t *testing.T) { environment := boundGitEnvironment( []string{ diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index 9585f36..b63cb8a 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -91,10 +91,18 @@ func rootedPrivateGitCommand( ctx context.Context, git string, worktreePath string, - _ string, + gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { + if err := verifyRealPathRoot(commonPath, commonRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted common Git directory: %w", + err, + ) + } directory, err := gitRoot.Open(".") if err != nil { return nil, nil, fmt.Errorf( @@ -124,12 +132,23 @@ func rootedPrivateGitCommand( helperArguments = append(helperArguments, arguments...) command := exec.CommandContext(ctx, executable, helperArguments...) command.ExtraFiles = []*os.File{directory} - command.Env = boundGitEnvironment( + commonDirectory := commonPath + if commonPath == gitPath { + commonDirectory = "." + } + command.Env = boundGitEnvironmentWithCommon( command.Environ(), ".", worktreePath, + commonDirectory, ) - return command, directory.Close, nil + cleanup := func() error { + return errors.Join( + verifyRealPathRoot(commonPath, commonRoot), + directory.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 0ed1743..17cd797 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -48,12 +48,18 @@ func rootedPrivateGitCommand( worktreePath string, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { - guard, err := openRepositoryGuard(gitPath, gitRoot) + gitGuard, err := openRepositoryGuard(gitPath, gitRoot) if err != nil { return nil, nil, err } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join(err, gitGuard.Close()) + } command := exec.CommandContext( ctx, git, @@ -63,12 +69,19 @@ func rootedPrivateGitCommand( )..., ) command.Dir = gitPath - command.Env = boundGitEnvironment( + command.Env = boundGitEnvironmentWithCommon( command.Environ(), gitPath, worktreePath, + commonPath, ) - return command, guard.Close, nil + cleanup := func() error { + return errors.Join( + commonGuard.Close(), + gitGuard.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( From 22ed425d0b003e3d745ae27f2c872a90f19510d2 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 16:36:17 +0800 Subject: [PATCH 022/104] fix: close final workspace race windows --- internal/workspace/AGENTS.md | 12 +- internal/workspace/prepared_rollback.go | 62 +-- internal/workspace/prepared_worktree.go | 222 +++++++++- internal/workspace/record.go | 34 +- internal/workspace/removal.go | 87 +++- internal/workspace/removal_acknowledgement.go | 407 ++++++++++++++++++ .../workspace/removal_acknowledgement_test.go | 155 +++++++ internal/workspace/repository_capability.go | 96 +---- internal/workspace/rooted_git_windows.go | 6 + internal/workspace/rooted_git_windows_test.go | 67 +++ internal/workspace/source_swap_test.go | 211 ++++++++- internal/workspace/workspace.go | 64 ++- internal/workspace/workspace_test.go | 55 +++ 13 files changed, 1318 insertions(+), 160 deletions(-) create mode 100644 internal/workspace/removal_acknowledgement.go create mode 100644 internal/workspace/removal_acknowledgement_test.go create mode 100644 internal/workspace/rooted_git_windows_test.go diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 265b635..d38b2ef 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -81,6 +81,11 @@ 和修复目录已丢失但 Git 注册仍存在的情况。路径存在时必须同时匹配记录中的 Git 私有目录和文件系统目录身份,复制或替换同名目录必须 fail-stop。 - `Remove` 默认拒绝 dirty、detached HEAD 和保护来源未知的 version 1 sidecar; + 调用方必须先停止所有可通过 worktree 路径或既有文件/目录句柄写入的进程;session + 层在 Agent 为 Done/Stopped、无 attached/resuming session 且持有 cleanup reservation + 时才调用。成功删除以绑定目录身份的 canonical 到 quarantine 原子 rename 为逻辑 + 线性化点;非强制 dirty 检查只保护该点前可观察到的改动,因为各平台都无法可移植地 + 撤销 rename 前已被其他进程持有的 cwd、dirfd 或可写文件句柄。 `force` 可显式放宽这些检查。删除前必须先原子持久化带 operation ID 的 removal intent。物理删除完成后保留 sidecar,直到 session tombstone durable 后由 `AcknowledgeRemoval` 校验 token 并删除。`ReconcileRemovals` 在重启时收敛 path 与 @@ -115,8 +120,13 @@ 任一中间实目录或 symlink 被替换时必须 fail-stop。List 与 record scan 全程持有固定 root/bucket 句柄。sidecar 的原子写、确认读取和删除必须在受约束 bucket 句柄内完成。 初次 sidecar 安装必须使用 no-replace 原语;removal acknowledgement 先把匹配 token 的 + 空 marker 目录从私有 staging 名 no-replace 提升到 canonical Agent 路径,持有该 + namespace reservation 复核 quarantine 与 Git registration 均缺失,再把匹配 token 的 sidecar 原子移动到 operation ID 隔离名,校验后再移动到同格式的新随机私有名并按已打开 - 文件身份删除,崩溃后从任一隔离名恢复。sidecar 与确认隔离名均缺失时必须 fail-closed; + 文件身份删除;sidecar 进入隔离名后才把 reservation 原子移回私有名并删除,崩溃后从任一 + 隔离名或 canonical marker reservation 恢复。List 与 removal reconciliation 必须把 + 匹配 sidecar token 的 reservation 视为缺失 worktree,其他同名对象一律 fail-stop。 + sidecar 与确认隔离名均缺失时必须 fail-closed; 只有当前 Manager 已成功删除同一 Agent ID 与 operation ID 的 sidecar 后,进程内重复确认 才可幂等成功。 sidecar 安装在原子改名前后都要确认已打开的 repository bucket 仍位于规范 hash 路径, diff --git a/internal/workspace/prepared_rollback.go b/internal/workspace/prepared_rollback.go index 3e9498c..e619cc1 100644 --- a/internal/workspace/prepared_rollback.go +++ b/internal/workspace/prepared_rollback.go @@ -194,19 +194,6 @@ func (m *Manager) repairPromotedPreparedWorktree( "workspace: promoted worktree directory identity changed", ) } - currentRepository, err := m.repositoryRootAtRoot( - ctx, - target.Path, - opened, - ) - if err != nil { - return false, err - } - if currentRepository != record.Repository { - return false, errors.New( - "workspace: promoted worktree repository changed", - ) - } gitDirectory, err := m.worktreeGitDirectoryAtRoot( ctx, target.Path, @@ -227,18 +214,20 @@ func (m *Manager) repairPromotedPreparedWorktree( ); err != nil { return false, err } - if _, err := m.runRootedGit( + prepared := &preparedWorktreeTarget{ + root: opened, + name: target.AgentID, + path: target.Path, + gitDirectory: record.GitDirectory, + } + if err := m.repairPreparedWorktreeRegistration( ctx, - target.Path, - opened, - "worktree", - "repair", - ".", + target, + prepared, + repository, + stagingPath, ); err != nil { - return false, fmt.Errorf( - "workspace: repair promoted worktree registration: %w", - err, - ) + return false, err } if err := verifyRootEntryUnchanged( bucket, @@ -247,32 +236,5 @@ func (m *Manager) repairPromotedPreparedWorktree( ); err != nil { return false, err } - current, exists, err := repository.worktreeRegistration(ctx, target.Path) - if err != nil { - return false, err - } - if !exists { - return false, errors.New( - "workspace: repaired worktree registration is missing", - ) - } - if err := repository.verifyPreparedWorktree( - ctx, - target, - record.GitDirectory, - current, - ); err != nil { - return false, err - } - if _, exists, err := repository.worktreeRegistration( - ctx, - stagingPath, - ); err != nil { - return false, err - } else if exists { - return false, errors.New( - "workspace: stale staging registration remains after repair", - ) - } return true, nil } diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 0ca58c8..837193a 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -12,9 +12,10 @@ import ( ) type preparedWorktreeTarget struct { - root *os.Root - name string - path string + root *os.Root + name string + path string + gitDirectory string } func preparedWorktreeStagingName( @@ -404,6 +405,7 @@ func (m *Manager) initializePreparedWorktree( if err != nil { return fmt.Errorf("workspace: persist worktree identity: %w", err) } + prepared.gitDirectory = gitDirectory return nil } @@ -411,6 +413,7 @@ func (m *Manager) promotePreparedWorktreeTarget( ctx context.Context, target Workspace, prepared *preparedWorktreeTarget, + repository repositoryCapability, ) (result error) { bucket, err := m.openManagedBucketRoot(target) if err != nil { @@ -448,6 +451,7 @@ func (m *Manager) promotePreparedWorktreeTarget( if err != nil { return err } + stagingPath := prepared.path moved, renameErr := renameDirectoryNoReplace( directory, opened, @@ -477,18 +481,14 @@ func (m *Manager) promotePreparedWorktreeTarget( ); err != nil { return err } - if _, err := m.runRootedGit( + if err := m.repairPreparedWorktreeRegistration( ctx, - target.Path, - prepared.root, - "worktree", - "repair", - ".", + target, + prepared, + repository, + stagingPath, ); err != nil { - return fmt.Errorf( - "workspace: repair promoted worktree registration: %w", - err, - ) + return err } return verifyRootEntryUnchanged( bucket, @@ -497,6 +497,202 @@ func (m *Manager) promotePreparedWorktreeTarget( ) } +func (m *Manager) repairPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) (result error) { + if prepared.gitDirectory == "" { + return errors.New( + "workspace: prepared private Git directory is unavailable", + ) + } + gitRoot, err := openRealPathRoot(prepared.gitDirectory) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + gitGuard, err := openRepositoryGuard(prepared.gitDirectory, gitRoot) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, gitGuard.Close()) + }() + before, err := repository.registeredWorktrees(ctx) + if err != nil { + return err + } + privateRepository := repository.withPrivateGitBinding( + prepared.gitDirectory, + gitRoot, + ) + runRepair := func() error { + command, cleanup, err := privateRepository.worktreeCommandAt( + ctx, + target.Path, + prepared.root, + "worktree", + "repair", + ".", + ) + if err != nil { + return err + } + _, commandErr := runGitCommand(command, "") + return errors.Join(commandErr, cleanup()) + } + restore := func() error { + arguments := []string{"worktree", "repair"} + for _, registered := range before { + if sameRegisteredWorktreePath(registered.path, stalePath) { + continue + } + if _, err := os.Lstat(registered.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + arguments = append(arguments, registered.path) + } + arguments = append(arguments, target.Path) + _, err := repository.run(ctx, "", arguments...) + return err + } + commandErr := runRepair() + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(prepared.gitDirectory, gitRoot), + ); err != nil { + return errors.Join(err, restore()) + } + after, err := repository.registeredWorktrees(ctx) + if err != nil { + return errors.Join(commandErr, err, restore()) + } + transitionErr := validatePreparedRegistrationTransition( + before, + after, + target, + stalePath, + privateRepository, + ) + if err := errors.Join(commandErr, transitionErr); err != nil { + restoreErr := restore() + restored, verifyErr := repository.registeredWorktrees(ctx) + if verifyErr == nil { + verifyErr = validatePreparedRegistrationTransition( + before, + restored, + target, + stalePath, + privateRepository, + ) + } + return fmt.Errorf( + "workspace: repair promoted worktree registration: %w", + errors.Join(err, restoreErr, verifyErr), + ) + } + return nil +} + +func validatePreparedRegistrationTransition( + before []registeredWorktree, + after []registeredWorktree, + target Workspace, + stalePath string, + repository repositoryCapability, +) error { + if len(after) != len(before) { + return errors.New( + "workspace: worktree registration set changed during repair", + ) + } + var ( + stale registeredWorktree + staleFound bool + targetRecord registeredWorktree + targetFound bool + ) + for _, registered := range before { + if sameRegisteredWorktreePath(registered.path, stalePath) { + stale = registered + staleFound = true + break + } + } + if !staleFound { + return errors.New( + "workspace: stale prepared worktree registration is missing", + ) + } + for _, registered := range after { + if sameRegisteredWorktreePath(registered.path, stalePath) { + return errors.New( + "workspace: stale prepared worktree registration remains after repair", + ) + } + if sameRegisteredWorktreePath(registered.path, target.Path) { + targetRecord = registered + targetFound = true + } + } + if !targetFound { + return errors.New( + "workspace: repaired worktree registration is missing", + ) + } + if err := repository.verifyPreparedWorktreeRegistration( + target, + targetRecord, + target.expectedHeadOID, + ); err != nil { + return err + } + for _, expected := range before { + if sameRegisteredWorktreePath(expected.path, stalePath) { + continue + } + var matched bool + for _, actual := range after { + if !sameRegisteredWorktreePath(expected.path, actual.path) { + continue + } + if expected.head != actual.head || + expected.branch != actual.branch || + expected.detached != actual.detached { + return errors.New( + "workspace: unrelated worktree registration changed during repair", + ) + } + matched = true + break + } + if !matched { + return errors.New( + "workspace: unrelated worktree registration disappeared during repair", + ) + } + } + if stale.head != targetRecord.head || + stale.branch != targetRecord.branch || + stale.detached != targetRecord.detached { + return errors.New( + "workspace: repaired worktree registration changed identity", + ) + } + return nil +} + func (m *Manager) cleanupPreparedWorktreeTarget( target Workspace, prepared *preparedWorktreeTarget, diff --git a/internal/workspace/record.go b/internal/workspace/record.go index a84915e..be6b814 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -915,15 +915,39 @@ func (m *Manager) removeWorkspaceRecordAfterValidation( func (m *Manager) removeAcknowledgedWorkspaceRecord( removal Removal, ) error { - return m.removeAcknowledgedWorkspaceRecordAfterValidation( + return m.removeAcknowledgedWorkspaceRecordWithHooks( removal, nil, + nil, ) } func (m *Manager) removeAcknowledgedWorkspaceRecordAfterValidation( removal Removal, afterValidation func(), +) error { + return m.removeAcknowledgedWorkspaceRecordWithHooks( + removal, + afterValidation, + nil, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordAfterQuarantine( + removal Removal, + afterQuarantine func() error, +) error { + return m.removeAcknowledgedWorkspaceRecordWithHooks( + removal, + nil, + afterQuarantine, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordWithHooks( + removal Removal, + afterValidation func(), + afterQuarantine func() error, ) (result error) { recordPath := workspaceRecordPath(removal.Workspace.Path) bucket, agentID, err := m.openRecordBucket(removal.Workspace.Path) @@ -1064,6 +1088,14 @@ func (m *Manager) removeAcknowledgedWorkspaceRecordAfterValidation( ) } fileOpen = false + if afterQuarantine != nil { + if err := afterQuarantine(); err != nil { + return fmt.Errorf( + "workspace: release removal acknowledgement reservation: %w", + err, + ) + } + } return m.removeRecordAcknowledgementQuarantine( bucket, quarantineName, diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 5217727..962f3e8 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -46,6 +46,13 @@ func workspaceForRemoval(record workspaceRecord) Workspace { } // Remove removes one managed worktree and preserves its branch. +// +// The caller must first stop every process that can write through the worktree +// path or a retained file or directory handle. A successful removal is +// logically ordered at the identity-bound rename from the managed path to its +// private quarantine. The non-force checks reject changes visible before that +// point; operating systems do not provide a portable way to revoke stale +// handles after the rename. func (m *Manager) Remove( ctx context.Context, agentID string, @@ -197,7 +204,9 @@ func (m *Manager) ReconcileRemovals(ctx context.Context) ([]Removal, error) { } // AcknowledgeRemoval removes a sidecar after its session tombstone is durable. -func (m *Manager) AcknowledgeRemoval(removal Removal) error { +func (m *Manager) AcknowledgeRemoval( + removal Removal, +) (result error) { m.mu.Lock() defer m.mu.Unlock() @@ -226,24 +235,68 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { "workspace: removal acknowledgement record is missing", ) } - record := newWorkspaceRecord(removal.Workspace, nil) - record.Removal = &workspaceRemovalRecord{ - OperationID: removal.operationID, - Started: true, - Quarantined: true, + if err := m.recoverRecordAcknowledgements(); err != nil { + return err + } + record, exists, err := m.readWorkspaceRecord(removal.Workspace.Path) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: removal acknowledgement record is missing", + ) + } + if !sameWorkspace(record.workspace(), removal.Workspace) || + record.Removal == nil || + record.Removal.OperationID != removal.operationID { + return m.removeAcknowledgedWorkspaceRecord(removal) } + reservation, err := m.reserveRemovalAcknowledgementPath(record) + if err != nil { + return err + } + reservationOwned := true + defer func() { + if reservationOwned { + result = errors.Join(result, reservation.release()) + } + }() inspectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - facts, err := m.removalFacts(inspectCtx, record) + repository, closeRepository, err := m.removalRepository(inspectCtx, record) if err != nil { return err } - if facts.pathExists || - facts.quarantineName != "" || - facts.registered { + bucket := reservation.bucket + quarantineName, _, quarantineErr := findRemovalQuarantine(bucket, record) + _, registered, registrationErr := repository.worktreeRegistration( + inspectCtx, + record.Path, + ) + verifyErr := reservation.verify() + closeRepositoryErr := closeRepository() + if err := errors.Join( + quarantineErr, + registrationErr, + verifyErr, + closeRepositoryErr, + ); err != nil { + return err + } + if quarantineName != "" || registered { return errors.New("workspace: cannot acknowledge an incomplete removal") } - if err := m.removeAcknowledgedWorkspaceRecord(removal); err != nil { + if err := m.removeAcknowledgedWorkspaceRecordAfterQuarantine( + removal, + func() error { + err := reservation.release() + if err == nil { + reservationOwned = false + } + return err + }, + ); err != nil { return err } m.acknowledgedRemovals[receipt] = struct{}{} @@ -400,6 +453,18 @@ func (m *Manager) removalFacts( record.Path, ) } + if pathExists { + reserved, err := removalPathIsAcknowledgementReservation(bucket, record) + if err != nil { + return removalFacts{}, fmt.Errorf( + "workspace: inspect removal acknowledgement reservation: %w", + err, + ) + } + if reserved { + pathExists = false + } + } var quarantineName string if record.Removal != nil { quarantineName, _, err = findRemovalQuarantine(bucket, record) diff --git a/internal/workspace/removal_acknowledgement.go b/internal/workspace/removal_acknowledgement.go new file mode 100644 index 0000000..bf8f2ec --- /dev/null +++ b/internal/workspace/removal_acknowledgement.go @@ -0,0 +1,407 @@ +package workspace + +import ( + "errors" + "fmt" + "os" + + "github.com/google/uuid" +) + +type removalAcknowledgementReservation struct { + bucket *os.Root + root *os.Root + record workspaceRecord + name string +} + +func removalAcknowledgementReservationPrefix( + record workspaceRecord, +) string { + return "." + record.AgentID + workspaceRecordSuffix + ".ack-path-" + + record.Removal.OperationID + "-" +} + +func removalAcknowledgementReservationName( + record workspaceRecord, +) string { + return removalAcknowledgementReservationPrefix(record) + uuid.NewString() +} + +func (m *Manager) reserveRemovalAcknowledgementPath( + record workspaceRecord, +) (_ *removalAcknowledgementReservation, result error) { + if record.Removal == nil || + !record.Removal.Started || + record.Removal.OperationID == "" || + record.Removal.DirectoryToken == "" { + return nil, errors.New( + "workspace: completed removal has no acknowledgement reservation identity", + ) + } + bucket, err := m.openManagedBucketRoot(record.workspace()) + if err != nil { + return nil, err + } + bucketOwned := true + defer func() { + if bucketOwned { + result = errors.Join(result, bucket.Close()) + } + }() + if err := cleanupRemovalAcknowledgementReservations(bucket, record); err != nil { + return nil, err + } + + info, err := bucket.Lstat(record.AgentID) + switch { + case err == nil: + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf( + "workspace: managed path %q appeared before removal acknowledgement", + record.Path, + ) + } + root, err := openRealRootFromRoot(bucket, record.AgentID) + if err != nil { + return nil, err + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: record.AgentID, + } + if err := reservation.verify(); err != nil { + _ = root.Close() + return nil, fmt.Errorf( + "workspace: managed path %q appeared before removal acknowledgement: %w", + record.Path, + err, + ) + } + bucketOwned = false + return reservation, nil + case !errors.Is(err, os.ErrNotExist): + return nil, fmt.Errorf( + "workspace: inspect removal acknowledgement path %q: %w", + record.Path, + err, + ) + } + + stagingName := removalAcknowledgementReservationName(record) + if err := bucket.Mkdir(stagingName, 0o700); err != nil { + return nil, fmt.Errorf( + "workspace: create removal acknowledgement reservation: %w", + err, + ) + } + root, err := openRealRootFromRoot(bucket, stagingName) + if err != nil { + return nil, fmt.Errorf( + "workspace: open removal acknowledgement reservation: %w", + err, + ) + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: stagingName, + } + reservationOwned := true + defer func() { + if reservationOwned { + result = errors.Join(result, reservation.release()) + } + }() + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + return nil, err + } + if err := reservation.verify(); err != nil { + return nil, err + } + opened, err := root.Stat(".") + if err != nil { + return nil, err + } + directory, err := openRecordDirectory(bucket) + if err != nil { + return nil, err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + stagingName, + removalQuarantineIsolationName(stagingName), + record.AgentID, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if moved { + reservation.name = record.AgentID + } + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return nil, fmt.Errorf( + "workspace: install removal acknowledgement reservation: %w", + err, + ) + } + if !moved { + return nil, errors.New( + "workspace: removal acknowledgement path was not reserved", + ) + } + if err := reservation.verify(); err != nil { + return nil, err + } + reservationOwned = false + bucketOwned = false + return reservation, nil +} + +func (r *removalAcknowledgementReservation) verify() error { + if r == nil || r.bucket == nil || r.root == nil { + return errors.New( + "workspace: removal acknowledgement reservation is closed", + ) + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(r.root, r.record) + if err != nil { + return err + } + if !onlyMarker { + return errors.New( + "workspace: removal acknowledgement reservation contains unexpected entries", + ) + } + return verifyRootEntryUnchanged(r.bucket, r.name, r.root) +} + +func (r *removalAcknowledgementReservation) release() (result error) { + if r == nil { + return nil + } + if r.bucket == nil { + return nil + } + defer func() { + if r.root != nil { + result = errors.Join(result, r.root.Close()) + r.root = nil + } + result = errors.Join(result, r.bucket.Close()) + r.bucket = nil + }() + if r.root == nil { + return errors.New( + "workspace: removal acknowledgement reservation root is closed", + ) + } + if r.name == r.record.AgentID { + if err := r.verify(); err != nil { + return err + } + opened, err := r.root.Stat(".") + if err != nil { + return err + } + isolatedName := removalAcknowledgementReservationName(r.record) + directory, err := openRecordDirectory(r.bucket) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + r.name, + removalQuarantineIsolationName(isolatedName), + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if moved { + r.name = isolatedName + } + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: isolate removal acknowledgement reservation: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: removal acknowledgement reservation was not isolated", + ) + } + } else { + if err := validateClearedRemovalDirectory( + r.root, + r.record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged( + r.bucket, + r.name, + r.root, + ); err != nil { + return err + } + } + if err := removeRemovalMarkerIfPresent(r.root, r.record); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + r.root, + r.record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged(r.bucket, r.name, r.root); err != nil { + return err + } + if err := r.root.Close(); err != nil { + r.root = nil + return err + } + r.root = nil + if err := r.bucket.Remove(r.name); err != nil { + return err + } + return syncRecordBucket(r.bucket, r.record.Path) +} + +func cleanupRemovalAcknowledgementReservations( + bucket *os.Root, + record workspaceRecord, +) error { + entries, err := readRootDirectory(bucket) + if err != nil { + return err + } + prefix := removalAcknowledgementReservationPrefix(record) + for _, entry := range entries { + if !validRemovalAcknowledgementReservationName(entry.Name(), prefix) { + continue + } + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: removal acknowledgement reservation %q is not a real directory", + entry.Name(), + ) + } + root, err := openRealRootFromRoot(bucket, entry.Name()) + if err != nil { + return err + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: entry.Name(), + } + if err := cleanupIsolatedRemovalAcknowledgementReservation( + reservation, + ); err != nil { + _ = root.Close() + return err + } + } + return nil +} + +func cleanupIsolatedRemovalAcknowledgementReservation( + reservation *removalAcknowledgementReservation, +) error { + if err := validateClearedRemovalDirectory( + reservation.root, + reservation.record, + false, + ); err != nil { + return err + } + if err := removeRemovalMarkerIfPresent( + reservation.root, + reservation.record, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged( + reservation.bucket, + reservation.name, + reservation.root, + ); err != nil { + return err + } + if err := reservation.root.Close(); err != nil { + reservation.root = nil + return err + } + reservation.root = nil + if err := reservation.bucket.Remove(reservation.name); err != nil { + return err + } + return syncRecordBucket(reservation.bucket, reservation.record.Path) +} + +func validRemovalAcknowledgementReservationName( + name string, + prefix string, +) bool { + if len(name) != len(prefix)+36 || name[:len(prefix)] != prefix { + return false + } + raw := name[len(prefix):] + parsed, err := uuid.Parse(raw) + return err == nil && parsed.String() == raw +} + +func removalPathIsAcknowledgementReservation( + bucket *os.Root, + record workspaceRecord, +) (_ bool, result error) { + if record.Removal == nil || !record.Removal.Started { + return false, nil + } + root, err := openRealRootFromRoot(bucket, record.AgentID) + if err != nil { + return false, err + } + defer func() { + result = errors.Join(result, root.Close()) + }() + return openedRemovalPathIsAcknowledgementReservation( + bucket, + root, + record, + ) +} + +func openedRemovalPathIsAcknowledgementReservation( + bucket *os.Root, + root *os.Root, + record workspaceRecord, +) (bool, error) { + if record.Removal == nil || !record.Removal.Started { + return false, nil + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(root, record) + if err != nil { + return false, err + } + if !onlyMarker { + return false, nil + } + if err := verifyRootEntryUnchanged(bucket, record.AgentID, root); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/workspace/removal_acknowledgement_test.go b/internal/workspace/removal_acknowledgement_test.go new file mode 100644 index 0000000..410b5d3 --- /dev/null +++ b/internal/workspace/removal_acknowledgement_test.go @@ -0,0 +1,155 @@ +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestAcknowledgeRemovalReservesManagedPathDuringRepositoryCheck( + t *testing.T, +) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + hookMarker := filepath.Join(t.TempDir(), "reservation-observed") + wrapper := filepath.Join(t.TempDir(), "git-wrapper") + script := `#!/bin/sh +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +case " $* " in + *" worktree list --porcelain -z "*) + if mkdir "$DROVE_TEST_WORKSPACE_PATH" 2>/dev/null; then + exit 91 + fi + [ -d "$DROVE_TEST_WORKSPACE_PATH" ] || exit 92 + : > "$DROVE_TEST_HOOK_MARKER" + ;; +esac +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_WORKSPACE_PATH", prepared.Path) + t.Setenv("DROVE_TEST_HOOK_MARKER", hookMarker) + manager.git = wrapper + + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } + if _, err := os.Stat(hookMarker); err != nil { + t.Fatalf("repository check did not observe reservation: %v", err) + } + if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("acknowledgement reservation remains: %v", err) + } + if _, err := os.Lstat(workspaceRecordPath(prepared.Path)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("acknowledged record remains: %v", err) + } +} + +func TestAcknowledgeRemovalRecoversManagedPathReservation( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read removal record: exists=%v err=%v", exists, err) + } + reservation, err := manager.reserveRemovalAcknowledgementPath(record) + if err != nil { + t.Fatalf("reserve acknowledgement path: %v", err) + } + listed, err := manager.List(context.Background()) + if err != nil { + t.Fatalf("list acknowledgement reservation: %v", err) + } + if len(listed) != 1 || !listed[0].Missing { + t.Fatalf("listed acknowledgement reservation = %+v", listed) + } + if err := reservation.root.Close(); err != nil { + t.Fatalf("close reservation root: %v", err) + } + reservation.root = nil + if err := reservation.bucket.Close(); err != nil { + t.Fatalf("close reservation bucket: %v", err) + } + reservation.bucket = nil + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + removals, err := restarted.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile removal with reservation: %v", err) + } + if len(removals) != 1 || + removals[0].operationID != result.Removal.operationID { + t.Fatalf("reconciled removals = %+v", removals) + } + if err := restarted.AcknowledgeRemoval(removals[0]); err != nil { + t.Fatalf("acknowledge recovered reservation: %v", err) + } + if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("recovered acknowledgement reservation remains: %v", err) + } +} diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 64ea6da..4d1c673 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -372,11 +372,26 @@ func resolveExistingDirectory(path string) (string, error) { func (r repositoryCapability) headOID( ctx context.Context, ) (string, error) { - output, err := r.runPrivateGit(ctx, "", "rev-parse", "--verify", "HEAD") + if sameRegisteredWorktreePath(r.gitPath, r.commonPath) { + output, err := r.run(ctx, "", "rev-parse", "--verify", "HEAD") + if err != nil { + return "", fmt.Errorf("workspace: resolve branch start: %w", err) + } + return validateHeadOID(strings.TrimSpace(string(output))) + } + registered, exists, err := r.worktreeRegistration(ctx, r.path) if err != nil { return "", fmt.Errorf("workspace: resolve branch start: %w", err) } - oid := strings.TrimSpace(string(output)) + if !exists { + return "", errors.New( + "workspace: source worktree registration is missing", + ) + } + return validateHeadOID(registered.head) +} + +func validateHeadOID(oid string) (string, error) { if oid == "" || strings.ContainsAny(oid, " \t\r\n") { return "", errors.New( "workspace: source HEAD returned an invalid object ID", @@ -608,7 +623,7 @@ func (r repositoryCapability) pruneWorktrees( } func (r repositoryCapability) verifyPreparedWorktree( - ctx context.Context, + _ context.Context, target Workspace, gitDirectory string, registered registeredWorktree, @@ -627,90 +642,19 @@ func (r repositoryCapability) verifyPreparedWorktree( ); err != nil { return err } - head, err := r.run( - ctx, - "", - "--git-dir="+gitDirectory, - "rev-parse", - "--verify", - "HEAD", - ) - if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree HEAD: %w", - err, - ) - } - if strings.TrimSpace(string(head)) != registered.head { - return errors.New( - "workspace: prepared worktree HEAD does not match registration", - ) - } - branch, err := r.run( - ctx, - "", - "--git-dir="+gitDirectory, - "symbolic-ref", - "--quiet", - "HEAD", - ) - if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree branch: %w", - err, - ) - } - if strings.TrimSpace(string(branch)) != registered.branch { - return errors.New( - "workspace: prepared worktree branch does not match registration", - ) - } return nil } func (r repositoryCapability) verifyBoundPreparedWorktree( - ctx context.Context, + _ context.Context, target Workspace, registered registeredWorktree, ) error { - if err := r.verifyPreparedWorktreeRegistration( + return r.verifyPreparedWorktreeRegistration( target, registered, target.expectedHeadOID, - ); err != nil { - return err - } - head, err := r.runPrivateGit(ctx, "", "rev-parse", "--verify", "HEAD") - if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree HEAD: %w", - err, - ) - } - if strings.TrimSpace(string(head)) != registered.head { - return errors.New( - "workspace: prepared worktree HEAD does not match registration", - ) - } - branch, err := r.runPrivateGit( - ctx, - "", - "symbolic-ref", - "--quiet", - "HEAD", ) - if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree branch: %w", - err, - ) - } - if strings.TrimSpace(string(branch)) != registered.branch { - return errors.New( - "workspace: prepared worktree branch does not match registration", - ) - } - return nil } func (r repositoryCapability) verifyPreparationRefs( diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 17cd797..5dc7b1b 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -119,6 +119,12 @@ func rootedWorktreeGitCommand( closeWorktree(), ) } + command.Env = boundGitEnvironmentWithCommon( + command.Environ(), + gitPath, + path, + commonPath, + ) cleanup := func() error { return errors.Join( commonGuard.Close(), diff --git a/internal/workspace/rooted_git_windows_test.go b/internal/workspace/rooted_git_windows_test.go new file mode 100644 index 0000000..4abb18b --- /dev/null +++ b/internal/workspace/rooted_git_windows_test.go @@ -0,0 +1,67 @@ +//go:build windows + +package workspace + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestRootedWorktreeGitCommandBindsCommonDirectory(t *testing.T) { + parent := t.TempDir() + worktreePath := filepath.Join(parent, "worktree") + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{worktreePath, gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedWorktreeGitCommand( + context.Background(), + "git", + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"read-tree", "--reset", "-u", "HEAD"}, + ) + if err != nil { + t.Fatalf("create rooted worktree command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted worktree command: %v", err) + } + }() + for _, want := range []string{ + "GIT_DIR=" + gitPath, + "GIT_WORK_TREE=" + worktreePath, + "GIT_COMMON_DIR=" + commonPath, + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("worktree command environment lacks %q: %q", want, command.Env) + } + } +} diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index a23f68f..a72da38 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -1785,7 +1785,7 @@ func TestDiscardUsesRetainedCommonGitDirectoryAfterReplacement( ) } -func TestIncludedPathsUsesRetainedCommonGitDirectoryAfterReplacement( +func TestIncludedPathsRejectsReplacedCommonGitDirectory( t *testing.T, ) { parent := t.TempDir() @@ -1865,17 +1865,14 @@ exec "$DROVE_TEST_REAL_GIT" "$@" t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper - paths, err := manager.includedPaths( + _, err = manager.includedPaths( context.Background(), source, sourceRoot, lease.repository, ) - if err != nil { - t.Fatalf("evaluate include manifest through retained repository: %v", err) - } - if len(paths) != 1 || paths[0] != includedPath { - t.Fatalf("included paths = %q, want [%s]", paths, includedPath) + if err == nil || !strings.Contains(err.Error(), "changed while in use") { + t.Fatalf("replaced common Git directory error = %v", err) } } @@ -2406,6 +2403,206 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestPreparationHeadUsesPinnedWorktreeRegistration(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, repository) + runGit(t, repository, "worktree", "add", "-b", "source-branch", source) + sourceOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + if err := os.WriteFile( + filepath.Join(repository, "second.txt"), + []byte("second\n"), + 0o600, + ); err != nil { + t.Fatalf("write second commit file: %v", err) + } + runGit(t, repository, "add", "second.txt") + runGit(t, repository, "commit", "-m", "second") + replacementOID := strings.TrimSpace(runGit(t, repository, "rev-parse", "HEAD")) + if replacementOID == sourceOID { + t.Fatal("source and replacement OIDs unexpectedly match") + } + + maliciousCommon := filepath.Join(parent, "malicious-common") + if err := os.CopyFS( + maliciousCommon, + os.DirFS(filepath.Join(repository, ".git")), + ); err != nil { + t.Fatalf("copy malicious common Git directory: %v", err) + } + runGit( + t, + parent, + "--git-dir="+maliciousCommon, + "update-ref", + "refs/heads/source-branch", + replacementOID, + ) + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + marker := filepath.Join(parent, "head-commondir-swapped") + wrapper := filepath.Join(parent, "git-wrapper-head-commondir") + script := `#!/bin/sh +matched= +has_rev_parse= +has_verify= +has_head= +for argument in "$@"; do + [ "$argument" = "rev-parse" ] && has_rev_parse=1 + [ "$argument" = "--verify" ] && has_verify=1 + [ "$argument" = "HEAD" ] && has_head=1 +done +if [ -n "$has_rev_parse" ] && [ -n "$has_verify" ] && [ -n "$has_head" ] && + [ "$GIT_WORK_TREE" = "$DROVE_TEST_SOURCE" ] && + [ ! -e "$DROVE_TEST_MARKER" ]; then + private=$("$DROVE_TEST_REAL_GIT" rev-parse --absolute-git-dir) || exit 90 + original=$(cat "$private/commondir") || exit 91 + printf '%s\n' "$DROVE_TEST_MALICIOUS_COMMON" > "$private/commondir" || exit 92 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + printf '%s\n' "$original" > "$private/commondir" || exit 93 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_SOURCE", source) + t.Setenv("DROVE_TEST_MARKER", marker) + t.Setenv("DROVE_TEST_MALICIOUS_COMMON", maliciousCommon) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + root, err := openRealPathRoot(source) + if err != nil { + t.Fatalf("open source worktree: %v", err) + } + lease, err := newPreparationLease( + context.Background(), + manager, + source, + root, + ) + if err != nil { + t.Fatalf("open preparation lease: %v", err) + } + if lease.repository.startOID != sourceOID { + t.Fatalf( + "preparation start OID = %q, want registered %q", + lease.repository.startOID, + sourceOID, + ) + } + if err := lease.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } +} + +func TestPrepareProtectsPromotedWorktreePointerDuringRepair(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + sibling := filepath.Join(parent, "sibling") + initSourceSwapRepository(t, repository) + runGit(t, repository, "worktree", "add", "-b", "sibling", sibling) + sibling, err := resolvePath(sibling) + if err != nil { + t.Fatalf("resolve sibling worktree: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapResult := filepath.Join(parent, "repair-swap-result") + wrapper := filepath.Join(parent, "git-wrapper-swap-repair-pointer") + script := `#!/bin/sh +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "repair" ] && + [ ! -e "$DROVE_TEST_SWAP_RESULT" ]; then + target=${GIT_WORK_TREE:-$(pwd -P)} + original="$target/.git.drove-original" + if mv "$target/.git" "$original" 2>/dev/null; then + cp "$DROVE_TEST_SIBLING_POINTER" "$target/.git" || exit 90 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + rm -f "$target/.git" || exit 91 + mv "$original" "$target/.git" || exit 92 + printf swapped > "$DROVE_TEST_SWAP_RESULT" + exit "$status" + fi + printf blocked > "$DROVE_TEST_SWAP_RESULT" + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_SIBLING_POINTER", filepath.Join(sibling, ".git")) + t.Setenv("DROVE_TEST_SWAP_RESULT", swapResult) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + _, err = manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err == nil || !strings.Contains(err.Error(), "registration") { + t.Fatalf("prepare through repair pointer swap error = %v", err) + } + result, err := os.ReadFile(swapResult) + if err != nil { + t.Fatalf("read repair swap result: %v", err) + } + if string(result) != "swapped" { + t.Fatalf("repair pointer swap result = %q, want swapped", result) + } + canonicalRepository, err := resolvePath(repository) + if err != nil { + t.Fatalf("resolve repository: %v", err) + } + assertGitWorktreeRegistration( + t, + filepath.Join(repository, ".git"), + filepath.Join( + manager.root, + repositoryHash(canonicalRepository), + testAgentID, + ), + false, + ) + registered, err := manager.worktreeRegistered( + context.Background(), + repository, + sibling, + ) + if err != nil || !registered { + t.Fatalf( + "sibling registration restored = %v, err=%v\n%s", + registered, + err, + runGit(t, repository, "worktree", "list", "--porcelain"), + ) + } +} + func postSuccessSwapGitWrapper( t *testing.T, parent string, diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 7ecdea0..525c79e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -438,6 +438,7 @@ func (m *Manager) prepare( ctx, result, preparedTarget, + sourceRepository, ); err != nil { return Workspace{}, err } @@ -631,6 +632,39 @@ func (m *Manager) listRepositoryBucket( err, ) } + if hasRecord { + reserved, reservationErr := + openedRemovalPathIsAcknowledgementReservation( + bucket, + workspaceRoot, + record, + ) + if reserved { + closeErr := workspaceRoot.Close() + if err := errors.Join( + reservationErr, + closeErr, + ); err != nil { + return nil, err + } + missing := record.workspace() + missing.Missing = true + missing.protectionKnown = record.ProtectionKnown + missing.includedPaths = append( + []string(nil), + record.IncludedPaths..., + ) + result = append(result, missing) + continue + } + if reservationErr != nil { + _ = workspaceRoot.Close() + return nil, fmt.Errorf( + "workspace: inspect removal acknowledgement reservation: %w", + reservationErr, + ) + } + } if err := m.pinDataDirectory(); err != nil { _ = workspaceRoot.Close() return nil, err @@ -1353,7 +1387,35 @@ func (m *Manager) repositoryRootWith( return "", fmt.Errorf("workspace: inspect configured worktree: %w", err) } - root := filepath.Dir(commonPath) + worktreesOutput, err := run( + "worktree", + "list", + "--porcelain", + "-z", + ) + if err != nil { + return "", fmt.Errorf("workspace: list repository worktrees: %w", err) + } + worktrees, err := parseRegisteredWorktrees(worktreesOutput) + if err != nil { + return "", err + } + if len(worktrees) == 0 { + return "", errors.New("workspace: repository has no registered worktree") + } + root := worktrees[0].path + if sameRegisteredWorktreePath(root, commonPath) { + topLevelOutput, err := run("rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("workspace: inspect repository worktree: %w", err) + } + root = trimGitLineTerminator(topLevelOutput) + if !filepath.IsAbs(root) { + return "", errors.New( + "workspace: repository worktree is not absolute", + ) + } + } resolved, err := resolvePath(root) if err != nil { return "", fmt.Errorf("workspace: resolve repository root: %w", err) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 862511f..49db36c 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1688,6 +1688,61 @@ func TestPrepareFromLinkedWorktreeUsesCommonRepositoryIdentity(t *testing.T) { } } +func TestPrepareFromSeparateGitDirectoryUsesWorktreeIdentity(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + gitDirectory := filepath.Join(parent, "metadata", "repository.git") + if err := os.MkdirAll(filepath.Dir(gitDirectory), 0o700); err != nil { + t.Fatalf("create metadata directory: %v", err) + } + runGit( + t, + parent, + "init", + "--separate-git-dir="+gitDirectory, + repository, + ) + runGit(t, repository, "config", "user.email", "drove@example.com") + runGit(t, repository, "config", "user.name", "Drove Test") + if err := os.WriteFile( + filepath.Join(repository, "tracked.txt"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write tracked file: %v", err) + } + runGit(t, repository, "add", "tracked.txt") + runGit(t, repository, "commit", "-m", "initial") + repository, err := resolvePath(repository) + if err != nil { + t.Fatalf("resolve worktree path: %v", err) + } + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "separate-git-directory", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare from separate Git directory: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "repository = %q, want worktree %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard separate Git directory worktree: %v", err) + } +} + func TestPrepareFromLinkedWorktreeUsesSourceIndexForIncludes(t *testing.T) { repository := newTestRepository(t) const includedPath = "linked-local.env" From 6f80b00ea4bc983643e5f3514fd80ff4fbf24432 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 17:11:20 +0800 Subject: [PATCH 023/104] fix: preserve stop failure semantics --- internal/api/server.go | 10 ++++++++- internal/api/server_test.go | 33 +++++++++++++++++++++++++++ internal/client/client_test.go | 38 ++++++++++++++++++++++++++++++++ internal/session/session.go | 2 +- internal/session/session_test.go | 4 ++-- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 4991c9f..ad85fcd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -279,12 +279,20 @@ func parseExplainLimit(r *http.Request) (int, error) { func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.opts.Manager.Stop(agent.ID(id)); err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + writeStopError(w, err) return } w.WriteHeader(http.StatusNoContent) } +func writeStopError(w http.ResponseWriter, err error) { + if errors.Is(err, session.ErrUnknownAgent) { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeErr(w, http.StatusInternalServerError, err.Error()) +} + type workspaceCleanupResponse struct { AgentID string `json:"agent_id"` Branch string `json:"branch"` diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7a015b1..9530d90 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -451,6 +451,39 @@ func TestWriteResumeErrorMapsUnavailableHookAuthority(t *testing.T) { } } +func TestWriteStopErrorMapsOnlyUnknownAgentToNotFound(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + { + name: "unknown agent", + err: fmt.Errorf("wrapped: %w", session.ErrUnknownAgent), + wantStatus: http.StatusNotFound, + }, + { + name: "runtime failure", + err: errors.New("close PTY"), + wantStatus: http.StatusInternalServerError, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + writeStopError(response, test.err) + if response.Code != test.wantStatus { + t.Fatalf( + "status = %d, want %d; body=%s", + response.Code, + test.wantStatus, + response.Body.String(), + ) + } + }) + } +} + func TestHandleExplainReturnsAttachedTypedResponse(t *testing.T) { server, manager, _ := newTestServer(t) status, err := manager.Start(context.Background(), session.StartRequest{ diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 28e4021..734fd36 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -180,6 +180,44 @@ func TestCleanupWorktreeKeepsCurrentDaemonNotFoundAsUserError(t *testing.T) { } } +func TestStopClassifiesOnlyNotFoundAsUserError(t *testing.T) { + for _, test := range []struct { + name string + statusCode int + wantUser bool + }{ + { + name: "unknown agent", + statusCode: http.StatusNotFound, + wantUser: true, + }, + { + name: "runtime failure", + statusCode: http.StatusInternalServerError, + wantUser: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + _ *http.Request, + ) { + http.Error(w, `{"error":"stop failed"}`, test.statusCode) + })) + defer server.Close() + + client := New(strings.TrimPrefix(server.URL, "http://")) + err := client.Stop(context.Background(), "agent-1") + if err == nil { + t.Fatal("stop succeeded") + } + if got := IsUserError(err); got != test.wantUser { + t.Fatalf("IsUserError(%v) = %v, want %v", err, got, test.wantUser) + } + }) + } +} + func TestStartUsesDedicatedWorktreeEndpointWithoutClientTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func( w http.ResponseWriter, diff --git a/internal/session/session.go b/internal/session/session.go index 6f62508..1ed04d9 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1001,7 +1001,7 @@ func (m *Manager) Close() error { func (m *Manager) Stop(id agent.ID) error { a, ok := m.agent(id) if !ok { - return fmt.Errorf("session: unknown agent %q", id) + return fmt.Errorf("%w: %q", ErrUnknownAgent, id) } m.mu.RLock() diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a0d4645..9282d16 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -412,8 +412,8 @@ func TestStopRestoredStoppedAgentIsIdempotent(t *testing.T) { if err := result.Manager.Stop("agent-1"); err != nil { t.Fatalf("stop restored agent: %v", err) } - if err := result.Manager.Stop("missing"); err == nil { - t.Fatal("stop unknown agent succeeded") + if err := result.Manager.Stop("missing"); !errors.Is(err, ErrUnknownAgent) { + t.Fatalf("stop unknown agent error = %v, want ErrUnknownAgent", err) } if _, err := result.Manager.SendInput("agent-1", []byte("input")); !errors.Is(err, ErrNotAttached) { t.Fatalf("send input to restored agent error = %v, want ErrNotAttached", err) From 9280ba4f80a2fa2e37b9cc928e522219a30802d6 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 17:27:42 +0800 Subject: [PATCH 024/104] fix: normalize tracked include aliases --- internal/workspace/include.go | 64 +++++++++++++++++++++++-- internal/workspace/prepared_worktree.go | 11 +++-- internal/workspace/workspace.go | 2 + internal/workspace/workspace_test.go | 64 +++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 9a677ec..fc8f5c1 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -10,12 +10,15 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "slices" "sort" "strings" "unicode/utf8" "github.com/google/uuid" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" ) const ( @@ -129,7 +132,8 @@ func (m *Manager) copyIncludedFiles( } copiedPaths := make([]string, 0, len(paths)) for _, relative := range paths { - if _, tracked := target.trackedPaths[filepath.ToSlash(relative)]; tracked { + key := trackedPathKey(relative, target.trackedIgnoreCase) + if _, tracked := target.trackedPaths[key]; tracked { continue } if err := copyIncludedPath( @@ -176,7 +180,11 @@ func (m *Manager) worktreeTrackedPaths( ctx context.Context, path string, repository repositoryCapability, -) (map[string]struct{}, error) { +) (map[string]struct{}, bool, error) { + ignoreCase, err := repositoryIgnoreCase(ctx, path, repository) + if err != nil { + return nil, false, err + } output, err := repository.runPrivateGitAt( ctx, path, @@ -188,7 +196,7 @@ func (m *Manager) worktreeTrackedPaths( "-z", ) if err != nil { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "workspace: inspect target tracked paths: %w", err, ) @@ -196,10 +204,56 @@ func (m *Manager) worktreeTrackedPaths( tracked := make(map[string]struct{}) for _, rawPath := range strings.Split(string(output), "\x00") { if rawPath != "" { - tracked[rawPath] = struct{}{} + tracked[trackedPathKey(rawPath, ignoreCase)] = struct{}{} } } - return tracked, nil + return tracked, ignoreCase, nil +} + +func repositoryIgnoreCase( + ctx context.Context, + path string, + repository repositoryCapability, +) (bool, error) { + output, err := repository.runPrivateGitAt( + ctx, + path, + "", + "config", + "--type=bool", + "--get", + "core.ignoreCase", + ) + if isExitCode(err, 1) { + return false, nil + } + if err != nil { + return false, fmt.Errorf( + "workspace: inspect repository path case setting: %w", + err, + ) + } + switch strings.TrimSpace(string(output)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, errors.New( + "workspace: repository path case setting is invalid", + ) + } +} + +func trackedPathKey(path string, ignoreCase bool) string { + key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + if ignoreCase || runtime.GOOS == "windows" { + key = cases.Fold().String(key) + } + if runtime.GOOS == "darwin" { + key = norm.NFD.String(key) + } + return key } func (m *Manager) includedPaths( diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 837193a..fc47c9c 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -338,11 +338,12 @@ func (m *Manager) initializePreparedWorktree( ) } if len(includedPaths) > 0 { - target.trackedPaths, err = m.worktreeTrackedPaths( - ctx, - prepared.path, - privateRepository, - ) + target.trackedPaths, target.trackedIgnoreCase, err = + m.worktreeTrackedPaths( + ctx, + prepared.path, + privateRepository, + ) if err != nil { return err } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 525c79e..0b040c4 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -55,6 +55,7 @@ type Workspace struct { protectionKnown bool includedPaths []string trackedPaths map[string]struct{} + trackedIgnoreCase bool preparation *preparationLease repositoryEvidence *repositoryEvidence } @@ -458,6 +459,7 @@ func (m *Manager) prepare( includeSelection, ) result.trackedPaths = nil + result.trackedIgnoreCase = false if err != nil { return Workspace{}, err } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 49db36c..f62f9f5 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1652,6 +1652,70 @@ func TestPrepareSkipsIncludeTrackedByTargetBranch(t *testing.T) { } } +func TestPrepareSkipsCaseAliasTrackedByTargetBranch(t *testing.T) { + repository := newTestRepository(t) + runGit(t, repository, "config", "core.ignoreCase", "true") + if err := os.WriteFile( + filepath.Join(repository, "Secret.local"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write tracked target file: %v", err) + } + runGit(t, repository, "add", "Secret.local") + runGit(t, repository, "commit", "-m", "track target local file") + runGit(t, repository, "branch", "target-with-case-alias") + runGit(t, repository, "rm", "Secret.local") + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files on source") + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte("secret.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + if err := os.WriteFile( + filepath.Join(repository, "secret.local"), + []byte("source local\n"), + 0o600, + ); err != nil { + t.Fatalf("write source local file: %v", err) + } + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "target-with-case-alias", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare target branch: %v", err) + } + assertFileContents(t, filepath.Join(prepared.Path, "Secret.local"), "tracked\n") + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + if len(record.IncludedPaths) != 0 { + t.Fatalf("protected include paths = %q, want none", record.IncludedPaths) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard target branch worktree: %v", err) + } +} + func TestPrepareFromLinkedWorktreeUsesCommonRepositoryIdentity(t *testing.T) { repository := newTestRepository(t) source := filepath.Join(t.TempDir(), "source-worktree") From 6ad1dec54f711c2a0a9df8c689b618f168bef08e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 17:46:52 +0800 Subject: [PATCH 025/104] fix: harden workspace deletion recovery --- internal/workspace/record_rename_unix.go | 59 ++-- internal/workspace/record_rename_unix_test.go | 10 + internal/workspace/recovery_security_test.go | 172 +++++++++++ internal/workspace/secure_remove.go | 288 +++++++++++++++++- internal/workspace/workspace.go | 25 +- 5 files changed, 521 insertions(+), 33 deletions(-) diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index e86ba70..45a5d89 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -66,7 +66,7 @@ func renameRecordPathAfterValidation( targetName string, replace bool, afterValidation func(), -) (bool, error) { +) (installed bool, result error) { if err := verifyRecordPathIdentity( directory, expected, @@ -82,17 +82,19 @@ func renameRecordPathAfterValidation( if err := unix.Linkat(fd, sourceName, fd, alias, 0); err != nil { return false, err } + aliasFile, err := openRecordPath(directory, alias) + if err != nil { + return false, fmt.Errorf("open staged record alias: %w", err) + } aliasPresent := true defer func() { if aliasPresent { - if verifyRecordPathIdentity( - directory, - expected, - alias, - ) == nil { - _ = unix.Unlinkat(fd, alias, 0) - } + result = errors.Join( + result, + unlinkRecordPath(directory, aliasFile, alias), + ) } + result = errors.Join(result, aliasFile.Close()) }() if err := verifyRecordPathIdentity( directory, @@ -144,7 +146,7 @@ func renameRecordPathAfterValidation( err, ) } - if err := unix.Unlinkat(fd, alias, 0); err != nil { + if err := unlinkRecordPath(directory, aliasFile, alias); err != nil { return true, err } aliasPresent = false @@ -256,6 +258,27 @@ func verifyRecordPathIdentity( if err != nil { return fmt.Errorf("inspect opened record: %w", err) } + current, err := openRecordPath(directory, name) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, current.Close()) + }() + currentInfo, err := current.Stat() + if err != nil { + return fmt.Errorf("inspect record path %q: %w", name, err) + } + if !os.SameFile(expectedInfo, currentInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + return nil +} + +func openRecordPath( + directory *os.File, + name string, +) (*os.File, error) { fd, err := unix.Openat( int(directory.Fd()), name, @@ -263,26 +286,24 @@ func verifyRecordPathIdentity( 0, ) if err != nil { - return fmt.Errorf("open record path %q: %w", name, err) + return nil, fmt.Errorf("open record path %q: %w", name, err) } current := os.NewFile(uintptr(fd), name) if current == nil { _ = unix.Close(fd) - return errors.New("create record file from descriptor") + return nil, errors.New("create record file from descriptor") } - defer func() { - result = errors.Join(result, current.Close()) - }() currentInfo, err := current.Stat() if err != nil { - return fmt.Errorf("inspect record path %q: %w", name, err) + _ = current.Close() + return nil, fmt.Errorf("inspect record path %q: %w", name, err) } if !currentInfo.Mode().IsRegular() || - currentInfo.Mode()&os.ModeSymlink != 0 || - !os.SameFile(expectedInfo, currentInfo) { - return fmt.Errorf("record path %q changed identity", name) + currentInfo.Mode()&os.ModeSymlink != 0 { + _ = current.Close() + return nil, fmt.Errorf("record path %q is not a regular file", name) } - return nil + return current, nil } func openRecordDirectory(root *os.Root) (*os.File, error) { diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index b1a0fda..4166ecb 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" ) @@ -65,6 +66,15 @@ func TestRenameRecordRejectsSourceReplacementAfterValidation(t *testing.T) { } assertFileContents(t, sourcePath, "replacement\n") assertFileContents(t, movedPath, "original\n") + entries, err := os.ReadDir(rootPath) + if err != nil { + t.Fatalf("read record directory: %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".drove-install-") { + t.Fatalf("staged alias remains after rejected replacement: %q", entry.Name()) + } + } } func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index c044a3c..6ebccaa 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -185,6 +185,92 @@ func TestRemoveRootDirectoryContentsRejectsReplacementParent(t *testing.T) { } } +func TestRemoveOpenedDirectoryPreservesCanonicalReplacement(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(originalPath, "old"), + []byte("old\n"), + 0o600, + ); err != nil { + t.Fatalf("write original file: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + opened, err := openRealRootFromRoot(parent, "workspace") + if err != nil { + t.Fatalf("open original directory: %v", err) + } + replacement := filepath.Join(originalPath, "replacement") + + err = removeOpenedDirectoryFromRootAfterIsolation( + parent, + "workspace", + opened, + "workspace", + func(string) { + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create canonical replacement: %v", err) + } + if err := os.WriteFile( + replacement, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement file: %v", err) + } + }, + ) + if err != nil { + t.Fatalf("remove opened directory: %v", err) + } + assertFileContents(t, replacement, "replacement\n") + entries, err := os.ReadDir(parentPath) + if err != nil { + t.Fatalf("read parent directory: %v", err) + } + for _, entry := range entries { + if isRootDeletionName(entry.Name(), "workspace") { + t.Fatalf("deletion debris remains: %q", entry.Name()) + } + } +} + +func TestRemoveAllRecoversIsolatedDirectoryDeletion(t *testing.T) { + parentPath := t.TempDir() + debrisName := rootDeletionPrefix("workspace") + + "49494949-4949-4949-8949-494949494949" + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Mkdir(debrisPath, 0o700); err != nil { + t.Fatalf("create deletion debris: %v", err) + } + if err := os.WriteFile( + filepath.Join(debrisPath, "old"), + []byte("old\n"), + 0o600, + ); err != nil { + t.Fatalf("write deletion debris: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeAllFromRoot(parent, "workspace"); err != nil { + t.Fatalf("recover isolated deletion: %v", err) + } + if _, err := os.Lstat(debrisPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("deletion debris remains: %v", err) + } +} + func TestRenameRecordRejectsReplacedTemporaryPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test requires renaming an open file") @@ -1684,6 +1770,92 @@ func TestReconcileStartedRemovalRejectsReplacementQuarantine(t *testing.T) { } } +func TestEnsureRemovalMarkerRecoversInterruptedPublication(t *testing.T) { + record := workspaceRecord{ + AgentID: "11111111-1111-4111-8111-111111111111", + Removal: &workspaceRemovalRecord{ + OperationID: "47474747-4747-4747-8747-474747474747", + DirectoryToken: "48484848-4848-4848-8848-484848484848", + }, + } + for _, test := range []struct { + name string + path string + }{ + { + name: "staged payload", + path: removalMarkerTemporaryPrefix(record) + + "49494949-4949-4949-8949-494949494949", + }, + { + name: "canonical payload", + path: removalMarkerName(record), + }, + } { + t.Run(test.name, func(t *testing.T) { + path := t.TempDir() + if err := os.WriteFile( + filepath.Join(path, test.path), + []byte("partial"), + 0o600, + ); err != nil { + t.Fatalf("write interrupted marker: %v", err) + } + root, err := openRealPathRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + + if err := ensureRemovalMarker(root, record); err != nil { + t.Fatalf("recover removal marker: %v", err) + } + if err := verifyRemovalMarker(root, record); err != nil { + t.Fatalf("verify recovered marker: %v", err) + } + if test.path != removalMarkerName(record) { + if _, err := root.Lstat(test.path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("staged marker remains after recovery: %v", err) + } + } + entries, err := readRootDirectory(root) + if err != nil { + t.Fatalf("read recovered marker directory: %v", err) + } + if len(entries) != 1 || + entries[0].Name() != removalMarkerName(record) { + t.Fatalf("recovered marker entries = %v", entries) + } + }) + } +} + +func TestWorkspaceStatusDirtyIgnoresOnlyActiveRemovalFiles(t *testing.T) { + record := workspaceRecord{ + Removal: &workspaceRemovalRecord{ + OperationID: "47474747-4747-4747-8747-474747474747", + DirectoryToken: "48484848-4848-4848-8848-484848484848", + }, + } + temporary := removalMarkerTemporaryPrefix(record) + + "49494949-4949-4949-8949-494949494949" + clean := []byte( + "?? " + removalMarkerName(record) + "\n" + + "?? " + temporary + "\n", + ) + if workspaceStatusDirty(clean, &record) { + t.Fatalf("active removal files were classified as dirty: %q", clean) + } + for _, dirty := range [][]byte{ + []byte("?? " + removalMarkerTemporaryPrefix(record) + "not-a-uuid\n"), + []byte("?? .drove-removal-other.install-49494949-4949-4949-8949-494949494949\n"), + } { + if !workspaceStatusDirty(dirty, &record) { + t.Fatalf("unrelated removal file was ignored: %q", dirty) + } + } +} + func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index 98a6d00..929d0fd 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -1,6 +1,7 @@ package workspace import ( + "crypto/sha256" "errors" "fmt" "io" @@ -280,6 +281,25 @@ func removalMarkerName(record workspaceRecord) string { return ".drove-removal-" + record.Removal.OperationID } +func removalMarkerTemporaryPrefix(record workspaceRecord) string { + return removalMarkerName(record) + ".install-" +} + +func isRemovalMarkerTemporaryName( + record workspaceRecord, + name string, +) bool { + suffix, found := strings.CutPrefix( + name, + removalMarkerTemporaryPrefix(record), + ) + if !found { + return false + } + _, err := uuid.Parse(suffix) + return err == nil +} + func removalQuarantineIsolationName(name string) string { const suffix = ".rename" if strings.HasSuffix(name, suffix) { @@ -309,6 +329,9 @@ func ensureRemovalMarker( record.Removal.DirectoryToken == "" { return errors.New("workspace: removal record has no directory token") } + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } name := removalMarkerName(record) _, err := root.Lstat(name) switch { @@ -319,6 +342,19 @@ func ensureRemovalMarker( case err != nil: return fmt.Errorf("workspace: inspect removal marker: %w", err) } + if err := verifyRemovalMarker(root, record); err == nil { + return nil + } + if err := removeIncompleteRemovalMarker(root, name); err != nil { + return err + } + if err := installRemovalMarker( + root, + name, + record.Removal.DirectoryToken, + ); err != nil { + return err + } return verifyRemovalMarker(root, record) } @@ -327,8 +363,9 @@ func installRemovalMarker( name string, token string, ) (result error) { + temporaryName := name + ".install-" + uuid.NewString() file, err := root.OpenFile( - name, + temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600, ) @@ -340,8 +377,8 @@ func installRemovalMarker( if cleanupOnFailure { result = errors.Join( result, - removeRecordPathIfSame(root, name, file), - syncRecordBucket(root, name), + removeRecordPathIfSame(root, temporaryName, file), + syncRecordBucket(root, temporaryName), ) } result = errors.Join(result, file.Close()) @@ -361,15 +398,116 @@ func installRemovalMarker( if err != nil { return fmt.Errorf("workspace: open removal marker directory: %w", err) } + installed, renameErr := renameRecordFile( + directory, + file, + temporaryName, + name, + false, + ) + if !installed { + closeDirectoryErr := directory.Close() + return fmt.Errorf( + "workspace: install removal marker: %w", + errors.Join(renameErr, closeDirectoryErr), + ) + } + cleanupOnFailure = false syncErr := syncRecordDirectory(directory) closeDirectoryErr := directory.Close() - if err := errors.Join(syncErr, closeDirectoryErr); err != nil { + if err := errors.Join(renameErr, syncErr, closeDirectoryErr); err != nil { return fmt.Errorf("workspace: sync removal marker directory: %w", err) } - cleanupOnFailure = false return nil } +func cleanupRemovalMarkerTemps( + root *os.Root, + record workspaceRecord, +) (result error) { + entries, err := readRootDirectory(root) + if err != nil { + return fmt.Errorf("workspace: inspect removal marker staging files: %w", err) + } + removed := false + for _, entry := range entries { + if !isRemovalMarkerTemporaryName(record, entry.Name()) { + continue + } + info, err := root.Lstat(entry.Name()) + if err != nil { + return fmt.Errorf( + "workspace: inspect removal marker staging file: %w", + err, + ) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New( + "workspace: removal marker staging path is not a regular file", + ) + } + file, err := root.Open(entry.Name()) + if err != nil { + return fmt.Errorf( + "workspace: open removal marker staging file: %w", + err, + ) + } + opened, statErr := file.Stat() + if statErr == nil && !os.SameFile(info, opened) { + statErr = errors.New( + "workspace: removal marker staging file changed while opening", + ) + } + removeErr := error(nil) + if statErr == nil { + removeErr = removeRecordPathIfSame(root, entry.Name(), file) + } + closeErr := file.Close() + if err := errors.Join(statErr, removeErr, closeErr); err != nil { + return err + } + removed = true + } + if removed { + return syncRecordBucket(root, removalMarkerName(record)) + } + return nil +} + +func removeIncompleteRemovalMarker( + root *os.Root, + name string, +) (result error) { + info, err := root.Lstat(name) + if err != nil { + return fmt.Errorf("workspace: inspect incomplete removal marker: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace: incomplete removal marker is not a regular file") + } + file, err := root.Open(name) + if err != nil { + return fmt.Errorf("workspace: open incomplete removal marker: %w", err) + } + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() + if err != nil { + return fmt.Errorf("workspace: inspect incomplete removal marker: %w", err) + } + if !os.SameFile(info, opened) { + return errors.New( + "workspace: incomplete removal marker changed while opening", + ) + } + if err := removeRecordPathIfSame(root, name, file); err != nil { + return fmt.Errorf("workspace: remove incomplete removal marker: %w", err) + } + return syncRecordBucket(root, name) +} + func verifyRemovalMarker( root *os.Root, record workspaceRecord, @@ -949,7 +1087,7 @@ func readRootDirectory(root *os.Root) ([]os.DirEntry, error) { func removeAllFromRoot(root *os.Root, name string) (result error) { info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { - return nil + return removeRootDeletionDebris(root, name) } if err != nil { return err @@ -969,6 +1107,22 @@ func removeOpenedDirectoryFromRoot( parent *os.Root, name string, root *os.Root, +) (result error) { + return removeOpenedDirectoryFromRootAfterIsolation( + parent, + name, + root, + name, + nil, + ) +} + +func removeOpenedDirectoryFromRootAfterIsolation( + parent *os.Root, + name string, + root *os.Root, + recoveryName string, + afterIsolation func(string), ) (result error) { defer func() { if root != nil { @@ -987,6 +1141,19 @@ func removeOpenedDirectoryFromRoot( if err := verifyRootEntryUnchanged(parent, name, root); err != nil { return err } + if debris, err := findRootDeletionDebris( + parent, + recoveryName, + name, + ); err != nil { + return err + } else if debris != "" { + return fmt.Errorf( + "directory %q has existing deletion debris %q", + recoveryName, + debris, + ) + } if err := removeRootDirectoryContents( parent, name, @@ -995,12 +1162,119 @@ func removeOpenedDirectoryFromRoot( ); err != nil { return err } + openedInfo, err := root.Stat(".") + if err != nil { + return err + } + deleteName := rootDeletionPrefix(recoveryName) + uuid.NewString() + directory, err = openRecordDirectory(parent) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + openedInfo, + name, + deleteName+".rename", + deleteName, + ) + closeDirectoryErr := directory.Close() + if moved { + name = deleteName + } + if err := errors.Join(renameErr, closeDirectoryErr); err != nil { + return fmt.Errorf("isolate removed directory: %w", err) + } + if !moved { + return errors.New("removed directory was not isolated") + } + if err := verifyRootEntryUnchanged(parent, name, root); err != nil { + return err + } + if afterIsolation != nil { + afterIsolation(name) + } closeErr = root.Close() root = nil if closeErr != nil { return closeErr } - return parent.Remove(name) + if err := parent.Remove(name); err != nil { + return err + } + return syncRecordBucket(parent, name) +} + +func rootDeletionPrefix(name string) string { + digest := sha256.Sum256([]byte(name)) + return fmt.Sprintf(".drove-delete-%x-", digest[:8]) +} + +func isRootDeletionName(name string, recoveryName string) bool { + suffix, found := strings.CutPrefix(name, rootDeletionPrefix(recoveryName)) + if !found { + return false + } + suffix = strings.TrimSuffix(suffix, ".rename") + _, err := uuid.Parse(suffix) + return err == nil +} + +func findRootDeletionDebris( + parent *os.Root, + recoveryName string, + ignoredName string, +) (string, error) { + entries, err := readRootDirectory(parent) + if err != nil { + return "", err + } + found := "" + for _, entry := range entries { + if entry.Name() == ignoredName || + !isRootDeletionName(entry.Name(), recoveryName) { + continue + } + info, err := parent.Lstat(entry.Name()) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf( + "deletion debris %q is not a real directory", + entry.Name(), + ) + } + if found != "" { + return "", fmt.Errorf( + "directory %q has multiple deletion debris paths", + recoveryName, + ) + } + found = entry.Name() + } + return found, nil +} + +func removeRootDeletionDebris( + parent *os.Root, + recoveryName string, +) error { + name, err := findRootDeletionDebris(parent, recoveryName, "") + if err != nil || name == "" { + return err + } + root, err := openRealRootFromRoot(parent, name) + if err != nil { + return err + } + return removeOpenedDirectoryFromRootAfterIsolation( + parent, + name, + root, + recoveryName, + nil, + ) } func removeRootDirectoryContents( diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 0b040c4..453188b 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1136,18 +1136,12 @@ func workspaceStatusDirty( status []byte, recorded *workspaceRecord, ) bool { - ignored := "" - if recorded != nil && - recorded.Removal != nil && - recorded.Removal.DirectoryToken != "" { - ignored = "?? " + removalMarkerName(*recorded) - } for _, line := range strings.Split( strings.TrimSpace(string(status)), "\n", ) { line = strings.TrimSuffix(line, "\r") - if line == "" || line == ignored { + if line == "" || isRemovalInternalStatus(line, recorded) { continue } return true @@ -1155,6 +1149,23 @@ func workspaceStatusDirty( return false } +func isRemovalInternalStatus( + line string, + recorded *workspaceRecord, +) bool { + if recorded == nil || + recorded.Removal == nil || + recorded.Removal.DirectoryToken == "" { + return false + } + name, found := strings.CutPrefix(line, "?? ") + if !found { + return false + } + return name == removalMarkerName(*recorded) || + isRemovalMarkerTemporaryName(*recorded, name) +} + func (m *Manager) worktreeGitDirectory( ctx context.Context, path string, From 2b8c3de3a8382f464b80c00e05a1e2104eafad3e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 17:54:19 +0800 Subject: [PATCH 026/104] fix: pin BSD worktree repair metadata --- internal/workspace/prepared_worktree.go | 10 +- internal/workspace/rooted_git_other.go | 15 +++ internal/workspace/rooted_git_repair_bound.go | 32 ++++++ internal/workspace/rooted_git_test.go | 97 ++++++++++++++++++- internal/workspace/rooted_git_unix.go | 31 ++++-- 5 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 internal/workspace/rooted_git_repair_bound.go diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index fc47c9c..9c1825e 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -536,13 +536,15 @@ func (m *Manager) repairPreparedWorktreeRegistration( gitRoot, ) runRepair := func() error { - command, cleanup, err := privateRepository.worktreeCommandAt( + command, cleanup, err := rootedRepairWorktreeGitCommand( ctx, + m.git, target.Path, prepared.root, - "worktree", - "repair", - ".", + privateRepository.gitPath, + privateRepository.gitRoot, + privateRepository.commonPath, + privateRepository.commonRoot, ) if err != nil { return err diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 72c652c..061fcea 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -69,3 +69,18 @@ func rootedPreparedWorktreeGitCommand( "workspace: rooted prepared worktree Git commands are unsupported on this platform", ) } + +func rootedRepairWorktreeGitCommand( + context.Context, + string, + string, + *os.Root, + string, + *os.Root, + string, + *os.Root, +) (*exec.Cmd, func() error, error) { + return nil, nil, errors.New( + "workspace: rooted worktree repair is unsupported on this platform", + ) +} diff --git a/internal/workspace/rooted_git_repair_bound.go b/internal/workspace/rooted_git_repair_bound.go new file mode 100644 index 0000000..c763e48 --- /dev/null +++ b/internal/workspace/rooted_git_repair_bound.go @@ -0,0 +1,32 @@ +//go:build linux || windows + +package workspace + +import ( + "context" + "os" + "os/exec" +) + +func rootedRepairWorktreeGitCommand( + ctx context.Context, + git string, + worktreePath string, + worktreeRoot *os.Root, + gitPath string, + gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, +) (*exec.Cmd, func() error, error) { + return rootedWorktreeGitCommand( + ctx, + git, + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"worktree", "repair", "."}, + ) +} diff --git a/internal/workspace/rooted_git_test.go b/internal/workspace/rooted_git_test.go index f530118..e3518ab 100644 --- a/internal/workspace/rooted_git_test.go +++ b/internal/workspace/rooted_git_test.go @@ -69,14 +69,101 @@ func TestRootedPrivateGitCommandBindsCommonDirectoryForPlatform( wantExtraFiles, ) } - commonDirectory := commonPath switch runtime.GOOS { case "linux": - commonDirectory = "/proc/self/fd/4" + want := "GIT_COMMON_DIR=/proc/self/fd/4" + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q", want) + } + case "windows": + want := "GIT_COMMON_DIR=" + commonPath + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q", want) + } + default: + for _, entry := range command.Env { + if strings.HasPrefix(entry, "GIT_COMMON_DIR=") { + t.Fatalf( + "private command reopens common Git directory: %q", + entry, + ) + } + } + } +} + +func TestBSDWorktreeRepairUsesPrivateGitRoot(t *testing.T) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + default: + t.Skip("test covers the BSD rooted Git helper") + } + parent := t.TempDir() + worktreePath := filepath.Join(parent, "worktree") + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{worktreePath, gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedRepairWorktreeGitCommand( + context.Background(), + "git", + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + ) + if err != nil { + t.Fatalf("create rooted repair command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted repair command: %v", err) + } + }() + if len(command.ExtraFiles) != 1 { + t.Fatalf("repair command inherited files = %d, want 1", len(command.ExtraFiles)) + } + for _, want := range []string{ + "GIT_DIR=.", + "GIT_WORK_TREE=" + worktreePath, + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("repair command environment lacks %q", want) + } + } + for _, entry := range command.Env { + if strings.HasPrefix(entry, "GIT_COMMON_DIR=") { + t.Fatalf("repair command reopens common Git directory: %q", entry) + } } - want := "GIT_COMMON_DIR=" + commonDirectory - if !slices.Contains(command.Env, want) { - t.Fatalf("private command environment lacks %q: %q", want, command.Env) + wantArguments := []string{"worktree", "repair", worktreePath} + if len(command.Args) < len(wantArguments) || + !slices.Equal( + command.Args[len(command.Args)-len(wantArguments):], + wantArguments, + ) { + t.Fatalf("repair command arguments = %q", command.Args) } } diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index b63cb8a..b742206 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -91,7 +91,7 @@ func rootedPrivateGitCommand( ctx context.Context, git string, worktreePath string, - gitPath string, + _ string, gitRoot *os.Root, commonPath string, commonRoot *os.Root, @@ -132,15 +132,10 @@ func rootedPrivateGitCommand( helperArguments = append(helperArguments, arguments...) command := exec.CommandContext(ctx, executable, helperArguments...) command.ExtraFiles = []*os.File{directory} - commonDirectory := commonPath - if commonPath == gitPath { - commonDirectory = "." - } - command.Env = boundGitEnvironmentWithCommon( + command.Env = boundGitEnvironment( command.Environ(), ".", worktreePath, - commonDirectory, ) cleanup := func() error { return errors.Join( @@ -286,3 +281,25 @@ func rootedPreparedWorktreeGitCommand( } return command, cleanup, nil } + +func rootedRepairWorktreeGitCommand( + ctx context.Context, + git string, + worktreePath string, + _ *os.Root, + gitPath string, + gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, +) (*exec.Cmd, func() error, error) { + return rootedPrivateGitCommand( + ctx, + git, + worktreePath, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"worktree", "repair", worktreePath}, + ) +} From e2577e4bab763e95fc1cd44f153b24a38db0bc16 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 18:16:50 +0800 Subject: [PATCH 027/104] fix: lock Windows rename source names --- .../workspace/directory_rename_windows.go | 2 +- internal/workspace/record_rename_windows.go | 2 +- .../workspace/record_rename_windows_test.go | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/internal/workspace/directory_rename_windows.go b/internal/workspace/directory_rename_windows.go index 6b00402..5627c2a 100644 --- a/internal/workspace/directory_rename_windows.go +++ b/internal/workspace/directory_rename_windows.go @@ -63,7 +63,7 @@ func openDirectoryForRename( &status, nil, windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, windows.FILE_OPEN, windows.FILE_SYNCHRONOUS_IO_NONALERT| windows.FILE_DIRECTORY_FILE| diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index f6fb0c6..09255cc 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -199,7 +199,7 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { &status, nil, windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, windows.FILE_OPEN, windows.FILE_SYNCHRONOUS_IO_NONALERT| windows.FILE_NON_DIRECTORY_FILE| diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index ab5fca2..55a3e45 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -24,6 +24,57 @@ func TestOpenRecordDirectoryProvidesSyncableWindowsHandle(t *testing.T) { } } +func TestWindowsRenameHandlesBlockCanonicalReplacement(t *testing.T) { + for _, test := range []struct { + name string + make func(string) error + open func(*os.File, string) (*os.File, error) + }{ + { + name: "record", + make: func(path string) error { + return os.WriteFile(path, []byte("record\n"), 0o600) + }, + open: openRecordForRename, + }, + { + name: "directory", + make: func(path string) error { + return os.Mkdir(path, 0o700) + }, + open: openDirectoryForRename, + }, + } { + t.Run(test.name, func(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := test.make(sourcePath); err != nil { + t.Fatalf("create source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent directory: %v", err) + } + defer directory.Close() + renaming, err := test.open(directory, "source") + if err != nil { + t.Fatalf("open source for rename: %v", err) + } + targetPath := filepath.Join(rootPath, "moved") + if err := os.Rename(sourcePath, targetPath); err == nil { + _ = renaming.Close() + t.Fatal("canonical source was renamed while protected") + } + if err := renaming.Close(); err != nil { + t.Fatalf("close protected source: %v", err) + } + if err := os.Rename(sourcePath, targetPath); err != nil { + t.Fatalf("rename source after releasing protection: %v", err) + } + }) + } +} + func TestInstallAndReplaceWorkspaceRecordOnWindows(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { From 4e31b4cf8949ceee399ce9c7f40d62c408c7c4ac Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 18:22:17 +0800 Subject: [PATCH 028/104] fix: show rejected TUI input errors --- internal/clitui/model.go | 1 + internal/clitui/model_test.go | 11 +++++++++++ internal/clitui/view.go | 3 +++ 3 files changed, 15 insertions(+) diff --git a/internal/clitui/model.go b/internal/clitui/model.go index 58d54d2..94999a1 100644 --- a/internal/clitui/model.go +++ b/internal/clitui/model.go @@ -403,6 +403,7 @@ func (m *model) handleSendKey(message tea.KeyMsg) tea.Cmd { m.focus = focusFleet return m.startSend(id, payload) default: + m.notice = "" var command tea.Cmd m.input, command = m.input.Update(message) return command diff --git a/internal/clitui/model_test.go b/internal/clitui/model_test.go index 8dd3db3..6dee495 100644 --- a/internal/clitui/model_test.go +++ b/internal/clitui/model_test.go @@ -277,6 +277,17 @@ func TestActionSendAddsExactlyOneNewlineAndEnforcesByteLimit(t *testing.T) { if !strings.Contains(m.notice, "maximum") || m.focus != focusSend { t.Fatalf("oversized input notice=%q focus=%d", m.notice, m.focus) } + if view := m.View(); !strings.Contains(view, "maximum") { + t.Fatalf("oversized input notice is not visible:\n%s", view) + } + + _, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + if m.notice != "" { + t.Fatalf("editing oversized input retained notice %q", m.notice) + } + if view := m.View(); !strings.Contains(view, "SEND agent-1") { + t.Fatalf("send editor did not return after editing:\n%s", view) + } } func TestPendingActionBlocksOtherActionsAndCancellationStopsRequest(t *testing.T) { diff --git a/internal/clitui/view.go b/internal/clitui/view.go index 1a5afb4..1843d7b 100644 --- a/internal/clitui/view.go +++ b/internal/clitui/view.go @@ -221,6 +221,9 @@ func (m *model) renderExplainPane(width, height int) string { func (m *model) renderStatus() string { switch m.focus { case focusSend: + if m.notice != "" { + return errorStyle.Render(terminalInlineText(m.notice)) + } return fmt.Sprintf( "SEND %s %s", terminalInlineText(m.actionID), From 7415400eeb7a9319aa3b349eee742849c3577d1c Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 18:55:27 +0800 Subject: [PATCH 029/104] fix: reopen Windows records without delete access --- internal/workspace/record_rename_windows.go | 39 +++++++++++++++---- .../workspace/record_rename_windows_test.go | 27 ++++++++++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 09255cc..4ad1cb8 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -25,7 +25,7 @@ func renameRecordFile( recordName string, replace bool, ) (installed bool, result error) { - renaming, err := openRecordForRename(directory, temporaryName) + renaming, err := openRecordForMutation(directory, temporaryName) if err != nil { return false, err } @@ -90,7 +90,7 @@ func unlinkRecordPathAfterValidation( name string, afterValidation func(), ) (result error) { - deleting, err := openRecordForRename(directory, name) + deleting, err := openRecordForMutation(directory, name) if err != nil { return err } @@ -113,7 +113,7 @@ func unlinkRecordPathAfterValidation( afterValidation() } - current, err := openRecordForRename(directory, name) + current, err := openRecordForIdentity(directory, name) if err != nil { return fmt.Errorf("reopen record path %q: %w", name, err) } @@ -176,7 +176,32 @@ func renameWindowsHandle( return true, nil } -func openRecordForRename(directory *os.File, name string) (*os.File, error) { +func openRecordForMutation(directory *os.File, name string) (*os.File, error) { + return openRecordWithAccess( + directory, + name, + windows.FILE_READ_ATTRIBUTES|windows.DELETE|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + ) +} + +func openRecordForIdentity(directory *os.File, name string) (*os.File, error) { + return openRecordWithAccess( + directory, + name, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ| + windows.FILE_SHARE_WRITE| + windows.FILE_SHARE_DELETE, + ) +} + +func openRecordWithAccess( + directory *os.File, + name string, + access uint32, + share uint32, +) (*os.File, error) { objectName, err := windows.NewNTUnicodeString(name) if err != nil { return nil, err @@ -192,14 +217,12 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { ) err = windows.NtCreateFile( &handle, - windows.FILE_READ_ATTRIBUTES| - windows.DELETE| - windows.SYNCHRONIZE, + access, attributes, &status, nil, windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + share, windows.FILE_OPEN, windows.FILE_SYNCHRONOUS_IO_NONALERT| windows.FILE_NON_DIRECTORY_FILE| diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 55a3e45..69fb899 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -35,7 +35,7 @@ func TestWindowsRenameHandlesBlockCanonicalReplacement(t *testing.T) { make: func(path string) error { return os.WriteFile(path, []byte("record\n"), 0o600) }, - open: openRecordForRename, + open: openRecordForMutation, }, { name: "directory", @@ -75,6 +75,31 @@ func TestWindowsRenameHandlesBlockCanonicalReplacement(t *testing.T) { } } +func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent directory: %v", err) + } + defer directory.Close() + + if err := unlinkRecordPath(directory, expected, "source"); err != nil { + t.Fatalf("unlink source while mutation handle is held: %v", err) + } + if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { + t.Fatalf("source remains after unlink: %v", err) + } +} + func TestInstallAndReplaceWorkspaceRecordOnWindows(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { From e127a4596020878d0d5c559a517ca3e06bd4ff1a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 18:59:22 +0800 Subject: [PATCH 030/104] fix: recover renamed removal reservations --- internal/workspace/removal_acknowledgement.go | 66 ++++++- .../workspace/removal_acknowledgement_test.go | 169 ++++++++++++++++++ 2 files changed, 226 insertions(+), 9 deletions(-) diff --git a/internal/workspace/removal_acknowledgement.go b/internal/workspace/removal_acknowledgement.go index bf8f2ec..47cc897 100644 --- a/internal/workspace/removal_acknowledgement.go +++ b/internal/workspace/removal_acknowledgement.go @@ -4,10 +4,17 @@ import ( "errors" "fmt" "os" + "strings" "github.com/google/uuid" ) +type removalAcknowledgementReservationCandidate struct { + name string + id uuid.UUID + renamed bool +} + type removalAcknowledgementReservation struct { bucket *os.Root root *os.Root @@ -288,17 +295,43 @@ func cleanupRemovalAcknowledgementReservations( return err } prefix := removalAcknowledgementReservationPrefix(record) + candidates := make( + []removalAcknowledgementReservationCandidate, + 0, + ) + seen := make(map[uuid.UUID]removalAcknowledgementReservationCandidate) for _, entry := range entries { - if !validRemovalAcknowledgementReservationName(entry.Name(), prefix) { + if !strings.HasPrefix(entry.Name(), prefix) { continue } + candidate, valid := parseRemovalAcknowledgementReservationName( + entry.Name(), + prefix, + ) + if !valid { + return fmt.Errorf( + "workspace: removal acknowledgement reservation %q has an invalid name", + entry.Name(), + ) + } if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { return fmt.Errorf( "workspace: removal acknowledgement reservation %q is not a real directory", entry.Name(), ) } - root, err := openRealRootFromRoot(bucket, entry.Name()) + if previous, exists := seen[candidate.id]; exists { + return fmt.Errorf( + "workspace: removal acknowledgement reservations %q and %q have ambiguous phases", + previous.name, + candidate.name, + ) + } + seen[candidate.id] = candidate + candidates = append(candidates, candidate) + } + for _, candidate := range candidates { + root, err := openRealRootFromRoot(bucket, candidate.name) if err != nil { return err } @@ -306,7 +339,7 @@ func cleanupRemovalAcknowledgementReservations( bucket: bucket, root: root, record: record, - name: entry.Name(), + name: candidate.name, } if err := cleanupIsolatedRemovalAcknowledgementReservation( reservation, @@ -352,16 +385,31 @@ func cleanupIsolatedRemovalAcknowledgementReservation( return syncRecordBucket(reservation.bucket, reservation.record.Path) } -func validRemovalAcknowledgementReservationName( +func parseRemovalAcknowledgementReservationName( name string, prefix string, -) bool { - if len(name) != len(prefix)+36 || name[:len(prefix)] != prefix { - return false +) (removalAcknowledgementReservationCandidate, bool) { + raw, found := strings.CutPrefix(name, prefix) + if !found { + return removalAcknowledgementReservationCandidate{}, false + } + const renameSuffix = ".rename" + renamed := strings.HasSuffix(raw, renameSuffix) + if renamed { + raw = strings.TrimSuffix(raw, renameSuffix) + } + if len(raw) != 36 { + return removalAcknowledgementReservationCandidate{}, false } - raw := name[len(prefix):] parsed, err := uuid.Parse(raw) - return err == nil && parsed.String() == raw + if err != nil || parsed.String() != raw { + return removalAcknowledgementReservationCandidate{}, false + } + return removalAcknowledgementReservationCandidate{ + name: name, + id: parsed, + renamed: renamed, + }, true } func removalPathIsAcknowledgementReservation( diff --git a/internal/workspace/removal_acknowledgement_test.go b/internal/workspace/removal_acknowledgement_test.go index 410b5d3..1185ed9 100644 --- a/internal/workspace/removal_acknowledgement_test.go +++ b/internal/workspace/removal_acknowledgement_test.go @@ -7,9 +7,178 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" ) +func TestParseRemovalAcknowledgementReservationName(t *testing.T) { + const ( + prefix = ".record.ack-path-operation-" + id = "abcdefab-cdef-4abc-8def-abcdefabcdef" + ) + for _, test := range []struct { + name string + value string + valid bool + renamed bool + }{ + {name: "base", value: prefix + id, valid: true}, + { + name: "rename", + value: prefix + id + ".rename", + valid: true, + renamed: true, + }, + {name: "wrong prefix", value: "other-" + id}, + {name: "uppercase UUID", value: prefix + strings.ToUpper(id)}, + {name: "double rename", value: prefix + id + ".rename.rename"}, + {name: "extra suffix", value: prefix + id + ".extra"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate, valid := parseRemovalAcknowledgementReservationName( + test.value, + prefix, + ) + if valid != test.valid { + t.Fatalf("valid = %v, want %v", valid, test.valid) + } + if valid && candidate.renamed != test.renamed { + t.Fatalf( + "renamed = %v, want %v", + candidate.renamed, + test.renamed, + ) + } + }) + } +} + +func TestCleanupRemovalAcknowledgementReservationsRecoversRenamePhase( + t *testing.T, +) { + for _, renamed := range []bool{false, true} { + t.Run(map[bool]string{false: "base", true: "rename"}[renamed], func(t *testing.T) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + name := removalAcknowledgementReservationPrefix(record) + + "89898989-8989-4898-8989-898989898989" + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + if renamed { + renamedPath := filepath.Join(bucketPath, name+".rename") + if err := os.Rename( + filepath.Join(bucketPath, name), + renamedPath, + ); err != nil { + t.Fatalf("isolate reservation: %v", err) + } + name += ".rename" + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("cleanup reservation: %v", err) + } + if _, err := os.Lstat(filepath.Join(bucketPath, name)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reservation remains: %v", err) + } + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("repeat cleanup reservation: %v", err) + } + }) + } +} + +func TestCleanupRemovalAcknowledgementReservationsRejectsAmbiguousPhases( + t *testing.T, +) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + baseName := removalAcknowledgementReservationPrefix(record) + + "90909090-9090-4090-8090-909090909090" + for _, name := range []string{baseName, baseName + ".rename"} { + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err == nil { + t.Fatal("cleanup accepted ambiguous reservation phases") + } + for _, name := range []string{baseName, baseName + ".rename"} { + if _, err := os.Stat(filepath.Join(bucketPath, name)); err != nil { + t.Fatalf("ambiguous reservation %q changed: %v", name, err) + } + } +} + +func removalAcknowledgementTestRecord(bucketPath string) workspaceRecord { + return workspaceRecord{ + AgentID: testAgentID, + Path: filepath.Join(bucketPath, testAgentID), + Removal: &workspaceRemovalRecord{ + OperationID: "67676767-6767-4767-8767-676767676767", + DirectoryToken: "68686868-6868-4868-8868-686868686868", + Started: true, + }, + } +} + +func createRemovalAcknowledgementReservationFixture( + t *testing.T, + bucket *os.Root, + record workspaceRecord, + name string, +) { + t.Helper() + if err := bucket.Mkdir(name, 0o700); err != nil { + t.Fatalf("create reservation %q: %v", name, err) + } + root, err := openRealRootFromRoot(bucket, name) + if err != nil { + t.Fatalf("open reservation %q: %v", name, err) + } + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + _ = root.Close() + t.Fatalf("install reservation marker: %v", err) + } + if err := root.Close(); err != nil { + t.Fatalf("close reservation %q: %v", name, err) + } +} + func TestAcknowledgeRemovalReservesManagedPathDuringRepositoryCheck( t *testing.T, ) { From b1a0bc973a4f10e27bc365534ad10c90e210d1f5 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 19:05:13 +0800 Subject: [PATCH 031/104] fix: recover interrupted Unix record installs --- internal/workspace/record.go | 19 +++ internal/workspace/record_recovery_other.go | 9 ++ internal/workspace/record_recovery_unix.go | 142 ++++++++++++++++++ internal/workspace/record_rename_unix.go | 80 +++++++++- internal/workspace/record_rename_unix_test.go | 83 ++++++++++ 5 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 internal/workspace/record_recovery_other.go create mode 100644 internal/workspace/record_recovery_unix.go diff --git a/internal/workspace/record.go b/internal/workspace/record.go index be6b814..dcf1815 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -190,6 +190,12 @@ func (m *Manager) installWorkspaceRecordState( defer func() { result = errors.Join(result, bucket.Close()) }() + if err := recoverRecordRenameDebris(bucket); err != nil { + return false, fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } name := agentID + workspaceRecordSuffix if err := checkRecordTarget(bucket, name, recordPath, requireAbsent); err != nil { return false, err @@ -439,6 +445,12 @@ func (m *Manager) readWorkspaceRecordFromBucket( ) (_ workspaceRecord, _ bool, result error) { recordPath := workspaceRecordPath(worktreePath) name := agentID + workspaceRecordSuffix + if err := recoverRecordRenameDebris(bucket); err != nil { + return workspaceRecord{}, false, fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } info, err := bucket.Lstat(name) if errors.Is(err, os.ErrNotExist) { return workspaceRecord{}, false, nil @@ -1226,6 +1238,13 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { _ = bucketRoot.Close() return err } + if err := recoverRecordRenameDebris(bucketRoot); err != nil { + _ = bucketRoot.Close() + return fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } entries, err := readRootDirectory(bucketRoot) if err != nil { _ = bucketRoot.Close() diff --git a/internal/workspace/record_recovery_other.go b/internal/workspace/record_recovery_other.go new file mode 100644 index 0000000..a253c29 --- /dev/null +++ b/internal/workspace/record_recovery_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd + +package workspace + +import "os" + +func recoverRecordRenameDebris(_ *os.Root) error { + return nil +} diff --git a/internal/workspace/record_recovery_unix.go b/internal/workspace/record_recovery_unix.go new file mode 100644 index 0000000..fccc644 --- /dev/null +++ b/internal/workspace/record_recovery_unix.go @@ -0,0 +1,142 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/google/uuid" +) + +const ( + recordInstallAliasPrefix = ".drove-install-" + recordRemoveLegacyPrefix = ".drove-remove-" +) + +type recordInstallAlias struct { + name string +} + +func recoverRecordRenameDebris(root *os.Root) (result error) { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + aliases := make([]recordInstallAlias, 0) + for _, entry := range entries { + switch { + case strings.HasPrefix(entry.Name(), recordRemoveLegacyPrefix): + return fmt.Errorf( + "workspace: legacy record removal artifact %q has no recoverable owner", + entry.Name(), + ) + case strings.HasPrefix(entry.Name(), recordInstallAliasPrefix): + if !validRecordInstallAliasName(entry.Name()) { + return fmt.Errorf( + "workspace: record install artifact %q has an invalid name", + entry.Name(), + ) + } + if !entry.Type().IsRegular() { + return fmt.Errorf( + "workspace: record install artifact %q is not a regular file", + entry.Name(), + ) + } + aliases = append(aliases, recordInstallAlias{name: entry.Name()}) + } + } + if len(aliases) == 0 { + return nil + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, directory.Close()) + }() + for _, alias := range aliases { + file, err := openRecordPath(directory, alias.name) + if err != nil { + return fmt.Errorf( + "workspace: open record install artifact %q: %w", + alias.name, + err, + ) + } + witness, witnessErr := findRecordInstallAliasWitness( + directory, + entries, + alias.name, + file, + ) + if witnessErr == nil { + witnessErr = unlinkLinkedRecordPath( + directory, + file, + alias.name, + witness, + ) + } + closeErr := file.Close() + if err := errors.Join(witnessErr, closeErr); err != nil { + return err + } + } + return syncRecordDirectory(directory) +} + +func validRecordInstallAliasName(name string) bool { + raw, found := strings.CutPrefix(name, recordInstallAliasPrefix) + if !found || len(raw) != 36 { + return false + } + parsed, err := uuid.Parse(raw) + return err == nil && parsed.String() == raw +} + +func findRecordInstallAliasWitness( + directory *os.File, + entries []os.DirEntry, + aliasName string, + alias *os.File, +) (string, error) { + aliasInfo, err := alias.Stat() + if err != nil { + return "", err + } + if !aliasInfo.Mode().IsRegular() { + return "", fmt.Errorf( + "workspace: record install artifact %q is not a regular file", + aliasName, + ) + } + for _, entry := range entries { + if entry.Name() == aliasName || + strings.HasPrefix(entry.Name(), recordInstallAliasPrefix) || + !entry.Type().IsRegular() { + continue + } + candidate, err := openRecordPath(directory, entry.Name()) + if err != nil { + return "", err + } + candidateInfo, statErr := candidate.Stat() + closeErr := candidate.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return "", err + } + if candidateInfo.Mode().IsRegular() && + os.SameFile(aliasInfo, candidateInfo) { + return entry.Name(), nil + } + } + return "", fmt.Errorf( + "workspace: record install artifact %q has no same-file owner", + aliasName, + ) +} diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index 45a5d89..8f6bcb9 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -91,7 +91,12 @@ func renameRecordPathAfterValidation( if aliasPresent { result = errors.Join( result, - unlinkRecordPath(directory, aliasFile, alias), + unlinkLinkedRecordPath( + directory, + aliasFile, + alias, + sourceName, + ), ) } result = errors.Join(result, aliasFile.Close()) @@ -121,10 +126,11 @@ func renameRecordPathAfterValidation( err, ) } - if err := unlinkRecordPath( + if err := unlinkLinkedRecordPath( directory, expected, sourceName, + targetName, ); err != nil { return true, fmt.Errorf( "remove installed record source: %w", @@ -146,7 +152,12 @@ func renameRecordPathAfterValidation( err, ) } - if err := unlinkRecordPath(directory, aliasFile, alias); err != nil { + if err := unlinkLinkedRecordPath( + directory, + aliasFile, + alias, + targetName, + ); err != nil { return true, err } aliasPresent = false @@ -160,10 +171,11 @@ func renameRecordPathAfterValidation( err, ) } - if err := unlinkRecordPath( + if err := unlinkLinkedRecordPath( directory, expected, sourceName, + targetName, ); err != nil { return true, fmt.Errorf( "remove installed record source: %w", @@ -173,6 +185,66 @@ func renameRecordPathAfterValidation( return true, nil } +func unlinkLinkedRecordPath( + directory *os.File, + expected *os.File, + name string, + witnessName string, +) error { + if err := verifyRecordPathIdentity( + directory, + expected, + name, + ); err != nil { + return err + } + if err := verifyRecordPathIdentity( + directory, + expected, + witnessName, + ); err != nil { + return fmt.Errorf("verify linked record witness: %w", err) + } + fd := int(directory.Fd()) + isolatedName := ".drove-install-" + uuid.NewString() + if err := unix.Renameat(fd, name, fd, isolatedName); err != nil { + return fmt.Errorf("isolate linked record path %q: %w", name, err) + } + restore := func() error { + if err := unix.Linkat(fd, isolatedName, fd, name, 0); err != nil { + return fmt.Errorf( + "restore linked record path %q: %w", + name, + err, + ) + } + if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { + return fmt.Errorf( + "remove restored linked record isolation %q: %w", + isolatedName, + err, + ) + } + return nil + } + if err := errors.Join( + verifyRecordPathIdentity(directory, expected, isolatedName), + verifyRecordPathIdentity(directory, expected, witnessName), + ); err != nil { + return errors.Join( + fmt.Errorf("verify isolated linked record path %q: %w", name, err), + restore(), + ) + } + if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { + return errors.Join( + fmt.Errorf("remove isolated linked record path %q: %w", name, err), + restore(), + ) + } + return nil +} + func unlinkRecordPath( directory *os.File, expected *os.File, diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 4166ecb..45f6eb6 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -122,3 +122,86 @@ func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { assertFileContents(t, sourcePath, "replacement\n") assertFileContents(t, movedPath, "original\n") } + +func TestRecoverRecordInstallAliasUsesSameFileWitness(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + aliasName := recordInstallAliasPrefix + + "78787878-7878-4787-8787-787878787878" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.Link(sourcePath, aliasPath); err != nil { + t.Fatalf("link install alias: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("recover install alias: %v", err) + } + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("repeat install alias recovery: %v", err) + } + assertFileContents(t, sourcePath, "record\n") + if _, err := os.Lstat(aliasPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("install alias remains: %v", err) + } +} + +func TestRecoverRecordInstallAliasRejectsMissingWitness(t *testing.T) { + rootPath := t.TempDir() + aliasName := recordInstallAliasPrefix + + "89898989-8989-4898-8989-898989898989" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.WriteFile(aliasPath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write install alias: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err == nil { + t.Fatal("recovery accepted an install alias without an owner") + } + assertFileContents(t, aliasPath, "record\n") +} + +func TestRecoverRecordRenameDebrisRejectsLegacyRemovalBeforeMutation( + t *testing.T, +) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + aliasName := recordInstallAliasPrefix + + "90909090-9090-4090-8090-909090909090" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.Link(sourcePath, aliasPath); err != nil { + t.Fatalf("link install alias: %v", err) + } + legacyName := recordRemoveLegacyPrefix + + "67676767-6767-4767-8767-676767676767" + legacyPath := filepath.Join(rootPath, legacyName) + if err := os.WriteFile(legacyPath, []byte("legacy\n"), 0o600); err != nil { + t.Fatalf("write legacy removal artifact: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err == nil { + t.Fatal("recovery accepted a legacy removal artifact") + } + assertFileContents(t, aliasPath, "record\n") + assertFileContents(t, legacyPath, "legacy\n") +} From 4e11cbe50ea2b2813a82de9a51a0f93ea9cd5ee1 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 19:12:34 +0800 Subject: [PATCH 032/104] fix: recover Unix record removal artifacts --- internal/workspace/record.go | 248 +++++++++--------- internal/workspace/record_artifact.go | 287 +++++++++++++++++++++ internal/workspace/record_artifact_test.go | 140 ++++++++++ internal/workspace/record_owned.go | 107 ++++++++ internal/workspace/record_owned_other.go | 18 ++ internal/workspace/record_owned_unix.go | 24 ++ internal/workspace/record_owned_windows.go | 13 + 7 files changed, 716 insertions(+), 121 deletions(-) create mode 100644 internal/workspace/record_artifact.go create mode 100644 internal/workspace/record_artifact_test.go create mode 100644 internal/workspace/record_owned.go create mode 100644 internal/workspace/record_owned_other.go create mode 100644 internal/workspace/record_owned_unix.go create mode 100644 internal/workspace/record_owned_windows.go diff --git a/internal/workspace/record.go b/internal/workspace/record.go index dcf1815..87574b4 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -196,6 +196,16 @@ func (m *Manager) installWorkspaceRecordState( err, ) } + if err := m.recoverWorkspaceRecordArtifacts( + bucket, + filepath.Base(filepath.Dir(record.Path)), + record.AgentID, + ); err != nil { + return false, fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } name := agentID + workspaceRecordSuffix if err := checkRecordTarget(bucket, name, recordPath, requireAbsent); err != nil { return false, err @@ -212,7 +222,7 @@ func (m *Manager) installWorkspaceRecordState( ) } - temporaryName := "." + name + ".tmp-" + uuid.NewString() + temporaryName := workspaceRecordTemporaryName(name) file, err := bucket.OpenFile( temporaryName, os.O_RDWR|os.O_CREATE|os.O_EXCL, @@ -230,7 +240,13 @@ func (m *Manager) installWorkspaceRecordState( if temporaryName != "" { result = errors.Join( result, - removeRecordPathIfSame(bucket, temporaryName, file), + removeOwnedRecordPathIfSame( + bucket, + temporaryName, + file, + workspaceRecordTemporaryName(name), + nil, + ), ) } if fileOpen { @@ -451,6 +467,16 @@ func (m *Manager) readWorkspaceRecordFromBucket( err, ) } + if err := m.recoverWorkspaceRecordArtifacts( + bucket, + filepath.Base(filepath.Dir(worktreePath)), + agentID, + ); err != nil { + return workspaceRecord{}, false, fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } info, err := bucket.Lstat(name) if errors.Is(err, os.ErrNotExist) { return workspaceRecord{}, false, nil @@ -500,16 +526,32 @@ func (m *Manager) readWorkspaceRecordFromBucket( recordPath, ) } + record, err := m.decodeWorkspaceRecordFile( + file, + recordPath, + worktreePath, + ) + if err != nil { + return workspaceRecord{}, false, err + } + return record, true, nil +} + +func (m *Manager) decodeWorkspaceRecordFile( + file *os.File, + recordPath string, + worktreePath string, +) (workspaceRecord, error) { raw, err := io.ReadAll(io.LimitReader(file, maxWorkspaceRecordSize+1)) if err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: read record %q: %w", recordPath, err, ) } if len(raw) > maxWorkspaceRecordSize { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q exceeds %d bytes", recordPath, maxWorkspaceRecordSize, @@ -519,14 +561,14 @@ func (m *Manager) readWorkspaceRecordFromBucket( decoder.DisallowUnknownFields() var record workspaceRecord if err := decoder.Decode(&record); err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: decode record %q: %w", recordPath, err, ) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q has trailing data", recordPath, ) @@ -543,7 +585,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.DirectoryIdentity != "" || record.RepositoryEvidence != nil || record.Removal != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: legacy record %q contains newer fields", recordPath, ) @@ -551,7 +593,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.PreparationCommitted = true case protectedWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 2 record %q has no included paths", recordPath, ) @@ -563,7 +605,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.GitDirectory != "" || record.DirectoryIdentity != "" || record.RepositoryEvidence != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 2 record %q contains version 3 fields", recordPath, ) @@ -574,52 +616,52 @@ func (m *Manager) readWorkspaceRecordFromBucket( } case preparationWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 3 record %q has no included paths", recordPath, ) } if record.RepositoryEvidence != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 3 record %q contains version 4 fields", recordPath, ) } case repositoryWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 4 record %q has no included paths", recordPath, ) } case workspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 5 record %q has no included paths", recordPath, ) } default: - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q has unsupported version %d", recordPath, record.Version, ) } if filepath.Clean(worktreePath) != record.Path { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q path mismatch", recordPath, ) } if err := m.validateWorkspaceRecord(record); err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: validate record %q: %w", recordPath, err, ) } - return record, true, nil + return record, nil } func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { @@ -872,56 +914,54 @@ func (m *Manager) removeWorkspaceRecordAfterValidation( recordPath, ) } - if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + record, err := m.decodeWorkspaceRecordFile( + opened, + recordPath, + worktreePath, + ) + if err != nil { return err } - directory, err := openRecordDirectory(bucket) - if err != nil { - return fmt.Errorf( - "workspace: open record directory %q: %w", - recordPath, - err, - ) + if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + return err } - isolatedName := ".drove-record-remove-" + uuid.NewString() - isolated, isolateErr := isolateRecordPathIfSame( + isolatedName := workspaceRecordRemovalName(record) + if err := removeOwnedRecordPath( bucket, - directory, name, - isolatedName, opened, + isolatedName, + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + current, err := m.decodeWorkspaceRecordFile( + candidate, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if current.AgentID != record.AgentID { + return errors.New( + "workspace: isolated record changed ownership", + ) + } + return nil + }, afterValidation, - ) - syncErr := syncRecordDirectory(directory) - closeErr := directory.Close() - if err := errors.Join(isolateErr, syncErr, closeErr); err != nil { + ); err != nil { return fmt.Errorf( - "workspace: isolate record %q: %w", + "workspace: remove record %q: %w", recordPath, err, ) } - if !isolated { - return fmt.Errorf( - "workspace: record %q was not isolated", - recordPath, - ) - } if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { return err } - if err := removeRecordPathIfSame( - bucket, - isolatedName, - opened, - ); err != nil { - return fmt.Errorf( - "workspace: remove isolated record %q: %w", - recordPath, - err, - ) - } - return syncRecordBucket(bucket, recordPath) + return nil } func (m *Manager) removeAcknowledgedWorkspaceRecord( @@ -1245,6 +1285,17 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { err, ) } + if err := m.recoverWorkspaceRecordArtifacts( + bucketRoot, + bucket.Name(), + "", + ); err != nil { + _ = bucketRoot.Close() + return fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } entries, err := readRootDirectory(bucketRoot) if err != nil { _ = bucketRoot.Close() @@ -1446,48 +1497,25 @@ func coalesceRecordAcknowledgementsAfterValidation( ) } for _, duplicate := range candidates[1:] { - directory, err := openRecordDirectory(bucket) - if err != nil { - return recordAcknowledgement{}, err - } isolatedName := recordAcknowledgementPrefix( agentID, operationID, ) + uuid.NewString() - isolated, isolateErr := isolateRecordPathIfSame( + if err := removeOwnedRecordPath( bucket, - directory, duplicate.name, - isolatedName, expected, - afterValidation, - ) - afterValidation = nil - closeErr := directory.Close() - if err := errors.Join(isolateErr, closeErr); err != nil { - return recordAcknowledgement{}, fmt.Errorf( - "workspace: isolate acknowledgement alias %q: %w", - duplicate.name, - err, - ) - } - if !isolated { - return recordAcknowledgement{}, fmt.Errorf( - "workspace: acknowledgement alias %q was not isolated", - duplicate.name, - ) - } - if err := removeRecordPathIfSame( - bucket, isolatedName, - expected, + nil, + afterValidation, ); err != nil { return recordAcknowledgement{}, fmt.Errorf( - "workspace: remove isolated acknowledgement alias %q: %w", - isolatedName, + "workspace: remove acknowledgement alias %q: %w", + duplicate.name, err, ) } + afterValidation = nil } if len(candidates) > 1 { if err := syncRecordBucket(bucket, candidates[0].name); err != nil { @@ -1511,60 +1539,38 @@ func (m *Manager) removeRecordAcknowledgementQuarantine( err, ) } - fileOpen := true defer func() { - if fileOpen { - result = errors.Join(result, file.Close()) - } + result = errors.Join(result, file.Close()) }() - if _, err := m.verifyRemovalRecordFile( - file, - recordPath, - removal, - ); err != nil { - return err - } - directory, err := openRecordDirectory(bucket) - if err != nil { - return fmt.Errorf( - "workspace: open quarantined record directory %q: %w", - recordPath, - err, - ) - } deleteName := recordAcknowledgementPrefix( removal.Workspace.AgentID, removal.operationID, ) + uuid.NewString() - moved, moveErr := moveRecordFile( - directory, - file, + if err := removeOwnedRecordPath( + bucket, name, + file, deleteName, - ) - syncErr := syncRecordDirectory(directory) - closeErr := directory.Close() - if err := errors.Join(moveErr, syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: isolate quarantined record %q: %w", - recordPath, - err, - ) - } - if !moved { - return fmt.Errorf( - "workspace: quarantined record %q was not isolated", - recordPath, - ) - } - if err := removeRecordPathIfSame(bucket, deleteName, file); err != nil { + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := m.verifyRemovalRecordFile( + candidate, + recordPath, + removal, + ) + return err + }, + nil, + ); err != nil { return fmt.Errorf( "workspace: remove quarantined record %q: %w", recordPath, err, ) } - return syncRecordBucket(bucket, recordPath) + return nil } func restoreRecordAcknowledgement( diff --git a/internal/workspace/record_artifact.go b/internal/workspace/record_artifact.go new file mode 100644 index 0000000..9fbbea1 --- /dev/null +++ b/internal/workspace/record_artifact.go @@ -0,0 +1,287 @@ +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const ( + workspaceRecordRemovalLegacyPrefix = ".drove-record-remove-" + workspaceRecordRemovalPrefix = workspaceRecordRemovalLegacyPrefix + "v1-" +) + +type workspaceRecordArtifactKind uint8 + +const ( + workspaceRecordTemporaryArtifact workspaceRecordArtifactKind = iota + 1 + workspaceRecordRemovalArtifact +) + +type workspaceRecordArtifact struct { + name string + agentID string + operationID string + kind workspaceRecordArtifactKind +} + +func workspaceRecordTemporaryName(recordName string) string { + return "." + recordName + ".tmp-" + uuid.NewString() +} + +func workspaceRecordRemovalName(record workspaceRecord) string { + operationID := "none" + if record.Removal != nil { + operationID = record.Removal.OperationID + } + return workspaceRecordRemovalPrefix + record.AgentID + "-" + + operationID + "-" + uuid.NewString() +} + +func parseWorkspaceRecordArtifactName( + name string, +) (workspaceRecordArtifact, bool) { + if temporary, valid := parseWorkspaceRecordTemporaryName(name); valid { + return temporary, true + } + return parseWorkspaceRecordRemovalName(name) +} + +func parseWorkspaceRecordTemporaryName( + name string, +) (workspaceRecordArtifact, bool) { + if !strings.HasPrefix(name, ".") { + return workspaceRecordArtifact{}, false + } + const marker = workspaceRecordSuffix + ".tmp-" + raw := strings.TrimPrefix(name, ".") + index := strings.Index(raw, marker) + if index < 0 { + return workspaceRecordArtifact{}, false + } + agentID := raw[:index] + if validateAgentID(agentID) != nil { + return workspaceRecordArtifact{}, false + } + nonce := raw[index+len(marker):] + if !canonicalUUID(nonce) { + return workspaceRecordArtifact{}, false + } + return workspaceRecordArtifact{ + name: name, + agentID: agentID, + kind: workspaceRecordTemporaryArtifact, + }, true +} + +func parseWorkspaceRecordRemovalName( + name string, +) (workspaceRecordArtifact, bool) { + raw, found := strings.CutPrefix(name, workspaceRecordRemovalPrefix) + if !found || len(raw) < 36+1+4+1+36 { + return workspaceRecordArtifact{}, false + } + agentID := raw[:36] + if validateAgentID(agentID) != nil || raw[36] != '-' { + return workspaceRecordArtifact{}, false + } + raw = raw[37:] + operationID := "none" + switch { + case strings.HasPrefix(raw, "none-"): + raw = strings.TrimPrefix(raw, "none-") + default: + if len(raw) < 36+1+36 || raw[36] != '-' { + return workspaceRecordArtifact{}, false + } + operationID = raw[:36] + if !canonicalUUID(operationID) { + return workspaceRecordArtifact{}, false + } + raw = raw[37:] + } + if !canonicalUUID(raw) { + return workspaceRecordArtifact{}, false + } + return workspaceRecordArtifact{ + name: name, + agentID: agentID, + operationID: operationID, + kind: workspaceRecordRemovalArtifact, + }, true +} + +func canonicalUUID(raw string) bool { + if len(raw) != 36 { + return false + } + parsed, err := uuid.Parse(raw) + return err == nil && parsed.String() == raw +} + +func (m *Manager) recoverWorkspaceRecordArtifacts( + bucket *os.Root, + bucketName string, + agentFilter string, +) error { + entries, err := readRootDirectory(bucket) + if err != nil { + return err + } + for _, entry := range entries { + artifact, valid := parseWorkspaceRecordArtifactName(entry.Name()) + if !valid { + if strings.HasPrefix( + entry.Name(), + workspaceRecordRemovalLegacyPrefix, + ) || + strings.Contains( + entry.Name(), + workspaceRecordSuffix+".tmp-", + ) { + return fmt.Errorf( + "workspace: record artifact %q has an invalid name", + entry.Name(), + ) + } + continue + } + if agentFilter != "" && artifact.agentID != agentFilter { + continue + } + if err := m.recoverWorkspaceRecordArtifact( + bucket, + bucketName, + artifact, + ); err != nil { + return err + } + } + return nil +} + +func (m *Manager) recoverWorkspaceRecordArtifact( + bucket *os.Root, + bucketName string, + artifact workspaceRecordArtifact, +) (result error) { + info, err := bucket.Lstat(artifact.name) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: record artifact %q is not a regular file", + artifact.name, + ) + } + file, err := bucket.Open(artifact.name) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() + if err != nil { + return err + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return fmt.Errorf( + "workspace: record artifact %q changed while opening", + artifact.name, + ) + } + worktreePath := filepath.Join(m.root, bucketName, artifact.agentID) + recordPath := workspaceRecordPath(worktreePath) + record, err := m.decodeWorkspaceRecordFile( + file, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if record.AgentID != artifact.agentID { + return fmt.Errorf( + "workspace: record artifact %q has the wrong agent", + artifact.name, + ) + } + if artifact.kind == workspaceRecordRemovalArtifact { + expectedOperation := "none" + if record.Removal != nil { + expectedOperation = record.Removal.OperationID + } + if artifact.operationID != expectedOperation { + return fmt.Errorf( + "workspace: record artifact %q has the wrong operation", + artifact.name, + ) + } + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + if artifact.kind == workspaceRecordTemporaryArtifact { + canonicalName := artifact.agentID + workspaceRecordSuffix + canonical, err := bucket.Lstat(canonicalName) + if err == nil && !os.SameFile(opened, canonical) { + return fmt.Errorf( + "workspace: temporary record artifact %q conflicts with its canonical record", + artifact.name, + ) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + nextName := workspaceRecordTemporaryName( + artifact.agentID + workspaceRecordSuffix, + ) + if artifact.kind == workspaceRecordRemovalArtifact { + nextName = workspaceRecordRemovalName(record) + } + return removeOwnedRecordPath( + bucket, + artifact.name, + file, + nextName, + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + current, err := m.decodeWorkspaceRecordFile( + candidate, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if current.AgentID != artifact.agentID { + return errors.New( + "workspace: record artifact changed ownership", + ) + } + if artifact.kind == workspaceRecordRemovalArtifact { + currentOperation := "none" + if current.Removal != nil { + currentOperation = current.Removal.OperationID + } + if currentOperation != artifact.operationID { + return errors.New( + "workspace: record artifact changed operation", + ) + } + } + return nil + }, + nil, + ) +} diff --git a/internal/workspace/record_artifact_test.go b/internal/workspace/record_artifact_test.go new file mode 100644 index 0000000..4d6b6de --- /dev/null +++ b/internal/workspace/record_artifact_test.go @@ -0,0 +1,140 @@ +package workspace + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRecoverWorkspaceRecordTemporaryArtifact(t *testing.T) { + manager, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + recordName := filepath.Base(recordPath) + temporaryName := workspaceRecordTemporaryName(recordName) + temporaryPath := filepath.Join(filepath.Dir(recordPath), temporaryName) + if err := os.Link(recordPath, temporaryPath); err != nil { + t.Fatalf("link temporary record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("repeat record artifact recovery: %v", err) + } + if _, err := os.Stat(recordPath); err != nil { + t.Fatalf("canonical record changed: %v", err) + } + if _, err := os.Lstat(temporaryPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary record remains: %v", err) + } + _ = manager +} + +func TestRecoverWorkspaceRecordRemovalArtifact(t *testing.T) { + _, target, record, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + removalName := workspaceRecordRemovalName(record) + removalPath := filepath.Join(filepath.Dir(recordPath), removalName) + if err := os.Rename(recordPath, removalPath); err != nil { + t.Fatalf("isolate removal record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("repeat record artifact recovery: %v", err) + } + for _, path := range []string{recordPath, removalPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("record artifact %q remains: %v", path, err) + } + } +} + +func TestRecoverWorkspaceRecordArtifactsRejectsLegacyRemoval(t *testing.T) { + _, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + legacyName := workspaceRecordRemovalLegacyPrefix + + "78787878-7878-4787-8787-787878787878" + legacyPath := filepath.Join(filepath.Dir(recordPath), legacyName) + if err := os.Rename(recordPath, legacyPath); err != nil { + t.Fatalf("isolate legacy removal record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err == nil { + t.Fatal("recovery accepted a legacy removal artifact") + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy removal artifact changed: %v", err) + } +} + +func TestRemoveWorkspaceRecordLeavesNoUnixTransactionDebris(t *testing.T) { + manager, target, _, _ := newRecordArtifactFixture(t) + if err := manager.removeWorkspaceRecord(target.Path); err != nil { + t.Fatalf("remove workspace record: %v", err) + } + entries, err := os.ReadDir(filepath.Dir(target.Path)) + if err != nil { + t.Fatalf("read record bucket: %v", err) + } + for _, entry := range entries { + if entry.Name() == target.AgentID+workspaceRecordSuffix { + t.Fatalf("canonical record remains: %q", entry.Name()) + } + if _, valid := parseWorkspaceRecordArtifactName(entry.Name()); valid { + t.Fatalf("record artifact remains: %q", entry.Name()) + } + } +} + +func newRecordArtifactFixture( + t *testing.T, +) (*Manager, Workspace, workspaceRecord, string) { + t.Helper() + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + repository := filepath.Join(t.TempDir(), "repository") + if err := manager.ensureManagedRoot(); err != nil { + t.Fatalf("ensure managed root: %v", err) + } + if err := manager.ensureManagedBucket(repository); err != nil { + t.Fatalf("ensure managed bucket: %v", err) + } + target := Workspace{ + AgentID: testAgentID, + Repository: repository, + Path: filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ), + Branch: "record-artifact", + } + if err := manager.writeWorkspaceRecord(target, nil); err != nil { + t.Fatalf("write workspace record: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(target.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + return manager, target, record, dataDir +} diff --git a/internal/workspace/record_owned.go b/internal/workspace/record_owned.go new file mode 100644 index 0000000..ca28e33 --- /dev/null +++ b/internal/workspace/record_owned.go @@ -0,0 +1,107 @@ +package workspace + +import ( + "errors" + "fmt" + "os" +) + +func removeOwnedRecordPathIfSame( + root *os.Root, + name string, + expected *os.File, + isolatedName string, + validate func(*os.File) error, +) error { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return removeOwnedRecordPath( + root, + name, + expected, + isolatedName, + validate, + nil, + ) +} + +func removeOwnedRecordPath( + root *os.Root, + name string, + expected *os.File, + isolatedName string, + validate func(*os.File) error, + afterValidation func(), +) (result error) { + if name == isolatedName { + return errors.New( + "workspace: owned record source and isolation names match", + ) + } + if validate != nil { + if err := validate(expected); err != nil { + return err + } + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, directory.Close()) + }() + moved, err := moveRecordFile( + directory, + expected, + name, + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "workspace: isolate owned record path %q: %w", + name, + err, + ) + } + if !moved { + return fmt.Errorf( + "workspace: owned record path %q was not isolated", + name, + ) + } + if err := syncRecordDirectory(directory); err != nil { + return fmt.Errorf( + "workspace: sync isolated owned record path %q: %w", + name, + err, + ) + } + if validate != nil { + if err := validate(expected); err != nil { + return err + } + } + if err := unlinkOwnedRecordPath( + directory, + expected, + isolatedName, + ); err != nil { + return err + } + if err := syncRecordDirectory(directory); err != nil { + return fmt.Errorf( + "workspace: sync removed owned record path %q: %w", + name, + err, + ) + } + return nil +} diff --git a/internal/workspace/record_owned_other.go b/internal/workspace/record_owned_other.go new file mode 100644 index 0000000..66602fc --- /dev/null +++ b/internal/workspace/record_owned_other.go @@ -0,0 +1,18 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows + +package workspace + +import ( + "errors" + "os" +) + +func unlinkOwnedRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "workspace: owned record removal is unsupported on this platform", + ) +} diff --git a/internal/workspace/record_owned_unix.go b/internal/workspace/record_owned_unix.go new file mode 100644 index 0000000..b83b47b --- /dev/null +++ b/internal/workspace/record_owned_unix.go @@ -0,0 +1,24 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func unlinkOwnedRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + if err := verifyRecordPathIdentity(directory, expected, name); err != nil { + return err + } + if err := unix.Unlinkat(int(directory.Fd()), name, 0); err != nil { + return fmt.Errorf("remove owned record path %q: %w", name, err) + } + return nil +} diff --git a/internal/workspace/record_owned_windows.go b/internal/workspace/record_owned_windows.go new file mode 100644 index 0000000..014498e --- /dev/null +++ b/internal/workspace/record_owned_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package workspace + +import "os" + +func unlinkOwnedRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPath(directory, expected, name) +} From 9e4b75848932b8d19a39d641be7d01584b6f50b7 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 19:20:09 +0800 Subject: [PATCH 033/104] fix: recover removal marker artifacts --- .../workspace/removal_marker_artifact_test.go | 77 ++++++++ internal/workspace/secure_remove.go | 173 ++++++++++++++---- 2 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 internal/workspace/removal_marker_artifact_test.go diff --git a/internal/workspace/removal_marker_artifact_test.go b/internal/workspace/removal_marker_artifact_test.go new file mode 100644 index 0000000..362d785 --- /dev/null +++ b/internal/workspace/removal_marker_artifact_test.go @@ -0,0 +1,77 @@ +package workspace + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCleanupRemovalMarkerDiscardIsIdempotent(t *testing.T) { + record := removalMarkerArtifactTestRecord() + path := t.TempDir() + name := removalMarkerDiscardName(record) + if err := os.WriteFile( + filepath.Join(path, name), + []byte("partial\n"), + 0o600, + ); err != nil { + t.Fatalf("write marker discard: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + t.Fatalf("cleanup marker discard: %v", err) + } + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + t.Fatalf("repeat marker discard cleanup: %v", err) + } + if _, err := root.Lstat(name); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("marker discard remains: %v", err) + } +} + +func TestRemoveRemovalMarkerRecoversDiscardedMarker(t *testing.T) { + record := removalMarkerArtifactTestRecord() + path := t.TempDir() + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + t.Fatalf("install removal marker: %v", err) + } + discardName := removalMarkerDiscardName(record) + if err := os.Rename( + filepath.Join(path, removalMarkerName(record)), + filepath.Join(path, discardName), + ); err != nil { + t.Fatalf("isolate removal marker: %v", err) + } + + if err := removeRemovalMarkerIfPresent(root, record); err != nil { + t.Fatalf("recover discarded marker: %v", err) + } + if _, err := root.Lstat(discardName); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("marker discard remains: %v", err) + } +} + +func removalMarkerArtifactTestRecord() workspaceRecord { + return workspaceRecord{ + AgentID: testAgentID, + Removal: &workspaceRemovalRecord{ + OperationID: "78787878-7878-4787-8787-787878787878", + DirectoryToken: "89898989-8989-4898-8989-898989898989", + }, + } +} diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index 929d0fd..194f6b8 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -285,19 +285,41 @@ func removalMarkerTemporaryPrefix(record workspaceRecord) string { return removalMarkerName(record) + ".install-" } +func removalMarkerTemporaryName(record workspaceRecord) string { + return removalMarkerTemporaryPrefix(record) + uuid.NewString() +} + +func removalMarkerDiscardPrefix(record workspaceRecord) string { + return removalMarkerName(record) + ".discard-" +} + +func removalMarkerDiscardName(record workspaceRecord) string { + return removalMarkerDiscardPrefix(record) + uuid.NewString() +} + func isRemovalMarkerTemporaryName( record workspaceRecord, name string, ) bool { - suffix, found := strings.CutPrefix( + return validRemovalMarkerArtifactName( name, removalMarkerTemporaryPrefix(record), ) - if !found { - return false - } - _, err := uuid.Parse(suffix) - return err == nil +} + +func isRemovalMarkerDiscardName( + record workspaceRecord, + name string, +) bool { + return validRemovalMarkerArtifactName( + name, + removalMarkerDiscardPrefix(record), + ) +} + +func validRemovalMarkerArtifactName(name string, prefix string) bool { + suffix, found := strings.CutPrefix(name, prefix) + return found && canonicalUUID(suffix) } func removalQuarantineIsolationName(name string) string { @@ -312,6 +334,9 @@ func validateRemovalMarkerPhase( root *os.Root, record workspaceRecord, ) error { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } if record.Removal != nil && record.Removal.ContentsCleared { return validateClearedRemovalDirectory(root, record, false) } @@ -345,7 +370,7 @@ func ensureRemovalMarker( if err := verifyRemovalMarker(root, record); err == nil { return nil } - if err := removeIncompleteRemovalMarker(root, name); err != nil { + if err := removeIncompleteRemovalMarker(root, name, record); err != nil { return err } if err := installRemovalMarker( @@ -377,7 +402,13 @@ func installRemovalMarker( if cleanupOnFailure { result = errors.Join( result, - removeRecordPathIfSame(root, temporaryName, file), + removeOwnedRecordPathIfSame( + root, + temporaryName, + file, + name+".install-"+uuid.NewString(), + nil, + ), syncRecordBucket(root, temporaryName), ) } @@ -425,13 +456,34 @@ func cleanupRemovalMarkerTemps( root *os.Root, record workspaceRecord, ) (result error) { + if record.Removal == nil || + record.Removal.OperationID == "" || + record.Removal.DirectoryToken == "" { + return errors.New( + "workspace: removal record has no marker identity", + ) + } entries, err := readRootDirectory(root) if err != nil { return fmt.Errorf("workspace: inspect removal marker staging files: %w", err) } removed := false for _, entry := range entries { - if !isRemovalMarkerTemporaryName(record, entry.Name()) { + temporary := isRemovalMarkerTemporaryName(record, entry.Name()) + discard := isRemovalMarkerDiscardName(record, entry.Name()) + if !temporary && !discard { + if strings.HasPrefix( + entry.Name(), + removalMarkerTemporaryPrefix(record), + ) || strings.HasPrefix( + entry.Name(), + removalMarkerDiscardPrefix(record), + ) { + return fmt.Errorf( + "workspace: removal marker artifact %q has an invalid name", + entry.Name(), + ) + } continue } info, err := root.Lstat(entry.Name()) @@ -461,7 +513,18 @@ func cleanupRemovalMarkerTemps( } removeErr := error(nil) if statErr == nil { - removeErr = removeRecordPathIfSame(root, entry.Name(), file) + isolatedName := removalMarkerDiscardName(record) + if temporary { + isolatedName = removalMarkerTemporaryName(record) + } + removeErr = removeOwnedRecordPath( + root, + entry.Name(), + file, + isolatedName, + nil, + nil, + ) } closeErr := file.Close() if err := errors.Join(statErr, removeErr, closeErr); err != nil { @@ -478,6 +541,7 @@ func cleanupRemovalMarkerTemps( func removeIncompleteRemovalMarker( root *os.Root, name string, + record workspaceRecord, ) (result error) { info, err := root.Lstat(name) if err != nil { @@ -502,10 +566,17 @@ func removeIncompleteRemovalMarker( "workspace: incomplete removal marker changed while opening", ) } - if err := removeRecordPathIfSame(root, name, file); err != nil { + if err := removeOwnedRecordPath( + root, + name, + file, + removalMarkerDiscardName(record), + nil, + nil, + ); err != nil { return fmt.Errorf("workspace: remove incomplete removal marker: %w", err) } - return syncRecordBucket(root, name) + return nil } func verifyRemovalMarker( @@ -538,12 +609,8 @@ func verifyRemovalMarker( if !os.SameFile(info, opened) { return errors.New("workspace: removal marker changed while opening") } - raw, err := io.ReadAll(io.LimitReader(file, 128)) - if err != nil { - return fmt.Errorf("workspace: read removal marker: %w", err) - } - if string(raw) != record.Removal.DirectoryToken+"\n" { - return errors.New("workspace: removal marker token mismatch") + if err := validateRemovalMarkerFile(file, record); err != nil { + return err } current, err := root.Lstat(name) if err != nil { @@ -555,10 +622,33 @@ func verifyRemovalMarker( return nil } +func validateRemovalMarkerFile( + file *os.File, + record workspaceRecord, +) error { + if record.Removal == nil || record.Removal.DirectoryToken == "" { + return errors.New("workspace: removal record has no directory token") + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("workspace: rewind removal marker: %w", err) + } + raw, err := io.ReadAll(io.LimitReader(file, 128)) + if err != nil { + return fmt.Errorf("workspace: read removal marker: %w", err) + } + if string(raw) != record.Removal.DirectoryToken+"\n" { + return errors.New("workspace: removal marker token mismatch") + } + return nil +} + func removalDirectoryContainsOnlyMarker( root *os.Root, record workspaceRecord, ) (bool, error) { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return false, err + } entries, err := readRootDirectory(root) if err != nil { return false, err @@ -632,35 +722,46 @@ func removeRootEntriesExcept( func removeRemovalMarkerIfPresent( root *os.Root, record workspaceRecord, -) error { +) (result error) { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } name := removalMarkerName(record) - _, err := root.Lstat(name) + info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("workspace: inspect removal marker: %w", err) } - if err := verifyRemovalMarker(root, record); err != nil { - return err + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace: removal marker is not a regular file") } - if err := root.Remove(name); err != nil { - return fmt.Errorf("workspace: remove removal marker: %w", err) + file, err := root.Open(name) + if err != nil { + return fmt.Errorf("workspace: open removal marker: %w", err) } - directory, err := openRecordDirectory(root) + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() if err != nil { - return fmt.Errorf( - "workspace: open removal marker directory: %w", - err, - ) + return fmt.Errorf("workspace: inspect opened removal marker: %w", err) } - syncErr := syncRecordDirectory(directory) - closeErr := directory.Close() - if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: sync removal marker deletion: %w", - err, - ) + if !os.SameFile(info, opened) { + return errors.New("workspace: removal marker changed while opening") + } + if err := removeOwnedRecordPath( + root, + name, + file, + removalMarkerDiscardName(record), + func(candidate *os.File) error { + return validateRemovalMarkerFile(candidate, record) + }, + nil, + ); err != nil { + return fmt.Errorf("workspace: remove removal marker: %w", err) } return nil } From 201788c5dd20645bc07a58ac3ad1994843b01c69 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 19:26:10 +0800 Subject: [PATCH 034/104] fix: remove anonymous Unix deletion artifacts --- internal/workspace/include.go | 79 ++++++++++++++++--- internal/workspace/prepared_checkout_unix.go | 9 ++- internal/workspace/record.go | 74 ----------------- .../workspace/record_removal_security_test.go | 4 +- internal/workspace/record_rename_unix.go | 76 ------------------ internal/workspace/record_rename_unix_test.go | 14 ++-- 6 files changed, 88 insertions(+), 168 deletions(-) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index fc8f5c1..dba8b83 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -32,6 +32,29 @@ type includeSelection struct { exists bool } +func includeManifestTemporaryName() string { + return "." + worktreeIncludeFile + "-" + uuid.NewString() +} + +func includedFileTemporaryName(destinationName string) string { + return "." + destinationName + ".include-" + uuid.NewString() +} + +func removeIncludeTemporary( + root *os.Root, + name string, + expected *os.File, + nextName string, +) error { + return removeOwnedRecordPathIfSame( + root, + name, + expected, + nextName, + nil, + ) +} + func (m *Manager) copyIncludedSelection( ctx context.Context, sourcePath string, @@ -405,7 +428,7 @@ func (m *Manager) runIncludeManifest( result = errors.Join(result, verifyErr, closeErr, removeErr) }() - name := "." + worktreeIncludeFile + "-" + uuid.NewString() + name := includeManifestTemporaryName() writer, err := root.OpenFile( name, os.O_RDWR|os.O_CREATE|os.O_EXCL, @@ -417,14 +440,24 @@ func (m *Manager) runIncludeManifest( if _, err := writer.Write(manifest); err != nil { return nil, errors.Join( fmt.Errorf("workspace: write include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } if err := writer.Sync(); err != nil { return nil, errors.Join( fmt.Errorf("workspace: sync include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } @@ -432,7 +465,12 @@ func (m *Manager) runIncludeManifest( if err != nil { return nil, errors.Join( fmt.Errorf("workspace: reopen include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } @@ -442,14 +480,24 @@ func (m *Manager) runIncludeManifest( if err := errors.Join(statErr, readerStatErr, closeErr); err != nil { return nil, errors.Join( fmt.Errorf("workspace: inspect include rule file: %w", err), - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), reader.Close(), ) } if !os.SameFile(writerInfo, readerInfo) { return nil, errors.Join( errors.New("workspace: include rule file changed while reopening"), - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), reader.Close(), ) } @@ -457,7 +505,12 @@ func (m *Manager) runIncludeManifest( if name != "" { result = errors.Join( result, - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), ) } result = errors.Join(result, reader.Close()) @@ -487,7 +540,12 @@ func (m *Manager) runIncludeManifest( commandResult = errors.Join(commandResult, cleanupManifest()) }() if unlinkBeforeRun && name != "" { - if err := root.Remove(name); err != nil { + if err := removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ); err != nil { return nil, fmt.Errorf( "workspace: unlink include rule file: %w", err, @@ -816,7 +874,7 @@ func copyIncludedPathAfterCopy( return fmt.Errorf("verify destination parent: %w", err) } - temporaryName := "." + destinationName + ".include-" + uuid.NewString() + temporaryName := includedFileTemporaryName(destinationName) output, err := destinationParent.OpenFile( temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, @@ -830,10 +888,11 @@ func copyIncludedPathAfterCopy( if temporaryName != "" { result = errors.Join( result, - removeRecordPathIfSame( + removeIncludeTemporary( destinationParent, temporaryName, output, + includedFileTemporaryName(destinationName), ), ) } diff --git a/internal/workspace/prepared_checkout_unix.go b/internal/workspace/prepared_checkout_unix.go index b6c8e96..7e2294b 100644 --- a/internal/workspace/prepared_checkout_unix.go +++ b/internal/workspace/prepared_checkout_unix.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" + "github.com/google/uuid" "golang.org/x/sys/unix" ) @@ -520,7 +521,13 @@ func installPreparedCheckoutFile( if !installed { result = errors.Join( result, - removeRecordPathIfSame(parent, name, output), + removeOwnedRecordPathIfSame( + parent, + name, + output, + ".drove-checkout-discard-"+uuid.NewString(), + nil, + ), ) } if outputOpen { diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 87574b4..ab6e57a 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -359,80 +359,6 @@ func checkRecordTarget( return nil } -func removeRecordPathIfSame( - root *os.Root, - name string, - expected *os.File, -) error { - return removeRecordPathIfSameAfterValidation( - root, - name, - expected, - nil, - ) -} - -func removeRecordPathIfSameAfterValidation( - root *os.Root, - name string, - expected *os.File, - afterValidation func(), -) (result error) { - _, err := root.Lstat(name) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err - } - directory, err := openRecordDirectory(root) - if err != nil { - return err - } - defer func() { - result = errors.Join(result, directory.Close()) - }() - return unlinkRecordPathAfterValidation( - directory, - expected, - name, - afterValidation, - ) -} - -func isolateRecordPathIfSame( - root *os.Root, - directory *os.File, - name string, - isolatedName string, - expected *os.File, - afterValidation func(), -) (bool, error) { - current, err := root.Lstat(name) - if err != nil { - return false, err - } - opened, err := expected.Stat() - if err != nil { - return false, err - } - if !os.SameFile(opened, current) { - return false, fmt.Errorf( - "workspace: record path %q changed identity", - name, - ) - } - if afterValidation != nil { - afterValidation() - } - return moveRecordFile( - directory, - expected, - name, - isolatedName, - ) -} - func (m *Manager) readWorkspaceRecord( worktreePath string, ) (_ workspaceRecord, _ bool, result error) { diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go index c813347..13ed61e 100644 --- a/internal/workspace/record_removal_security_test.go +++ b/internal/workspace/record_removal_security_test.go @@ -28,10 +28,12 @@ func TestRemoveRecordPathPreservesReplacementAfterValidation(t *testing.T) { defer expected.Close() originalPath := recordPath + ".original" - err = removeRecordPathIfSameAfterValidation( + err = removeOwnedRecordPath( root, "temporary", expected, + "temporary-owned-removal", + nil, func() { if err := os.Rename(recordPath, originalPath); err != nil { t.Fatalf("move validated temporary record: %v", err) diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index 8f6bcb9..f91690f 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -245,82 +245,6 @@ func unlinkLinkedRecordPath( return nil } -func unlinkRecordPath( - directory *os.File, - expected *os.File, - name string, -) error { - return unlinkRecordPathAfterValidation( - directory, - expected, - name, - nil, - ) -} - -func unlinkRecordPathAfterValidation( - directory *os.File, - expected *os.File, - name string, - afterValidation func(), -) error { - if err := verifyRecordPathIdentity( - directory, - expected, - name, - ); err != nil { - return err - } - if afterValidation != nil { - afterValidation() - } - fd := int(directory.Fd()) - isolatedName := ".drove-remove-" + uuid.NewString() - if err := unix.Renameat(fd, name, fd, isolatedName); err != nil { - return fmt.Errorf("isolate record path %q: %w", name, err) - } - restore := func() error { - if err := unix.Linkat( - fd, - isolatedName, - fd, - name, - 0, - ); err != nil { - return fmt.Errorf( - "restore isolated record path %q: %w", - name, - err, - ) - } - if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { - return fmt.Errorf( - "remove restored record isolation %q: %w", - isolatedName, - err, - ) - } - return nil - } - if err := verifyRecordPathIdentity( - directory, - expected, - isolatedName, - ); err != nil { - return errors.Join( - fmt.Errorf("verify isolated record path %q: %w", name, err), - restore(), - ) - } - if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { - return errors.Join( - fmt.Errorf("remove isolated record path %q: %w", name, err), - restore(), - ) - } - return nil -} - func verifyRecordPathIdentity( directory *os.File, expected *os.File, diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 45f6eb6..b46d9f1 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -92,17 +92,19 @@ func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { if _, err := source.WriteString("original\n"); err != nil { t.Fatalf("write source: %v", err) } - directory, err := os.Open(rootPath) + root, err := os.OpenRoot(rootPath) if err != nil { - t.Fatalf("open directory: %v", err) + t.Fatalf("open root: %v", err) } - defer directory.Close() + defer root.Close() movedPath := filepath.Join(rootPath, "source-original") - err = unlinkRecordPathAfterValidation( - directory, - source, + err = removeOwnedRecordPath( + root, "source", + source, + "source-owned-removal", + nil, func() { if err := os.Rename(sourcePath, movedPath); err != nil { t.Fatalf("move validated source: %v", err) From 60d3789d29e9c46286527139562dfa2ae6aae5dd Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 19:58:02 +0800 Subject: [PATCH 035/104] fix: bind BSD worktree registration --- internal/workspace/AGENTS.md | 6 +- internal/workspace/preparation_test.go | 3 + .../workspace/prepared_registration_bsd.go | 908 ++++++++++++++++++ .../prepared_registration_bsd_test.go | 292 ++++++ .../workspace/prepared_registration_direct.go | 37 + internal/workspace/prepared_rollback.go | 8 + internal/workspace/prepared_worktree.go | 10 + internal/workspace/repository_capability.go | 14 +- internal/workspace/rooted_git_unix.go | 34 +- internal/workspace/source_swap_test.go | 25 +- 10 files changed, 1328 insertions(+), 9 deletions(-) create mode 100644 internal/workspace/prepared_registration_bsd.go create mode 100644 internal/workspace/prepared_registration_bsd_test.go create mode 100644 internal/workspace/prepared_registration_direct.go diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index d38b2ef..e931478 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -154,7 +154,11 @@ ## 约束 -- 禁止直接读取或修改 `.git` 内部结构;仓库事实必须通过 Git 命令查询。 +- 禁止直接读取或修改 `.git` 内部结构;仓库事实必须通过 Git 命令查询。唯一例外是 + macOS/BSD 创建 linked worktree 时,可在已打开且身份已复核的 target root 与 private + Git root 内原子改写 target `.git` 和 private `gitdir` 两个指针文件;该例外不得用于 + 查询 branch、HEAD、index 或其他仓库事实,也不得把指针中的公开路径当作 mutation + authority。 - 删除路径必须先证明位于 Drove worktree 根目录内,禁止接受任意路径。 - 不自动 merge、rebase、push 或删除已交付会话的分支。 - 导出类型:`Manager`、`Workspace`、`Removal`、`RemovalState`、`RemovalResult`、 diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 1fafebc..da61d22 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -414,6 +414,9 @@ func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( func TestReconcilePreparationsDiscardsGitSuffixedWorktreeAfterRestart( t *testing.T, ) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration rejects deterministic admin collisions") + } repository := newTestRepository(t) stalePath := filepath.Join(t.TempDir(), "-") runGit( diff --git a/internal/workspace/prepared_registration_bsd.go b/internal/workspace/prepared_registration_bsd.go new file mode 100644 index 0000000..046cf3f --- /dev/null +++ b/internal/workspace/prepared_registration_bsd.go @@ -0,0 +1,908 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +func bsdPreparedWorktreeStageName(worktreePath string) (string, error) { + if !filepath.IsAbs(worktreePath) || + filepath.Clean(worktreePath) != worktreePath { + return "", errors.New( + "workspace: prepared worktree path is not a clean absolute path", + ) + } + base := filepath.Base(worktreePath) + if base == "." || base == string(filepath.Separator) { + return "", errors.New("workspace: prepared worktree name is invalid") + } + name := base + if len(name) > 200 { + return "", errors.New("workspace: prepared worktree name is too long") + } + return name, nil +} + +func validateBSDPreparedWorktreeNamesAvailable( + commonRoot *os.Root, + stageName string, +) (result error) { + if debris, err := findRootDeletionDebris( + commonRoot, + stageName, + "", + ); err != nil { + return err + } else if debris != "" { + return fmt.Errorf( + "workspace: prepared registration has deletion debris %q", + debris, + ) + } + if _, err := commonRoot.Lstat(stageName); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return fmt.Errorf( + "workspace: prepared registration stage %q already exists", + stageName, + ) + } + return err + } + worktrees, err := openRealRootFromRoot(commonRoot, "worktrees") + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer func() { + result = errors.Join(result, worktrees.Close()) + }() + entries, err := readRootDirectory(worktrees) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == stageName || + strings.HasPrefix(entry.Name(), stageName) { + return fmt.Errorf( + "workspace: prepared private Git directory %q already exists", + entry.Name(), + ) + } + } + return nil +} + +func finalizePreparedWorktreeAdd( + ctx context.Context, + repository repositoryCapability, + worktreePath string, + worktreeRoot *os.Root, + commandSucceeded bool, +) (result error) { + stageName, err := bsdPreparedWorktreeStageName(worktreePath) + if err != nil { + return err + } + if !commandSucceeded { + return cleanupBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + "", + ) + } + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(worktreePath, worktreeRoot), + ); err != nil { + return errors.Join( + err, + cleanupBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + "", + ), + ) + } + privatePath := filepath.Join( + repository.commonPath, + "worktrees", + stageName, + ) + internalPath := filepath.Join(repository.commonPath, stageName) + stageRoot, err := openRealRootFromRoot(repository.commonRoot, stageName) + if err != nil { + return fmt.Errorf( + "workspace: open prepared registration stage: %w", + err, + ) + } + stageOwned := true + defer func() { + if stageOwned { + result = errors.Join(result, stageRoot.Close()) + } + }() + if err := validateBoundGitPointer( + stageRoot, + ".git", + "gitdir: "+privatePath+"\n", + ); err != nil { + return fmt.Errorf( + "workspace: validate prepared registration stage: %w", + err, + ) + } + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + filepath.Join("worktrees", stageName), + ) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, privateRoot.Close()) + }() + if err := validateBSDPrivateAdminSet( + repository.commonRoot, + stageName, + ); err != nil { + return err + } + if err := ensureBoundGitPointer( + worktreeRoot, + ".git", + boundGitPointerTempPrefix(stageName, ".git"), + "gitdir: "+privatePath+"\n", + "", + ); err != nil { + return fmt.Errorf( + "workspace: install prepared worktree Git pointer: %w", + err, + ) + } + if err := ensureBoundGitPointer( + privateRoot, + "gitdir", + boundGitPointerTempPrefix(stageName, "gitdir"), + worktreePath+string(filepath.Separator)+".git\n", + internalPath+string(filepath.Separator)+".git\n", + ); err != nil { + return fmt.Errorf( + "workspace: rebind prepared private Git directory: %w", + err, + ) + } + _, exists, err := repository.worktreeRegistration( + ctx, + worktreePath, + ) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: relocated prepared worktree registration is missing", + ) + } + if err := validateBSDStageContents(stageRoot); err != nil { + return err + } + if err := removeRootDeletionDebris( + repository.commonRoot, + stageName, + ); err != nil { + return err + } + stageOwned = false + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return fmt.Errorf( + "workspace: remove prepared registration stage: %w", + err, + ) + } + return errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(worktreePath, worktreeRoot), + ) +} + +func repairBoundPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) (handled bool, result error) { + stageName, err := bsdPreparedWorktreeStageName(stalePath) + if err != nil { + return true, err + } + privatePath := filepath.Join( + repository.commonPath, + "worktrees", + stageName, + ) + if prepared.gitDirectory != privatePath { + return true, errors.New( + "workspace: prepared private Git directory does not match its operation", + ) + } + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ); err != nil { + return true, err + } + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + filepath.Join("worktrees", stageName), + ) + if err != nil { + return true, err + } + defer func() { + result = errors.Join(result, privateRoot.Close()) + }() + if err := validateBoundGitPointer( + prepared.root, + ".git", + "gitdir: "+privatePath+"\n", + ); err != nil { + return true, err + } + if err := ensureBoundGitPointer( + privateRoot, + "gitdir", + boundGitPointerTempPrefix(stageName, "gitdir"), + target.Path+string(filepath.Separator)+".git\n", + stalePath+string(filepath.Separator)+".git\n", + ); err != nil { + return true, err + } + registered, exists, err := repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return true, err + } + if !exists { + return true, errors.New( + "workspace: promoted worktree registration is missing", + ) + } + if err := repository.verifyPreparedWorktree( + ctx, + target, + prepared.gitDirectory, + registered, + ); err != nil { + return true, err + } + return true, errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ) +} + +func cleanupPreparedWorktreeAddDebris( + ctx context.Context, + repository repositoryCapability, + target Workspace, + record workspaceRecord, +) error { + if record.BranchOperationID == "" { + return nil + } + stagingName, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return err + } + stageName, err := bsdPreparedWorktreeStageName( + filepath.Join(filepath.Dir(target.Path), stagingName), + ) + if err != nil { + return err + } + return cleanupBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + target.Path, + ) +} + +func cleanupBSDPreparedWorktreeStage( + ctx context.Context, + repository repositoryCapability, + stageName string, + allowedTargetPath string, +) (result error) { + if err := removeRootDeletionDebris( + repository.commonRoot, + stageName, + ); err != nil { + return err + } + stageRoot, err := openRealRootFromRoot(repository.commonRoot, stageName) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + stageOwned := true + defer func() { + if stageOwned { + result = errors.Join(result, stageRoot.Close()) + } + }() + privatePath := filepath.Join( + repository.commonPath, + "worktrees", + stageName, + ) + if err := validateBoundGitPointer( + stageRoot, + ".git", + "gitdir: "+privatePath+"\n", + ); err != nil { + return err + } + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + filepath.Join("worktrees", stageName), + ) + if err != nil { + return err + } + adminTarget, err := readBoundRegularFile(privateRoot, "gitdir") + closeErr := privateRoot.Close() + if err := errors.Join(err, closeErr); err != nil { + return err + } + internalTarget := filepath.Join( + repository.commonPath, + stageName, + ".git", + ) + "\n" + allowedTarget := "" + if allowedTargetPath != "" { + allowedTarget = filepath.Join(allowedTargetPath, ".git") + "\n" + } + if adminTarget != internalTarget && adminTarget != allowedTarget { + return errors.New( + "workspace: prepared private Git directory has an unexpected target", + ) + } + if err := validateBSDStageContents(stageRoot); err != nil { + return err + } + stageOwned = false + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return err + } + return repository.pruneWorktrees(ctx) +} + +func validateBSDPrivateAdminSet( + commonRoot *os.Root, + stageName string, +) (result error) { + worktrees, err := openRealRootFromRoot(commonRoot, "worktrees") + if err != nil { + return err + } + defer func() { + result = errors.Join(result, worktrees.Close()) + }() + entries, err := readRootDirectory(worktrees) + if err != nil { + return err + } + found := false + for _, entry := range entries { + if entry.Name() == stageName { + found = true + continue + } + if strings.HasPrefix(entry.Name(), stageName) { + return fmt.Errorf( + "workspace: Git selected unexpected private directory %q", + entry.Name(), + ) + } + } + if !found { + return errors.New( + "workspace: expected prepared private Git directory is missing", + ) + } + return nil +} + +func validateBSDStageContents(stageRoot *os.Root) error { + if err := recoverRecordRenameDebris(stageRoot); err != nil { + return err + } + entries, err := readRootDirectory(stageRoot) + if err != nil { + return err + } + if len(entries) != 1 || entries[0].Name() != ".git" { + return errors.New( + "workspace: prepared registration stage contains unexpected entries", + ) + } + return nil +} + +func boundGitPointerTempPrefix(stageName string, targetName string) string { + digest := sha256.Sum256([]byte(stageName + "\x00" + targetName)) + return fmt.Sprintf(".drove-git-pointer-%x-", digest[:8]) +} + +func ensureBoundGitPointer( + root *os.Root, + targetName string, + tempPrefix string, + content string, + allowedOld string, +) (result error) { + if err := recoverRecordRenameDebris(root); err != nil { + return err + } + newPrefix := tempPrefix + "new-" + oldPrefix := tempPrefix + "old-" + if err := validateBoundGitPointerNamespace( + root, + tempPrefix, + newPrefix, + oldPrefix, + ); err != nil { + return err + } + current, exists, err := readOptionalBoundRegularFile(root, targetName) + if err != nil { + return err + } + _, oldFile, err := findBoundGitPointerTemp( + root, + oldPrefix, + allowedOld, + ) + if err != nil { + return err + } + if oldFile != nil { + defer func() { + result = errors.Join(result, oldFile.Close()) + }() + } + if exists && current == content { + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) + } + switch { + case exists && current != allowedOld: + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + targetName, + ) + case allowedOld == "" && oldFile != nil: + return fmt.Errorf( + "workspace: Git pointer %q has an unexpected backup", + targetName, + ) + case !exists && allowedOld != "" && oldFile == nil: + return fmt.Errorf( + "workspace: Git pointer %q is missing", + targetName, + ) + } + if exists { + if oldFile == nil { + if err := isolateBoundGitPointer( + root, + targetName, + oldPrefix+uuid.NewString(), + allowedOld, + nil, + ); err != nil { + return err + } + } else { + currentFile, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + currentInfo, currentErr := currentFile.Stat() + oldInfo, oldErr := oldFile.Stat() + if err := errors.Join(currentErr, oldErr); err != nil { + return errors.Join(err, currentFile.Close()) + } + if !os.SameFile(currentInfo, oldInfo) { + return errors.Join( + fmt.Errorf( + "workspace: Git pointer %q conflicts with its backup", + targetName, + ), + currentFile.Close(), + ) + } + removeErr := removeOwnedRecordPath( + root, + targetName, + currentFile, + oldPrefix+uuid.NewString(), + func(candidate *os.File) error { + info, err := candidate.Stat() + if err != nil { + return err + } + if !os.SameFile(info, oldInfo) { + return errors.New( + "workspace: Git pointer changed before isolation", + ) + } + return nil + }, + nil, + ) + closeErr := currentFile.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } + } + tempName, tempFile, err := findBoundGitPointerTemp( + root, + newPrefix, + content, + ) + if err != nil { + return err + } + if tempFile == nil { + tempName = newPrefix + uuid.NewString() + tempFile, err = root.OpenFile( + tempName, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return err + } + if _, err := io.WriteString(tempFile, content); err != nil { + return errors.Join(err, tempFile.Close()) + } + if err := tempFile.Sync(); err != nil { + return errors.Join(err, tempFile.Close()) + } + } + defer func() { + result = errors.Join(result, tempFile.Close()) + }() + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + installed, renameErr := renameRecordFile( + directory, + tempFile, + tempName, + targetName, + false, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return err + } + if !installed { + return fmt.Errorf( + "workspace: Git pointer %q was not installed", + targetName, + ) + } + if err := validateBoundGitPointer(root, targetName, content); err != nil { + return err + } + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) +} + +func isolateBoundGitPointer( + root *os.Root, + targetName string, + isolatedName string, + expected string, + afterValidation func(), +) (result error) { + file, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + raw, err := readOpenedBoundFile(file) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q changed contents", + targetName, + ) + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + moved, moveErr := moveRecordFile( + directory, + file, + targetName, + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(moveErr, syncErr, closeErr); err != nil { + return err + } + if !moved { + return fmt.Errorf( + "workspace: Git pointer %q was not isolated", + targetName, + ) + } + return nil +} + +func validateBoundGitPointerNamespace( + root *os.Root, + basePrefix string, + newPrefix string, + oldPrefix string, +) error { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), basePrefix) { + continue + } + prefix := "" + switch { + case strings.HasPrefix(entry.Name(), newPrefix): + prefix = newPrefix + case strings.HasPrefix(entry.Name(), oldPrefix): + prefix = oldPrefix + } + if prefix == "" || + !canonicalUUID(strings.TrimPrefix(entry.Name(), prefix)) { + return fmt.Errorf( + "workspace: Git pointer artifact %q has an invalid name", + entry.Name(), + ) + } + } + return nil +} + +func findBoundGitPointerTemp( + root *os.Root, + prefix string, + content string, +) (string, *os.File, error) { + entries, err := readRootDirectory(root) + if err != nil { + return "", nil, err + } + var foundName string + var found *os.File + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), prefix) { + continue + } + suffix := strings.TrimPrefix(entry.Name(), prefix) + if !canonicalUUID(suffix) { + var closeErr error + if found != nil { + closeErr = found.Close() + } + return "", nil, errors.Join( + fmt.Errorf( + "workspace: Git pointer temporary %q has an invalid name", + entry.Name(), + ), + closeErr, + ) + } + file, err := openBoundRegularFile(root, entry.Name()) + if err != nil { + if found != nil { + err = errors.Join(err, found.Close()) + } + return "", nil, err + } + raw, err := readOpenedBoundFile(file) + if err != nil || raw != content { + closeErr := file.Close() + if found != nil { + closeErr = errors.Join(closeErr, found.Close()) + } + if err == nil { + err = errors.New( + "workspace: Git pointer temporary has unexpected contents", + ) + } + return "", nil, errors.Join(err, closeErr) + } + if found == nil { + foundName = entry.Name() + found = file + continue + } + if err := file.Close(); err != nil { + return "", nil, errors.Join(err, found.Close()) + } + } + return foundName, found, nil +} + +func cleanupBoundGitPointerTemps( + root *os.Root, + prefix string, + content string, +) error { + for { + name, file, err := findBoundGitPointerTemp(root, prefix, content) + if err != nil || file == nil { + return err + } + removeErr := removeOwnedRecordPath( + root, + name, + file, + prefix+uuid.NewString(), + func(candidate *os.File) error { + raw, err := readOpenedBoundFile(candidate) + if err != nil { + return err + } + if raw != content { + return errors.New( + "workspace: Git pointer temporary changed contents", + ) + } + return nil + }, + nil, + ) + closeErr := file.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } +} + +func validateBoundGitPointer( + root *os.Root, + name string, + expected string, +) error { + raw, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + name, + ) + } + return nil +} + +func readOptionalBoundRegularFile( + root *os.Root, + name string, +) (string, bool, error) { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + raw, err := readBoundRegularFile(root, name) + return raw, err == nil, err +} + +func readBoundRegularFile(root *os.Root, name string) (result string, err error) { + file, err := openBoundRegularFile(root, name) + if err != nil { + return "", err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + return readOpenedBoundFile(file) +} + +func openBoundRegularFile(root *os.Root, name string) (*os.File, error) { + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("workspace: %q is not a regular file", name) + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil { + return nil, errors.Join(err, file.Close()) + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, errors.Join( + fmt.Errorf("workspace: %q changed while opening", name), + file.Close(), + ) + } + return file, nil +} + +func readOpenedBoundFile(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + raw, err := io.ReadAll(io.LimitReader(file, 4097)) + if err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New("workspace: Git pointer is too large") + } + return string(raw), nil +} diff --git a/internal/workspace/prepared_registration_bsd_test.go b/internal/workspace/prepared_registration_bsd_test.go new file mode 100644 index 0000000..14af372 --- /dev/null +++ b/internal/workspace/prepared_registration_bsd_test.go @@ -0,0 +1,292 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestBSDPrepareDoesNotWriteThroughReplacedCommonGitPath(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + commonPath := filepath.Join(repository, ".git") + openedCommon := commonPath + "-opened" + replacement := filepath.Join(parent, "replacement-common") + if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatalf("create replacement common directory: %v", err) + } + sentinel := filepath.Join(replacement, "must-remain") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "common-swapped") + stageFile := filepath.Join(parent, "common-stage") + wrapper := filepath.Join(parent, "git-wrapper-common-swap") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ] && + [ ! -e "$DROVE_TEST_SWAPPED" ]; then + printf '%s/%s' "$PWD" "$target" > "$DROVE_TEST_STAGE" || exit 90 + mv "$DROVE_TEST_COMMON" "$DROVE_TEST_OPENED_COMMON" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_COMMON" || exit 92 + : > "$DROVE_TEST_SWAPPED" +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_COMMON", commonPath) + t.Setenv("DROVE_TEST_OPENED_COMMON", openedCommon) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_STAGE", stageFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced common Git path") + } + if _, err := os.Stat(swapped); err != nil { + t.Fatalf("worktree add did not reach the swap: %v", err) + } + assertFileContents( + t, + filepath.Join(commonPath, "must-remain"), + "replacement\n", + ) + if _, err := os.Lstat(filepath.Join(commonPath, "worktrees")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("replacement common directory received Git writes: %v", err) + } + + if err := os.Rename(commonPath, replacement); err != nil { + t.Fatalf("restore replacement directory: %v", err) + } + if err := os.Rename(openedCommon, commonPath); err != nil { + t.Fatalf("restore common Git directory: %v", err) + } + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile failed preparation: %v", err) + } + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read internal stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + if _, err := os.Lstat(stagePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal common-root stage remains: %v", err) + } + if _, err := os.Lstat( + filepath.Join(commonPath, "worktrees", filepath.Base(stagePath)), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal private Git directory remains: %v", err) + } + assertFileContents(t, sentinel, "replacement\n") +} + +func TestBSDPrepareDoesNotInvokePublicWorktreeRepair(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + repairInvoked := filepath.Join(parent, "repair-invoked") + wrapper := filepath.Join(parent, "git-wrapper-reject-repair") + script := `#!/bin/sh +case " $* " in + *" worktree repair "*) + : > "$DROVE_TEST_REPAIR_INVOKED" + exit 91 + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_REPAIR_INVOKED", repairInvoked) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if _, err := os.Lstat(repairInvoked); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("public worktree repair was invoked: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared worktree: %v", err) + } +} + +func TestEnsureBoundGitPointerRecoversTemporary(t *testing.T) { + path := t.TempDir() + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open pointer root: %v", err) + } + defer root.Close() + const ( + target = "gitdir" + old = "old-target/.git\n" + desired = "new-target/.git\n" + ) + if err := os.WriteFile(filepath.Join(path, target), []byte(old), 0o600); err != nil { + t.Fatalf("write old pointer: %v", err) + } + prefix := boundGitPointerTempPrefix("stage", target) + tempName := prefix + "new-" + + "78787878-7878-4787-8787-787878787878" + if err := os.WriteFile( + filepath.Join(path, tempName), + []byte(desired), + 0o600, + ); err != nil { + t.Fatalf("write pointer temporary: %v", err) + } + + if err := ensureBoundGitPointer( + root, + target, + prefix, + desired, + old, + ); err != nil { + t.Fatalf("recover pointer temporary: %v", err) + } + if err := ensureBoundGitPointer( + root, + target, + prefix, + desired, + old, + ); err != nil { + t.Fatalf("repeat pointer recovery: %v", err) + } + assertFileContents(t, filepath.Join(path, target), desired) + if _, err := os.Lstat(filepath.Join(path, tempName)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("pointer temporary remains: %v", err) + } +} + +func TestIsolateBoundGitPointerPreservesReplacement(t *testing.T) { + path := t.TempDir() + targetPath := filepath.Join(path, "gitdir") + originalPath := filepath.Join(path, "gitdir-original") + if err := os.WriteFile( + targetPath, + []byte("original/.git\n"), + 0o600, + ); err != nil { + t.Fatalf("write pointer: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open pointer root: %v", err) + } + defer root.Close() + + err = isolateBoundGitPointer( + root, + "gitdir", + ".drove-pointer-old-78787878-7878-4787-8787-787878787878", + "original/.git\n", + func() { + if err := os.Rename(targetPath, originalPath); err != nil { + t.Fatalf("move validated pointer: %v", err) + } + if err := os.WriteFile( + targetPath, + []byte("replacement/.git\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement pointer: %v", err) + } + }, + ) + if err == nil { + t.Fatal("pointer isolation accepted a replacement") + } + assertFileContents(t, targetPath, "replacement/.git\n") + assertFileContents(t, originalPath, "original/.git\n") +} + +func TestValidateBSDPreparedWorktreeNamesRejectsAdminCollision(t *testing.T) { + path := t.TempDir() + if err := os.Mkdir(filepath.Join(path, "worktrees"), 0o700); err != nil { + t.Fatalf("create worktrees directory: %v", err) + } + const stageName = "11111111-1111-4111-8111-111111111111123" + if err := os.Mkdir( + filepath.Join(path, "worktrees", stageName), + 0o700, + ); err != nil { + t.Fatalf("create private Git collision: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer root.Close() + + if err := validateBSDPreparedWorktreeNamesAvailable( + root, + stageName, + ); err == nil { + t.Fatal("validation accepted a deterministic admin collision") + } +} diff --git a/internal/workspace/prepared_registration_direct.go b/internal/workspace/prepared_registration_direct.go new file mode 100644 index 0000000..aaa8ecd --- /dev/null +++ b/internal/workspace/prepared_registration_direct.go @@ -0,0 +1,37 @@ +//go:build !darwin && !dragonfly && !freebsd && !netbsd && !openbsd + +package workspace + +import ( + "context" + "os" +) + +func finalizePreparedWorktreeAdd( + _ context.Context, + _ repositoryCapability, + _ string, + _ *os.Root, + _ bool, +) error { + return nil +} + +func repairBoundPreparedWorktreeRegistration( + _ context.Context, + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, + _ string, +) (bool, error) { + return false, nil +} + +func cleanupPreparedWorktreeAddDebris( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ workspaceRecord, +) error { + return nil +} diff --git a/internal/workspace/prepared_rollback.go b/internal/workspace/prepared_rollback.go index e619cc1..522d03e 100644 --- a/internal/workspace/prepared_rollback.go +++ b/internal/workspace/prepared_rollback.go @@ -19,6 +19,14 @@ func (m *Manager) removePreparedWorktreeAtRoot( "workspace: prepared worktree identity is incomplete", ) } + if err := cleanupPreparedWorktreeAddDebris( + ctx, + repository, + target, + record, + ); err != nil { + return err + } registered, exists, err := repository.worktreeRegistration( ctx, target.Path, diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 9c1825e..a442a4a 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -510,6 +510,16 @@ func (m *Manager) repairPreparedWorktreeRegistration( "workspace: prepared private Git directory is unavailable", ) } + handled, err := repairBoundPreparedWorktreeRegistration( + ctx, + target, + prepared, + repository, + stalePath, + ) + if handled { + return err + } gitRoot, err := openRealPathRoot(prepared.gitDirectory) if err != nil { return fmt.Errorf( diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 4d1c673..c549580 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -211,12 +211,24 @@ func (r repositoryCapability) addPreparedWorktree( return nil, err } output, commandErr := runGitCommand(command, "") + finalizeErr := finalizePreparedWorktreeAdd( + ctx, + r, + path, + root, + commandErr == nil, + ) verifyErr := errors.Join( r.verifyBinding(ctx), verifyRealPathRoot(path, root), verifyBranch(), ) - return output, errors.Join(commandErr, verifyErr, cleanup()) + return output, errors.Join( + commandErr, + finalizeErr, + verifyErr, + cleanup(), + ) } func (r repositoryCapability) runPrivateGit( diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index b742206..79b6d05 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -220,7 +220,7 @@ func rootedPreparedWorktreeGitCommand( repositoryRoot *os.Root, commonPath string, commonRoot *os.Root, - _ string, + worktreePath string, worktreeRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { @@ -230,10 +230,33 @@ func rootedPreparedWorktreeGitCommand( err, ) } - directory, err := worktreeRoot.Open(".") + if err := verifyRealPathRoot(worktreePath, worktreeRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify prepared worktree target: %w", + err, + ) + } + stageName, err := bsdPreparedWorktreeStageName(worktreePath) + if err != nil { + return nil, nil, err + } + if err := validateBSDPreparedWorktreeNamesAvailable( + commonRoot, + stageName, + ); err != nil { + return nil, nil, err + } + if len(arguments) < 2 || arguments[len(arguments)-2] != "." { + return nil, nil, errors.New( + "workspace: prepared worktree command has an unexpected target", + ) + } + arguments = append([]string(nil), arguments...) + arguments[len(arguments)-2] = "./" + stageName + directory, err := commonRoot.Open(".") if err != nil { return nil, nil, fmt.Errorf( - "workspace: open prepared worktree target: %w", + "workspace: open rooted common Git directory: %w", err, ) } @@ -268,13 +291,14 @@ func rootedPreparedWorktreeGitCommand( command.ExtraFiles = []*os.File{directory} command.Env = boundGitEnvironment( command.Environ(), - commonPath, - repositoryPath, + ".", + ".", ) cleanup := func() error { return errors.Join( verifyRealPathRoot(repositoryPath, repositoryRoot), verifyRealPathRoot(commonPath, commonRoot), + verifyRealPathRoot(worktreePath, worktreeRoot), commonGuard.Close(), directory.Close(), ) diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index a72da38..7ed0d68 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -232,6 +232,9 @@ func TestPrepareRollsBackAfterUnlistedAdminDirectoryCollisions( } func TestPrepareRemovesPrecreatedTargetWhenWorktreeAddFails(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration uses a separate common-root stage") + } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) @@ -303,6 +306,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration does not run Git from the managed target") + } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) @@ -470,6 +476,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD promotion does not invoke git worktree repair") + } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) @@ -2335,17 +2344,26 @@ worktree= add= no_checkout= checkout= +target= +next_target= for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi [ "$argument" = "worktree" ] && worktree=1 [ "$argument" = "add" ] && add=1 - [ "$argument" = "--no-checkout" ] && no_checkout=1 + if [ "$argument" = "--no-checkout" ]; then + no_checkout=1 + next_target=1 + fi [ "$argument" = "read-tree" ] && checkout=1 done if [ -n "$worktree" ] && [ -n "$add" ] && [ -n "$no_checkout" ]; then "$DROVE_TEST_REAL_GIT" "$@" status=$? if [ "$status" -eq 0 ]; then - sed 's/^gitdir: //' .git > "$DROVE_TEST_GIT_PATH" || exit 90 + sed 's/^gitdir: //' "$target/.git" > "$DROVE_TEST_GIT_PATH" || exit 90 fi exit "$status" fi @@ -2509,6 +2527,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } func TestPrepareProtectsPromotedWorktreePointerDuringRepair(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD promotion rewrites the bound private pointer directly") + } parent := t.TempDir() repository := filepath.Join(parent, "repository") sibling := filepath.Join(parent, "sibling") From 389625c3f7d64ae3743f86f7b3669c865de5a2a8 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 20:00:04 +0800 Subject: [PATCH 036/104] ci: verify workspace platform implementations --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++++ Makefile | 6 ++++- scripts/check-workspace-platforms.sh | 33 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100755 scripts/check-workspace-platforms.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 549e0f8..b439a7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,34 @@ jobs: with: name: coverage-${{ matrix.go }} path: coverage.out + + workspace-runtime: + name: Workspace Runtime (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + cache: true + + - name: Test workspace + run: go test ./internal/workspace -count=1 -timeout=20m + + workspace-platform-compile: + name: Workspace Platform Compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + cache: true + + - name: Compile workspace platform families + run: scripts/check-workspace-platforms.sh diff --git a/Makefile b/Makefile index fe4bce8..3270f26 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BIN := bin MODULE := github.com/Duang777/drove VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) -.PHONY: all build build-cli build-daemon test vet lint fmt clean install help +.PHONY: all build build-cli build-daemon test test-workspace-platforms vet lint fmt clean install help all: build @@ -21,6 +21,10 @@ test: $(GO) test ./... -race -coverprofile=coverage.out $(GO) tool cover -func=coverage.out | tail -n 1 +## test-workspace-platforms: 编译 workspace 的全部平台实现与测试 +test-workspace-platforms: + GO=$(GO) scripts/check-workspace-platforms.sh + ## vet: 静态检查 vet: $(GO) vet ./... diff --git a/scripts/check-workspace-platforms.sh b/scripts/check-workspace-platforms.sh new file mode 100755 index 0000000..44e7356 --- /dev/null +++ b/scripts/check-workspace-platforms.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +go_bin="${GO:-go}" +output_dir="$(mktemp -d)" +trap 'rm -rf "$output_dir"' EXIT + +targets=( + linux/amd64 + linux/arm64 + windows/amd64 + darwin/amd64 + darwin/arm64 + dragonfly/amd64 + freebsd/amd64 + netbsd/amd64 + openbsd/amd64 + aix/ppc64 + illumos/amd64 + solaris/amd64 + plan9/amd64 + js/wasm + wasip1/wasm +) + +for target in "${targets[@]}"; do + goos="${target%/*}" + goarch="${target#*/}" + echo "compile internal/workspace for ${goos}/${goarch}" + GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \ + "$go_bin" test -c ./internal/workspace \ + -o "$output_dir/workspace-${goos}-${goarch}.test" +done From 77929be35af70c1da85426e3f73d701891d9b9b4 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 20:02:03 +0800 Subject: [PATCH 037/104] fix: bind marker artifacts to directory tokens --- internal/workspace/secure_remove.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index 194f6b8..08bf85a 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -282,7 +282,8 @@ func removalMarkerName(record workspaceRecord) string { } func removalMarkerTemporaryPrefix(record workspaceRecord) string { - return removalMarkerName(record) + ".install-" + return removalMarkerName(record) + ".install-" + + record.Removal.DirectoryToken + "-" } func removalMarkerTemporaryName(record workspaceRecord) string { @@ -290,7 +291,8 @@ func removalMarkerTemporaryName(record workspaceRecord) string { } func removalMarkerDiscardPrefix(record workspaceRecord) string { - return removalMarkerName(record) + ".discard-" + return removalMarkerName(record) + ".discard-" + + record.Removal.DirectoryToken + "-" } func removalMarkerDiscardName(record workspaceRecord) string { @@ -388,7 +390,7 @@ func installRemovalMarker( name string, token string, ) (result error) { - temporaryName := name + ".install-" + uuid.NewString() + temporaryName := name + ".install-" + token + "-" + uuid.NewString() file, err := root.OpenFile( temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, @@ -406,7 +408,7 @@ func installRemovalMarker( root, temporaryName, file, - name+".install-"+uuid.NewString(), + name+".install-"+token+"-"+uuid.NewString(), nil, ), syncRecordBucket(root, temporaryName), From b6ea4ab9c6d284bc412b3f23b548d2222c98a965 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 20:52:55 +0800 Subject: [PATCH 038/104] fix: bind promoted worktree registration --- internal/workspace/AGENTS.md | 5 +- .../workspace/prepared_registration_bound.go | 632 ++++++++++++++++++ .../workspace/prepared_registration_direct.go | 2 +- internal/workspace/prepared_worktree.go | 187 +----- internal/workspace/rooted_git_other.go | 15 - internal/workspace/rooted_git_repair_bound.go | 32 - internal/workspace/rooted_git_test.go | 75 --- internal/workspace/rooted_git_unix.go | 22 - internal/workspace/source_swap_test.go | 119 ++-- 9 files changed, 706 insertions(+), 383 deletions(-) create mode 100644 internal/workspace/prepared_registration_bound.go delete mode 100644 internal/workspace/rooted_git_repair_bound.go diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index e931478..858a52a 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -155,10 +155,11 @@ ## 约束 - 禁止直接读取或修改 `.git` 内部结构;仓库事实必须通过 Git 命令查询。唯一例外是 - macOS/BSD 创建 linked worktree 时,可在已打开且身份已复核的 target root 与 private + linked worktree 创建和提升期间,可在已打开且身份已复核的 target root 与 private Git root 内原子改写 target `.git` 和 private `gitdir` 两个指针文件;该例外不得用于 查询 branch、HEAD、index 或其他仓库事实,也不得把指针中的公开路径当作 mutation - authority。 + authority。Linux、Windows 与 macOS/BSD 均不得为提升后的路径调用公开路径版 + `git worktree repair`。 - 删除路径必须先证明位于 Drove worktree 根目录内,禁止接受任意路径。 - 不自动 merge、rebase、push 或删除已交付会话的分支。 - 导出类型:`Manager`、`Workspace`、`Removal`、`RemovalState`、`RemovalResult`、 diff --git a/internal/workspace/prepared_registration_bound.go b/internal/workspace/prepared_registration_bound.go new file mode 100644 index 0000000..f6e19c4 --- /dev/null +++ b/internal/workspace/prepared_registration_bound.go @@ -0,0 +1,632 @@ +//go:build linux || windows + +package workspace + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" +) + +func finalizePreparedWorktreeAdd( + _ context.Context, + _ repositoryCapability, + _ string, + _ *os.Root, + _ bool, +) error { + return nil +} + +func repairBoundPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) (handled bool, result error) { + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ); err != nil { + return true, err + } + relativeGitDirectory, err := repository.boundGitDirectory( + prepared.gitDirectory, + target.AgentID, + ) + if err != nil { + return true, err + } + gitRoot, err := openRealRootFromRoot( + repository.commonRoot, + relativeGitDirectory, + ) + if err != nil { + return true, fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + if err := validateGitPointerTarget( + prepared.root, + ".git", + "gitdir: ", + prepared.gitDirectory, + ); err != nil { + return true, fmt.Errorf( + "workspace: validate prepared worktree Git pointer: %w", + err, + ) + } + oldPointer, err := readBoundRegularFile(gitRoot, "gitdir") + if err != nil { + return true, err + } + if err := validateGitPointerContent( + oldPointer, + "", + filepath.Join(stalePath, ".git"), + ); err != nil { + return true, fmt.Errorf( + "workspace: validate prepared private Git pointer: %w", + err, + ) + } + newPointer := filepath.ToSlash(filepath.Join(target.Path, ".git")) + "\n" + if err := ensureBoundGitPointer( + gitRoot, + "gitdir", + boundGitPointerTempPrefix( + filepath.Base(relativeGitDirectory), + "gitdir", + ), + newPointer, + oldPointer, + ); err != nil { + return true, fmt.Errorf( + "workspace: rebind prepared private Git directory: %w", + err, + ) + } + if err := validateGitPointerTarget( + prepared.root, + ".git", + "gitdir: ", + prepared.gitDirectory, + ); err != nil { + return true, fmt.Errorf( + "workspace: revalidate prepared worktree Git pointer: %w", + err, + ) + } + privateRepository := repository.withPrivateGitBinding( + prepared.gitDirectory, + gitRoot, + ) + registered, exists, err := repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return true, err + } + if !exists { + return true, errors.New( + "workspace: promoted worktree registration is missing", + ) + } + if err := privateRepository.verifyPreparedWorktree( + ctx, + target, + prepared.gitDirectory, + registered, + ); err != nil { + return true, err + } + return true, errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + verifyRealPathRoot(prepared.gitDirectory, gitRoot), + ) +} + +func cleanupPreparedWorktreeAddDebris( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ workspaceRecord, +) error { + return nil +} + +func validateGitPointerTarget( + root *os.Root, + name string, + prefix string, + expectedPath string, +) error { + content, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + return validateGitPointerContent(content, prefix, expectedPath) +} + +func validateGitPointerContent( + content string, + prefix string, + expectedPath string, +) error { + value, found := strings.CutPrefix(content, prefix) + if !found || !strings.HasSuffix(value, "\n") { + return errors.New("workspace: Git pointer has invalid contents") + } + value = strings.TrimSuffix(value, "\n") + if value == "" { + return errors.New("workspace: Git pointer has invalid contents") + } + actual := filepath.Clean(filepath.FromSlash(value)) + expected := filepath.Clean(expectedPath) + matches := actual == expected + if runtime.GOOS == "windows" { + matches = strings.EqualFold(actual, expected) + } + if !matches { + return errors.New("workspace: Git pointer has an unexpected target") + } + return nil +} + +func boundGitPointerTempPrefix(stageName string, targetName string) string { + digest := sha256.Sum256([]byte(stageName + "\x00" + targetName)) + return fmt.Sprintf(".drove-git-pointer-%x-", digest[:8]) +} + +func ensureBoundGitPointer( + root *os.Root, + targetName string, + tempPrefix string, + content string, + allowedOld string, +) (result error) { + if err := recoverRecordRenameDebris(root); err != nil { + return err + } + newPrefix := tempPrefix + "new-" + oldPrefix := tempPrefix + "old-" + if err := validateBoundGitPointerNamespace( + root, + tempPrefix, + newPrefix, + oldPrefix, + ); err != nil { + return err + } + current, exists, err := readOptionalBoundRegularFile(root, targetName) + if err != nil { + return err + } + _, oldFile, err := findBoundGitPointerTemp( + root, + oldPrefix, + allowedOld, + ) + if err != nil { + return err + } + if oldFile != nil { + defer func() { + result = errors.Join(result, oldFile.Close()) + }() + } + if exists && current == content { + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) + } + switch { + case exists && current != allowedOld: + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + targetName, + ) + case allowedOld == "" && oldFile != nil: + return fmt.Errorf( + "workspace: Git pointer %q has an unexpected backup", + targetName, + ) + case !exists && allowedOld != "" && oldFile == nil: + return fmt.Errorf( + "workspace: Git pointer %q is missing", + targetName, + ) + } + if exists { + if oldFile == nil { + if err := isolateBoundGitPointer( + root, + targetName, + oldPrefix+uuid.NewString(), + allowedOld, + nil, + ); err != nil { + return err + } + } else { + currentFile, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + currentInfo, currentErr := currentFile.Stat() + oldInfo, oldErr := oldFile.Stat() + if err := errors.Join(currentErr, oldErr); err != nil { + return errors.Join(err, currentFile.Close()) + } + if !os.SameFile(currentInfo, oldInfo) { + return errors.Join( + fmt.Errorf( + "workspace: Git pointer %q conflicts with its backup", + targetName, + ), + currentFile.Close(), + ) + } + removeErr := removeOwnedRecordPath( + root, + targetName, + currentFile, + oldPrefix+uuid.NewString(), + func(candidate *os.File) error { + info, err := candidate.Stat() + if err != nil { + return err + } + if !os.SameFile(info, oldInfo) { + return errors.New( + "workspace: Git pointer changed before isolation", + ) + } + return nil + }, + nil, + ) + closeErr := currentFile.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } + } + tempName, tempFile, err := findBoundGitPointerTemp( + root, + newPrefix, + content, + ) + if err != nil { + return err + } + if tempFile == nil { + tempName = newPrefix + uuid.NewString() + tempFile, err = root.OpenFile( + tempName, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return err + } + if _, err := io.WriteString(tempFile, content); err != nil { + return errors.Join(err, tempFile.Close()) + } + if err := tempFile.Sync(); err != nil { + return errors.Join(err, tempFile.Close()) + } + } + defer func() { + result = errors.Join(result, tempFile.Close()) + }() + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + installed, renameErr := renameRecordFile( + directory, + tempFile, + tempName, + targetName, + false, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return err + } + if !installed { + return fmt.Errorf( + "workspace: Git pointer %q was not installed", + targetName, + ) + } + if err := validateBoundGitPointer(root, targetName, content); err != nil { + return err + } + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) +} + +func isolateBoundGitPointer( + root *os.Root, + targetName string, + isolatedName string, + expected string, + afterValidation func(), +) (result error) { + file, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + raw, err := readOpenedBoundFile(file) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q changed contents", + targetName, + ) + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + moved, moveErr := moveRecordFile( + directory, + file, + targetName, + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(moveErr, syncErr, closeErr); err != nil { + return err + } + if !moved { + return fmt.Errorf( + "workspace: Git pointer %q was not isolated", + targetName, + ) + } + return nil +} + +func validateBoundGitPointerNamespace( + root *os.Root, + basePrefix string, + newPrefix string, + oldPrefix string, +) error { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), basePrefix) { + continue + } + prefix := "" + switch { + case strings.HasPrefix(entry.Name(), newPrefix): + prefix = newPrefix + case strings.HasPrefix(entry.Name(), oldPrefix): + prefix = oldPrefix + } + if prefix == "" || + !canonicalUUID(strings.TrimPrefix(entry.Name(), prefix)) { + return fmt.Errorf( + "workspace: Git pointer artifact %q has an invalid name", + entry.Name(), + ) + } + } + return nil +} + +func findBoundGitPointerTemp( + root *os.Root, + prefix string, + content string, +) (string, *os.File, error) { + entries, err := readRootDirectory(root) + if err != nil { + return "", nil, err + } + var foundName string + var found *os.File + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), prefix) { + continue + } + suffix := strings.TrimPrefix(entry.Name(), prefix) + if !canonicalUUID(suffix) { + var closeErr error + if found != nil { + closeErr = found.Close() + } + return "", nil, errors.Join( + fmt.Errorf( + "workspace: Git pointer temporary %q has an invalid name", + entry.Name(), + ), + closeErr, + ) + } + file, err := openBoundRegularFile(root, entry.Name()) + if err != nil { + if found != nil { + err = errors.Join(err, found.Close()) + } + return "", nil, err + } + raw, err := readOpenedBoundFile(file) + if err != nil || raw != content { + closeErr := file.Close() + if found != nil { + closeErr = errors.Join(closeErr, found.Close()) + } + if err == nil { + err = errors.New( + "workspace: Git pointer temporary has unexpected contents", + ) + } + return "", nil, errors.Join(err, closeErr) + } + if found == nil { + foundName = entry.Name() + found = file + continue + } + if err := file.Close(); err != nil { + return "", nil, errors.Join(err, found.Close()) + } + } + return foundName, found, nil +} + +func cleanupBoundGitPointerTemps( + root *os.Root, + prefix string, + content string, +) error { + for { + name, file, err := findBoundGitPointerTemp(root, prefix, content) + if err != nil || file == nil { + return err + } + removeErr := removeOwnedRecordPath( + root, + name, + file, + prefix+uuid.NewString(), + func(candidate *os.File) error { + raw, err := readOpenedBoundFile(candidate) + if err != nil { + return err + } + if raw != content { + return errors.New( + "workspace: Git pointer temporary changed contents", + ) + } + return nil + }, + nil, + ) + closeErr := file.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } +} + +func validateBoundGitPointer( + root *os.Root, + name string, + expected string, +) error { + raw, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + name, + ) + } + return nil +} + +func readOptionalBoundRegularFile( + root *os.Root, + name string, +) (string, bool, error) { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + raw, err := readBoundRegularFile(root, name) + return raw, err == nil, err +} + +func readBoundRegularFile(root *os.Root, name string) (result string, err error) { + file, err := openBoundRegularFile(root, name) + if err != nil { + return "", err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + return readOpenedBoundFile(file) +} + +func openBoundRegularFile(root *os.Root, name string) (*os.File, error) { + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("workspace: %q is not a regular file", name) + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil { + return nil, errors.Join(err, file.Close()) + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, errors.Join( + fmt.Errorf("workspace: %q changed while opening", name), + file.Close(), + ) + } + return file, nil +} + +func readOpenedBoundFile(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + raw, err := io.ReadAll(io.LimitReader(file, 4097)) + if err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New("workspace: Git pointer is too large") + } + return string(raw), nil +} diff --git a/internal/workspace/prepared_registration_direct.go b/internal/workspace/prepared_registration_direct.go index aaa8ecd..2cfd1ae 100644 --- a/internal/workspace/prepared_registration_direct.go +++ b/internal/workspace/prepared_registration_direct.go @@ -1,4 +1,4 @@ -//go:build !darwin && !dragonfly && !freebsd && !netbsd && !openbsd +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows package workspace diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index a442a4a..f5790c6 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -504,7 +504,7 @@ func (m *Manager) repairPreparedWorktreeRegistration( prepared *preparedWorktreeTarget, repository repositoryCapability, stalePath string, -) (result error) { +) error { if prepared.gitDirectory == "" { return errors.New( "workspace: prepared private Git directory is unavailable", @@ -520,190 +520,9 @@ func (m *Manager) repairPreparedWorktreeRegistration( if handled { return err } - gitRoot, err := openRealPathRoot(prepared.gitDirectory) - if err != nil { - return fmt.Errorf( - "workspace: open prepared private Git directory: %w", - err, - ) - } - defer func() { - result = errors.Join(result, gitRoot.Close()) - }() - gitGuard, err := openRepositoryGuard(prepared.gitDirectory, gitRoot) - if err != nil { - return err - } - defer func() { - result = errors.Join(result, gitGuard.Close()) - }() - before, err := repository.registeredWorktrees(ctx) - if err != nil { - return err - } - privateRepository := repository.withPrivateGitBinding( - prepared.gitDirectory, - gitRoot, - ) - runRepair := func() error { - command, cleanup, err := rootedRepairWorktreeGitCommand( - ctx, - m.git, - target.Path, - prepared.root, - privateRepository.gitPath, - privateRepository.gitRoot, - privateRepository.commonPath, - privateRepository.commonRoot, - ) - if err != nil { - return err - } - _, commandErr := runGitCommand(command, "") - return errors.Join(commandErr, cleanup()) - } - restore := func() error { - arguments := []string{"worktree", "repair"} - for _, registered := range before { - if sameRegisteredWorktreePath(registered.path, stalePath) { - continue - } - if _, err := os.Lstat(registered.path); err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - return err - } - arguments = append(arguments, registered.path) - } - arguments = append(arguments, target.Path) - _, err := repository.run(ctx, "", arguments...) - return err - } - commandErr := runRepair() - if err := errors.Join( - repository.verifyBinding(ctx), - verifyRealPathRoot(prepared.gitDirectory, gitRoot), - ); err != nil { - return errors.Join(err, restore()) - } - after, err := repository.registeredWorktrees(ctx) - if err != nil { - return errors.Join(commandErr, err, restore()) - } - transitionErr := validatePreparedRegistrationTransition( - before, - after, - target, - stalePath, - privateRepository, - ) - if err := errors.Join(commandErr, transitionErr); err != nil { - restoreErr := restore() - restored, verifyErr := repository.registeredWorktrees(ctx) - if verifyErr == nil { - verifyErr = validatePreparedRegistrationTransition( - before, - restored, - target, - stalePath, - privateRepository, - ) - } - return fmt.Errorf( - "workspace: repair promoted worktree registration: %w", - errors.Join(err, restoreErr, verifyErr), - ) - } - return nil -} - -func validatePreparedRegistrationTransition( - before []registeredWorktree, - after []registeredWorktree, - target Workspace, - stalePath string, - repository repositoryCapability, -) error { - if len(after) != len(before) { - return errors.New( - "workspace: worktree registration set changed during repair", - ) - } - var ( - stale registeredWorktree - staleFound bool - targetRecord registeredWorktree - targetFound bool + return errors.New( + "workspace: bound prepared worktree registration repair is unsupported", ) - for _, registered := range before { - if sameRegisteredWorktreePath(registered.path, stalePath) { - stale = registered - staleFound = true - break - } - } - if !staleFound { - return errors.New( - "workspace: stale prepared worktree registration is missing", - ) - } - for _, registered := range after { - if sameRegisteredWorktreePath(registered.path, stalePath) { - return errors.New( - "workspace: stale prepared worktree registration remains after repair", - ) - } - if sameRegisteredWorktreePath(registered.path, target.Path) { - targetRecord = registered - targetFound = true - } - } - if !targetFound { - return errors.New( - "workspace: repaired worktree registration is missing", - ) - } - if err := repository.verifyPreparedWorktreeRegistration( - target, - targetRecord, - target.expectedHeadOID, - ); err != nil { - return err - } - for _, expected := range before { - if sameRegisteredWorktreePath(expected.path, stalePath) { - continue - } - var matched bool - for _, actual := range after { - if !sameRegisteredWorktreePath(expected.path, actual.path) { - continue - } - if expected.head != actual.head || - expected.branch != actual.branch || - expected.detached != actual.detached { - return errors.New( - "workspace: unrelated worktree registration changed during repair", - ) - } - matched = true - break - } - if !matched { - return errors.New( - "workspace: unrelated worktree registration disappeared during repair", - ) - } - } - if stale.head != targetRecord.head || - stale.branch != targetRecord.branch || - stale.detached != targetRecord.detached { - return errors.New( - "workspace: repaired worktree registration changed identity", - ) - } - return nil } func (m *Manager) cleanupPreparedWorktreeTarget( diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 061fcea..72c652c 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -69,18 +69,3 @@ func rootedPreparedWorktreeGitCommand( "workspace: rooted prepared worktree Git commands are unsupported on this platform", ) } - -func rootedRepairWorktreeGitCommand( - context.Context, - string, - string, - *os.Root, - string, - *os.Root, - string, - *os.Root, -) (*exec.Cmd, func() error, error) { - return nil, nil, errors.New( - "workspace: rooted worktree repair is unsupported on this platform", - ) -} diff --git a/internal/workspace/rooted_git_repair_bound.go b/internal/workspace/rooted_git_repair_bound.go deleted file mode 100644 index c763e48..0000000 --- a/internal/workspace/rooted_git_repair_bound.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build linux || windows - -package workspace - -import ( - "context" - "os" - "os/exec" -) - -func rootedRepairWorktreeGitCommand( - ctx context.Context, - git string, - worktreePath string, - worktreeRoot *os.Root, - gitPath string, - gitRoot *os.Root, - commonPath string, - commonRoot *os.Root, -) (*exec.Cmd, func() error, error) { - return rootedWorktreeGitCommand( - ctx, - git, - worktreePath, - worktreeRoot, - gitPath, - gitRoot, - commonPath, - commonRoot, - []string{"worktree", "repair", "."}, - ) -} diff --git a/internal/workspace/rooted_git_test.go b/internal/workspace/rooted_git_test.go index e3518ab..d185190 100644 --- a/internal/workspace/rooted_git_test.go +++ b/internal/workspace/rooted_git_test.go @@ -92,81 +92,6 @@ func TestRootedPrivateGitCommandBindsCommonDirectoryForPlatform( } } -func TestBSDWorktreeRepairUsesPrivateGitRoot(t *testing.T) { - switch runtime.GOOS { - case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": - default: - t.Skip("test covers the BSD rooted Git helper") - } - parent := t.TempDir() - worktreePath := filepath.Join(parent, "worktree") - gitPath := filepath.Join(parent, "git") - commonPath := filepath.Join(parent, "common") - for _, path := range []string{worktreePath, gitPath, commonPath} { - if err := os.Mkdir(path, 0o700); err != nil { - t.Fatalf("create directory %q: %v", path, err) - } - } - worktreeRoot, err := openRealPathRoot(worktreePath) - if err != nil { - t.Fatalf("open worktree root: %v", err) - } - defer worktreeRoot.Close() - gitRoot, err := openRealPathRoot(gitPath) - if err != nil { - t.Fatalf("open Git root: %v", err) - } - defer gitRoot.Close() - commonRoot, err := openRealPathRoot(commonPath) - if err != nil { - t.Fatalf("open common root: %v", err) - } - defer commonRoot.Close() - - command, cleanup, err := rootedRepairWorktreeGitCommand( - context.Background(), - "git", - worktreePath, - worktreeRoot, - gitPath, - gitRoot, - commonPath, - commonRoot, - ) - if err != nil { - t.Fatalf("create rooted repair command: %v", err) - } - defer func() { - if err := cleanup(); err != nil { - t.Errorf("cleanup rooted repair command: %v", err) - } - }() - if len(command.ExtraFiles) != 1 { - t.Fatalf("repair command inherited files = %d, want 1", len(command.ExtraFiles)) - } - for _, want := range []string{ - "GIT_DIR=.", - "GIT_WORK_TREE=" + worktreePath, - } { - if !slices.Contains(command.Env, want) { - t.Fatalf("repair command environment lacks %q", want) - } - } - for _, entry := range command.Env { - if strings.HasPrefix(entry, "GIT_COMMON_DIR=") { - t.Fatalf("repair command reopens common Git directory: %q", entry) - } - } - wantArguments := []string{"worktree", "repair", worktreePath} - if len(command.Args) < len(wantArguments) || - !slices.Equal( - command.Args[len(command.Args)-len(wantArguments):], - wantArguments, - ) { - t.Fatalf("repair command arguments = %q", command.Args) - } -} - func TestBoundGitEnvironmentRemovesInheritedGitConfiguration(t *testing.T) { environment := boundGitEnvironment( []string{ diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index 79b6d05..158c7e3 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -305,25 +305,3 @@ func rootedPreparedWorktreeGitCommand( } return command, cleanup, nil } - -func rootedRepairWorktreeGitCommand( - ctx context.Context, - git string, - worktreePath string, - _ *os.Root, - gitPath string, - gitRoot *os.Root, - commonPath string, - commonRoot *os.Root, -) (*exec.Cmd, func() error, error) { - return rootedPrivateGitCommand( - ctx, - git, - worktreePath, - gitPath, - gitRoot, - commonPath, - commonRoot, - []string{"worktree", "repair", worktreePath}, - ) -} diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 7ed0d68..2723fcd 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -259,13 +259,26 @@ func TestPrepareRemovesPrecreatedTargetWhenWorktreeAddFails(t *testing.T) { invoked := filepath.Join(parent, "worktree-add-invoked") wrapper := filepath.Join(parent, "git-wrapper-add-failure") script := `#!/bin/sh +command_root= +next_root= next_target= for argument in "$@"; do + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue + fi if [ -n "$next_target" ]; then test -d "$argument" || exit 98 - printf '%s' "$PWD" > "$DROVE_TEST_INVOKED" + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 99 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 99 + fi + printf '%s' "$target" > "$DROVE_TEST_INVOKED" exit 97 fi + [ "$argument" = "-C" ] && next_root=1 [ "$argument" = "--no-checkout" ] && next_target=1 done exec "$DROVE_TEST_REAL_GIT" "$@" @@ -332,13 +345,25 @@ func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { } wrapper := filepath.Join(parent, "git-wrapper-target-swap") script := `#!/bin/sh +command_root= +next_root= target= next_target= for argument in "$@"; do + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue + fi if [ -n "$next_target" ]; then - target=$PWD + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 90 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 90 + fi next_target= fi + [ "$argument" = "-C" ] && next_root=1 [ "$argument" = "--no-checkout" ] && next_target=1 done "$DROVE_TEST_REAL_GIT" "$@" @@ -432,17 +457,28 @@ func TestPrepareDoesNotWriteThroughReplacedStagingPath(t *testing.T) { } wrapper := filepath.Join(parent, "git-wrapper-replace-staging") script := `#!/bin/sh +command_root= +next_root= target= next_target= for argument in "$@"; do + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue + fi if [ -n "$next_target" ]; then - target=$argument + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 90 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 90 + fi next_target= fi + [ "$argument" = "-C" ] && next_root=1 [ "$argument" = "--no-checkout" ] && next_target=1 done if [ -n "$target" ]; then - target=$PWD mv "$target" "$target-opened" || exit 91 ln -s "$DROVE_TEST_OUTSIDE" "$target" || exit 92 fi @@ -475,9 +511,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } -func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { +func TestPrepareCheckoutAndPruneFailuresPreserveRecoveryRecord(t *testing.T) { if runtime.GOOS != "linux" { - t.Skip("BSD promotion does not invoke git worktree repair") + t.Skip("BSD checkout uses a different implementation") } parent := t.TempDir() source := filepath.Join(parent, "source") @@ -500,13 +536,13 @@ func TestPreparePruneFailurePreservesRecoveryRecord(t *testing.T) { if err != nil { t.Fatalf("find Git: %v", err) } - repairFailed := filepath.Join(parent, "repair-failed") + checkoutFailed := filepath.Join(parent, "checkout-failed") pruneFailed := filepath.Join(parent, "prune-failed") - wrapper := filepath.Join(parent, "git-wrapper-repair-prune-failure") + wrapper := filepath.Join(parent, "git-wrapper-checkout-prune-failure") script := `#!/bin/sh case " $* " in - *" worktree repair "*) - : > "$DROVE_TEST_REPAIR_FAILED" + *" read-tree "*) + : > "$DROVE_TEST_CHECKOUT_FAILED" exit 91 ;; *" worktree prune "*) @@ -519,7 +555,7 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_REPAIR_FAILED", repairFailed) + t.Setenv("DROVE_TEST_CHECKOUT_FAILED", checkoutFailed) t.Setenv("DROVE_TEST_PRUNE_FAILED", pruneFailed) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -530,10 +566,10 @@ exec "$DROVE_TEST_REAL_GIT" "$@" "", testAgentID, ); err == nil { - t.Fatal("prepare succeeded after repair and prune failures") + t.Fatal("prepare succeeded after checkout and prune failures") } - if _, err := os.Lstat(repairFailed); err != nil { - t.Fatalf("repair failure was not reached: %v", err) + if _, err := os.Lstat(checkoutFailed); err != nil { + t.Fatalf("checkout failure was not reached: %v", err) } if _, err := os.Lstat(pruneFailed); err != nil { t.Fatalf("prune failure was not reached: %v", err) @@ -2526,9 +2562,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } -func TestPrepareProtectsPromotedWorktreePointerDuringRepair(t *testing.T) { +func TestPrepareDoesNotUsePublicWorktreeRepair(t *testing.T) { if runtime.GOOS != "linux" { - t.Skip("BSD promotion rewrites the bound private pointer directly") + t.Skip("Linux promotion rewrites the bound private pointer directly") } parent := t.TempDir() repository := filepath.Join(parent, "repository") @@ -2543,25 +2579,14 @@ func TestPrepareProtectsPromotedWorktreePointerDuringRepair(t *testing.T) { if err != nil { t.Fatalf("find Git: %v", err) } - swapResult := filepath.Join(parent, "repair-swap-result") - wrapper := filepath.Join(parent, "git-wrapper-swap-repair-pointer") + repairResult := filepath.Join(parent, "worktree-repair-result") + wrapper := filepath.Join(parent, "git-wrapper-reject-worktree-repair") script := `#!/bin/sh saw_worktree= for argument in "$@"; do - if [ "$saw_worktree" = "1" ] && [ "$argument" = "repair" ] && - [ ! -e "$DROVE_TEST_SWAP_RESULT" ]; then - target=${GIT_WORK_TREE:-$(pwd -P)} - original="$target/.git.drove-original" - if mv "$target/.git" "$original" 2>/dev/null; then - cp "$DROVE_TEST_SIBLING_POINTER" "$target/.git" || exit 90 - "$DROVE_TEST_REAL_GIT" "$@" - status=$? - rm -f "$target/.git" || exit 91 - mv "$original" "$target/.git" || exit 92 - printf swapped > "$DROVE_TEST_SWAP_RESULT" - exit "$status" - fi - printf blocked > "$DROVE_TEST_SWAP_RESULT" + if [ "$saw_worktree" = "1" ] && [ "$argument" = "repair" ]; then + printf invoked > "$DROVE_TEST_REPAIR_RESULT" + exit 94 fi [ "$argument" = "worktree" ] && saw_worktree=1 done @@ -2570,8 +2595,7 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_SIBLING_POINTER", filepath.Join(sibling, ".git")) - t.Setenv("DROVE_TEST_SWAP_RESULT", swapResult) + t.Setenv("DROVE_TEST_REPAIR_RESULT", repairResult) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager, err := New(filepath.Join(parent, "data")) @@ -2579,35 +2603,23 @@ exec "$DROVE_TEST_REAL_GIT" "$@" t.Fatalf("new manager: %v", err) } manager.git = wrapper - _, err = manager.Prepare( + prepared, err := manager.Prepare( context.Background(), repository, "", testAgentID, ) - if err == nil || !strings.Contains(err.Error(), "registration") { - t.Fatalf("prepare through repair pointer swap error = %v", err) - } - result, err := os.ReadFile(swapResult) if err != nil { - t.Fatalf("read repair swap result: %v", err) + t.Fatalf("prepare without public worktree repair: %v", err) } - if string(result) != "swapped" { - t.Fatalf("repair pointer swap result = %q, want swapped", result) - } - canonicalRepository, err := resolvePath(repository) - if err != nil { - t.Fatalf("resolve repository: %v", err) + if _, err := os.Lstat(repairResult); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("worktree repair was invoked: %v", err) } assertGitWorktreeRegistration( t, filepath.Join(repository, ".git"), - filepath.Join( - manager.root, - repositoryHash(canonicalRepository), - testAgentID, - ), - false, + prepared.Path, + true, ) registered, err := manager.worktreeRegistered( context.Background(), @@ -2622,6 +2634,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" runGit(t, repository, "worktree", "list", "--porcelain"), ) } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared worktree: %v", err) + } } func postSuccessSwapGitWrapper( From c6234c57739208d3e41b184137cbab8bc8e9c4b1 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:04:31 +0800 Subject: [PATCH 039/104] fix: honor legacy startup stop intent --- internal/session/projection.go | 3 +++ internal/session/projection_test.go | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/internal/session/projection.go b/internal/session/projection.go index d22aca5..46dcd83 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -403,6 +403,9 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { if isRestartStop { draft.restartStopped = true draft.startupResumePending = true + } else if to == agent.StateStopped && row.Reason == "user stop" { + draft.restartStopped = false + draft.startupResumePending = false } else if to == agent.StateDone { draft.restartStopped = false } diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 1b65363..64023ed 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -574,6 +574,15 @@ func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( finalRows []store.EventRow wantResume bool }{ + { + name: "legacy explicit user stop", + finalRows: []store.EventRow{{ + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: "user stop", + }}, + wantResume: false, + }, { name: "required hook failure", finalRows: []store.EventRow{{ From 5e4a973fdd60deb091b7f3c4cccd250d7600f7ad Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:08:04 +0800 Subject: [PATCH 040/104] fix: forward TUI terminal streams --- internal/cliattach/AGENTS.md | 5 +++-- internal/cliattach/run.go | 26 ++++++++++++++++++++++++-- internal/cliattach/run_test.go | 18 ++++++++++++++++++ internal/clitui/AGENTS.md | 3 ++- internal/clitui/model.go | 13 ++++++++++--- internal/clitui/model_test.go | 18 +++++++++++++----- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/internal/cliattach/AGENTS.md b/internal/cliattach/AGENTS.md index aa29b58..407de96 100644 --- a/internal/cliattach/AGENTS.md +++ b/internal/cliattach/AGENTS.md @@ -7,8 +7,9 @@ stdin/stdout pumps、resize signal 和退出清理。Cobra 只解析参数并调 ## 关键设计 -- `Run` 只接受具体 `client.Client`;测试通过包内 runner dependencies 替换 stream、 - TTY、cancel reader 和 signal source。 +- `Run` 只接受具体 `client.Client`;`Options` 可携带调用方已经租用的 stdin/stdout, + 未指定时使用进程标准流。测试通过包内 runner dependencies 替换 stream、TTY、 + cancel reader 和 signal source。 - stdin 必须是终端。runner 保存原状态、进入 raw mode,并在所有退出路径恢复一次。 - stdin 每次最多读取 32 KiB;UTF-8 不完整后缀跨读取保留,发送请求保持合法 UTF-8 且不超过 session 的 64 KiB 上限。 diff --git a/internal/cliattach/run.go b/internal/cliattach/run.go index 9925485..a6cc545 100644 --- a/internal/cliattach/run.go +++ b/internal/cliattach/run.go @@ -27,6 +27,8 @@ var ( // Options configures one CLI terminal attachment. type Options struct { ReadOnly bool + Stdin io.Reader + Stdout io.Writer } type terminalStream interface { @@ -76,9 +78,13 @@ func Run( if daemon == nil { return errors.New("cliattach: client is required") } + stdin, stdout, err := terminalStreams(options) + if err != nil { + return err + } return run(ctx, agentID, options, runnerDependencies{ - stdin: os.Stdin, - stdout: os.Stdout, + stdin: stdin, + stdout: stdout, open: func(ctx context.Context) (terminalStream, error) { return daemon.OpenTerminal(ctx) }, @@ -99,6 +105,22 @@ func Run( }) } +func terminalStreams(options Options) (inputTerminal, io.Writer, error) { + input := io.Reader(os.Stdin) + if options.Stdin != nil { + input = options.Stdin + } + stdin, ok := input.(inputTerminal) + if !ok { + return nil, nil, errStdinNotTerminal + } + stdout := io.Writer(os.Stdout) + if options.Stdout != nil { + stdout = options.Stdout + } + return stdin, stdout, nil +} + func run( ctx context.Context, agentID string, diff --git a/internal/cliattach/run_test.go b/internal/cliattach/run_test.go index b70f380..a8be0da 100644 --- a/internal/cliattach/run_test.go +++ b/internal/cliattach/run_test.go @@ -87,6 +87,24 @@ func TestAttachTTYRejectsNonTerminalBeforeSetup(t *testing.T) { } } +func TestTerminalStreamsUseInjectedTerminalAndOutput(t *testing.T) { + input := fakeInputTerminal{} + var output bytes.Buffer + stdin, stdout, err := terminalStreams(Options{ + Stdin: input, + Stdout: &output, + }) + if err != nil { + t.Fatalf("resolve terminal streams: %v", err) + } + if stdin != input { + t.Fatalf("stdin = %T, want injected terminal", stdin) + } + if stdout != &output { + t.Fatalf("stdout = %T, want injected output", stdout) + } +} + func TestAttachCleanupAfterPumpFailure(t *testing.T) { stream := newFakeTerminalStream() stream.messages <- client.TerminalOutput{ diff --git a/internal/clitui/AGENTS.md b/internal/clitui/AGENTS.md index 4c900b3..a561e04 100644 --- a/internal/clitui/AGENTS.md +++ b/internal/clitui/AGENTS.md @@ -12,7 +12,8 @@ - 只有选中的 Agent 持有 snapshot 订阅;preview actor 独占 stream 和 `Next` 调用, 切换目标时先取消并等待旧 generation 退出。 - Bubble Tea 负责总览终端状态与 resize;attach 通过 `tea.Exec` 暂停总览,并把 - raw mode、输入输出 pump、SIGWINCH 和 Ctrl-Q 交给 `cliattach.Run`。 + 它注入的终端 stdin/stdout 交给 `cliattach.Run`,后者接管 raw mode、输入输出 + pump、SIGWINCH 和 Ctrl-Q。 - 同时最多执行一个 send、stop 或 explain;选择的进程代次变化时取消旧操作并要求 用户重新确认。attach 返回不得覆盖用户在终端恢复后做出的新选择。 - 退出总览只清理本地请求、stream 和 worker,不停止远端 Agent。 diff --git a/internal/clitui/model.go b/internal/clitui/model.go index 94999a1..d8270b2 100644 --- a/internal/clitui/model.go +++ b/internal/clitui/model.go @@ -738,14 +738,21 @@ type attachCommand struct { run attachRunner agentID string options cliattach.Options + stderr io.Writer } func (c *attachCommand) Run() error { return c.run(c.ctx, c.agentID, c.options) } -func (*attachCommand) SetStdin(io.Reader) {} +func (c *attachCommand) SetStdin(stdin io.Reader) { + c.options.Stdin = stdin +} -func (*attachCommand) SetStdout(io.Writer) {} +func (c *attachCommand) SetStdout(stdout io.Writer) { + c.options.Stdout = stdout +} -func (*attachCommand) SetStderr(io.Writer) {} +func (c *attachCommand) SetStderr(stderr io.Writer) { + c.stderr = stderr +} diff --git a/internal/clitui/model_test.go b/internal/clitui/model_test.go index 6dee495..ba2bdcc 100644 --- a/internal/clitui/model_test.go +++ b/internal/clitui/model_test.go @@ -3,7 +3,6 @@ package clitui import ( "context" "errors" - "io" "reflect" "strings" "sync" @@ -449,10 +448,10 @@ func TestAttachUsesExecModesAndPreservesSelection(t *testing.T) { var ( options []cliattach.Options - stdin io.Reader - stdout io.Writer - stderr io.Writer ) + stdin := strings.NewReader("terminal input") + stdout := &strings.Builder{} + stderr := &strings.Builder{} attachErr := errors.New("attach failed") preview := newRecordingPreview() m := newModelWithDependencies(context.Background(), modelDependencies{ @@ -462,7 +461,16 @@ func TestAttachUsesExecModesAndPreservesSelection(t *testing.T) { if id != "agent-1" { t.Fatalf("attached agent = %q, want agent-1", id) } - options = append(options, option) + if option.Stdin != stdin || option.Stdout != stdout { + t.Fatalf( + "attach streams = (%T, %T), want Bubble Tea streams", + option.Stdin, + option.Stdout, + ) + } + options = append(options, cliattach.Options{ + ReadOnly: option.ReadOnly, + }) if option.ReadOnly { return attachErr } From 5795272f7a025f2ca174066865929d2421bc7c92 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:10:25 +0800 Subject: [PATCH 041/104] fix: recover interrupted record writes --- internal/workspace/record_artifact.go | 77 ++++++++++------------ internal/workspace/record_artifact_test.go | 64 ++++++++++++++++++ 2 files changed, 100 insertions(+), 41 deletions(-) diff --git a/internal/workspace/record_artifact.go b/internal/workspace/record_artifact.go index 9fbbea1..d281170 100644 --- a/internal/workspace/record_artifact.go +++ b/internal/workspace/record_artifact.go @@ -199,6 +199,24 @@ func (m *Manager) recoverWorkspaceRecordArtifact( } worktreePath := filepath.Join(m.root, bucketName, artifact.agentID) recordPath := workspaceRecordPath(worktreePath) + if artifact.kind == workspaceRecordTemporaryArtifact { + return removeOwnedRecordPath( + bucket, + artifact.name, + file, + workspaceRecordTemporaryName( + artifact.agentID+workspaceRecordSuffix, + ), + nil, + nil, + ) + } + if artifact.kind != workspaceRecordRemovalArtifact { + return fmt.Errorf( + "workspace: record artifact %q has an invalid kind", + artifact.name, + ) + } record, err := m.decodeWorkspaceRecordFile( file, recordPath, @@ -213,45 +231,24 @@ func (m *Manager) recoverWorkspaceRecordArtifact( artifact.name, ) } - if artifact.kind == workspaceRecordRemovalArtifact { - expectedOperation := "none" - if record.Removal != nil { - expectedOperation = record.Removal.OperationID - } - if artifact.operationID != expectedOperation { - return fmt.Errorf( - "workspace: record artifact %q has the wrong operation", - artifact.name, - ) - } + expectedOperation := "none" + if record.Removal != nil { + expectedOperation = record.Removal.OperationID + } + if artifact.operationID != expectedOperation { + return fmt.Errorf( + "workspace: record artifact %q has the wrong operation", + artifact.name, + ) } if _, err := file.Seek(0, io.SeekStart); err != nil { return err } - if artifact.kind == workspaceRecordTemporaryArtifact { - canonicalName := artifact.agentID + workspaceRecordSuffix - canonical, err := bucket.Lstat(canonicalName) - if err == nil && !os.SameFile(opened, canonical) { - return fmt.Errorf( - "workspace: temporary record artifact %q conflicts with its canonical record", - artifact.name, - ) - } - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - } - nextName := workspaceRecordTemporaryName( - artifact.agentID + workspaceRecordSuffix, - ) - if artifact.kind == workspaceRecordRemovalArtifact { - nextName = workspaceRecordRemovalName(record) - } return removeOwnedRecordPath( bucket, artifact.name, file, - nextName, + workspaceRecordRemovalName(record), func(candidate *os.File) error { if _, err := candidate.Seek(0, io.SeekStart); err != nil { return err @@ -269,16 +266,14 @@ func (m *Manager) recoverWorkspaceRecordArtifact( "workspace: record artifact changed ownership", ) } - if artifact.kind == workspaceRecordRemovalArtifact { - currentOperation := "none" - if current.Removal != nil { - currentOperation = current.Removal.OperationID - } - if currentOperation != artifact.operationID { - return errors.New( - "workspace: record artifact changed operation", - ) - } + currentOperation := "none" + if current.Removal != nil { + currentOperation = current.Removal.OperationID + } + if currentOperation != artifact.operationID { + return errors.New( + "workspace: record artifact changed operation", + ) } return nil }, diff --git a/internal/workspace/record_artifact_test.go b/internal/workspace/record_artifact_test.go index 4d6b6de..ef57d0b 100644 --- a/internal/workspace/record_artifact_test.go +++ b/internal/workspace/record_artifact_test.go @@ -1,6 +1,7 @@ package workspace import ( + "bytes" "errors" "os" "path/filepath" @@ -36,6 +37,69 @@ func TestRecoverWorkspaceRecordTemporaryArtifact(t *testing.T) { _ = manager } +func TestRecoverWorkspaceRecordTemporaryDebrisBesideCanonical( + t *testing.T, +) { + for _, test := range []struct { + name string + content func([]byte) []byte + }{ + { + name: "complete separate inode", + content: func(canonical []byte) []byte { + return append([]byte(nil), canonical...) + }, + }, + { + name: "partial write", + content: func([]byte) []byte { + return []byte("{") + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + _, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + canonical, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read canonical record: %v", err) + } + temporaryPath := filepath.Join( + filepath.Dir(recordPath), + workspaceRecordTemporaryName(filepath.Base(recordPath)), + ) + if err := os.WriteFile( + temporaryPath, + test.content(canonical), + 0o600, + ); err != nil { + t.Fatalf("write temporary record debris: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + current, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read recovered canonical record: %v", err) + } + if !bytes.Equal(current, canonical) { + t.Fatal("temporary debris changed the canonical record") + } + if _, err := os.Lstat(temporaryPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("temporary record debris remains: %v", err) + } + }) + } +} + func TestRecoverWorkspaceRecordRemovalArtifact(t *testing.T) { _, target, record, dataDir := newRecordArtifactFixture(t) recordPath := workspaceRecordPath(target.Path) From cef0effc084d4e92a71137a8860318bc4f33e4ed Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:12:47 +0800 Subject: [PATCH 042/104] fix: recover removal marker discard --- internal/workspace/removal_acknowledgement.go | 6 +++ .../workspace/removal_acknowledgement_test.go | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/workspace/removal_acknowledgement.go b/internal/workspace/removal_acknowledgement.go index 47cc897..633916a 100644 --- a/internal/workspace/removal_acknowledgement.go +++ b/internal/workspace/removal_acknowledgement.go @@ -354,6 +354,12 @@ func cleanupRemovalAcknowledgementReservations( func cleanupIsolatedRemovalAcknowledgementReservation( reservation *removalAcknowledgementReservation, ) error { + if err := cleanupRemovalMarkerTemps( + reservation.root, + reservation.record, + ); err != nil { + return err + } if err := validateClearedRemovalDirectory( reservation.root, reservation.record, diff --git a/internal/workspace/removal_acknowledgement_test.go b/internal/workspace/removal_acknowledgement_test.go index 1185ed9..6c02f86 100644 --- a/internal/workspace/removal_acknowledgement_test.go +++ b/internal/workspace/removal_acknowledgement_test.go @@ -106,6 +106,46 @@ func TestCleanupRemovalAcknowledgementReservationsRecoversRenamePhase( } } +func TestCleanupRemovalAcknowledgementReservationsRecoversMarkerDiscard( + t *testing.T, +) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + name := removalAcknowledgementReservationPrefix(record) + + "91919191-9191-4191-8191-919191919191" + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + reservationPath := filepath.Join(bucketPath, name) + if err := os.Rename( + filepath.Join(reservationPath, removalMarkerName(record)), + filepath.Join(reservationPath, removalMarkerDiscardName(record)), + ); err != nil { + t.Fatalf("isolate removal marker: %v", err) + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("cleanup reservation: %v", err) + } + if _, err := os.Lstat(reservationPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reservation remains: %v", err) + } +} + func TestCleanupRemovalAcknowledgementReservationsRejectsAmbiguousPhases( t *testing.T, ) { From 61965efb2efcd24025b8ddcd44022e7f1fdf2e07 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:21:41 +0800 Subject: [PATCH 043/104] fix: stop previews for detached sessions --- internal/clitui/model.go | 4 ++++ internal/clitui/model_test.go | 40 ++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/internal/clitui/model.go b/internal/clitui/model.go index d8270b2..4585975 100644 --- a/internal/clitui/model.go +++ b/internal/clitui/model.go @@ -500,6 +500,10 @@ func filterProjectedFleet(rows []fleetRow, filter fleetFilter) []fleetRow { } func (m *model) replacePreview(agentID string) { + row, selected := m.selectedRow() + if !selected || row.AgentID != agentID || row.PID <= 0 { + agentID = "" + } m.previewTarget.Generation++ m.previewTarget.AgentID = agentID m.previewSnapshot = nil diff --git a/internal/clitui/model_test.go b/internal/clitui/model_test.go index ba2bdcc..e745c73 100644 --- a/internal/clitui/model_test.go +++ b/internal/clitui/model_test.go @@ -30,9 +30,9 @@ func TestModelSelectionFilterAndPreviewGeneration(t *testing.T) { _, _ = m.Update(fleetResultMsg{ RequestID: 1, Statuses: []*session.Status{ - newFleetStatus("pending", agent.StatePending, time.Now()), - newFleetStatus("blocked", agent.StateBlocked, time.Now()), - newFleetStatus("working", agent.StateWorking, time.Now()), + newAttachedFleetStatus("pending", agent.StatePending), + newAttachedFleetStatus("blocked", agent.StateBlocked), + newAttachedFleetStatus("working", agent.StateWorking), }, }) @@ -229,6 +229,33 @@ func TestPollReplacesPreviewWhenSelectedAgentProcessChanges(t *testing.T) { }) { t.Fatalf("replacement target = %+v", got) } + + stopped := newFleetStatus("agent-1", agent.StateStopped, time.Now()) + m.fleetInFlight = true + m.fleetRequestID = 3 + _, _ = m.Update(fleetResultMsg{ + RequestID: 3, + Statuses: []*session.Status{stopped}, + }) + if got := preview.lastReplacement(t); got != (previewTarget{ + Generation: 3, + }) { + t.Fatalf("stopped target = %+v, want canceled preview", got) + } + + restarted := newAttachedFleetStatus("agent-1", agent.StateStarting) + m.fleetInFlight = true + m.fleetRequestID = 4 + _, _ = m.Update(fleetResultMsg{ + RequestID: 4, + Statuses: []*session.Status{restarted}, + }) + if got := preview.lastReplacement(t); got != (previewTarget{ + AgentID: "agent-1", + Generation: 4, + }) { + t.Fatalf("restarted target = %+v", got) + } } func TestActionSendAddsExactlyOneNewlineAndEnforcesByteLimit(t *testing.T) { @@ -669,6 +696,7 @@ func seedModel(m *model, ids ...string) { Name: id, Vendor: "generic", State: agent.StateWorking, + PID: index + 1, HookStatus: detect.HookOff, UpdatedAt: time.Now(), } @@ -676,6 +704,12 @@ func seedModel(m *model, ids ...string) { m.rebuildVisibleRows() } +func newAttachedFleetStatus(id string, state agent.State) *session.Status { + status := newFleetStatus(id, state, time.Now()) + status.PID = 1 + return status +} + func keyMessage(key string) tea.KeyMsg { switch key { case "enter": From d42da4839309a0388083cce511b167d64dbe7450 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:45:33 +0800 Subject: [PATCH 044/104] fix: classify expired output requests --- internal/client/client.go | 5 +++++ internal/client/client_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/internal/client/client.go b/internal/client/client.go index fda85f8..25cc033 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -65,6 +65,11 @@ func IsUserError(err error) bool { } } + var expired *recording.OutputExpiredError + if errors.As(err, &expired) { + return true + } + var terminalErr *TerminalStreamError if !errors.As(err, &terminalErr) { return false diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 734fd36..0b2944a 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -633,6 +633,9 @@ func TestFrameReturnsTypedOutputExpiry(t *testing.T) { if !errors.As(err, &expired) { t.Fatalf("frame error = %v, want OutputExpiredError", err) } + if !IsUserError(err) { + t.Fatalf("frame expiry was not classified as a user error: %v", err) + } if expired.SessionID != "agent-1" || len(expired.Missing) != 1 || expired.Missing[0] != (recording.OutputRange{Start: 3, End: 8}) { From dd6e8b40c1e9fa583d982e55a4df77b112334c99 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:51:07 +0800 Subject: [PATCH 045/104] fix: reject unexpected cli arguments --- cmd/drove/main.go | 6 +++++- cmd/drove/main_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index 566e809..df0d8a2 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -80,13 +80,14 @@ func executeRoot(root *cobra.Command, args []string) error { if err != nil { return markUsageError(err) } + afterTerminator := len(remaining) > 0 && remaining[0] == "--" if len(remaining) > 0 && remaining[0] == "--" { remaining = remaining[1:] } if command.HasSubCommands() && !command.Runnable() && len(remaining) > 0 && - !strings.HasPrefix(remaining[0], "-") { + (afterTerminator || !strings.HasPrefix(remaining[0], "-")) { return markUsageError(fmt.Errorf( "unknown command %q for %q", remaining[0], @@ -224,6 +225,7 @@ func newInitCmd() *cobra.Command { return &cobra.Command{ Use: "init", Short: "初始化配置与数据目录", + Args: usageArgs(cobra.NoArgs), RunE: func(_ *cobra.Command, _ []string) error { cfg := config.Defaults() path := config.DefaultPath() @@ -309,6 +311,7 @@ func newPSCmd() *cobra.Command { return &cobra.Command{ Use: "ps", Short: "列出全部 Agent 会话", + Args: usageArgs(cobra.NoArgs), RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() c, err := newClient(ctx) @@ -1179,6 +1182,7 @@ func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "输出版本信息", + Args: usageArgs(cobra.NoArgs), Run: func(_ *cobra.Command, _ []string) { fmt.Printf("drove %s (commit=%s, built=%s)\n", version.Version, version.Commit, version.Date) }, diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index e887523..34ee810 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -16,6 +16,8 @@ import ( "time" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/Duang777/drove/internal/agent" "github.com/Duang777/drove/internal/auth" "github.com/Duang777/drove/internal/cliattach" @@ -269,6 +271,8 @@ func TestExecuteRootTreatsUnknownCommandsAsUsageErrors(t *testing.T) { {"worktree", "unknown"}, {"--", "unknown"}, {"worktree", "--", "unknown"}, + {"--", "--bogus"}, + {"worktree", "--", "--bogus"}, } for _, args := range tests { t.Run(strings.Join(args, "/"), func(t *testing.T) { @@ -283,6 +287,30 @@ func TestExecuteRootTreatsUnknownCommandsAsUsageErrors(t *testing.T) { } } +func TestZeroArgumentCommandsRejectArguments(t *testing.T) { + for _, command := range []*cobra.Command{ + newInitCmd(), + newPSCmd(), + newVersionCmd(), + } { + t.Run(command.Name(), func(t *testing.T) { + if command.Args == nil { + t.Fatal("argument validator is nil") + } + if err := command.Args(command, nil); err != nil { + t.Fatalf("validate no arguments: %v", err) + } + err := command.Args(command, []string{"unexpected"}) + if err == nil { + t.Fatal("accepted an unexpected argument") + } + if got := commandExitCode(err); got != exitUsage { + t.Fatalf("exit code = %d, want %d for %v", got, exitUsage, err) + } + }) + } +} + func TestExecuteRootTreatsDefaultCommandArgumentErrorsAsUsageErrors( t *testing.T, ) { From 7b2eb8a72651d388456fd09286938e22b095670c Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 21:57:09 +0800 Subject: [PATCH 046/104] fix: bound failed pty cleanup --- internal/pty/AGENTS.md | 5 ++- internal/pty/pty.go | 9 ++-- internal/pty/pty_test.go | 94 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/internal/pty/AGENTS.md b/internal/pty/AGENTS.md index 9c2106c..803b5e8 100644 --- a/internal/pty/AGENTS.md +++ b/internal/pty/AGENTS.md @@ -30,8 +30,9 @@ - 进程退出回调通过 `ExitInfo.CleanupErr` 暴露进程组清理失败;直接子进程退出成功 不得掩盖仍存活但当前用户无权终止的后代。 - PTY master 只在直接子进程已回收后关闭;`Close` 幂等,并等待读取、进程退出和 - 全部回调及 writer 完成。自然退出仍在读取结束后关闭 master,并与主动关闭共享 - 唯一的 master close。 + 全部回调及 writer 完成。健康的自然退出先排空读取再关闭 master;进程组清理失败时 + 先关闭 master 打断继承 slave 的不可清理后代,再等待读取结束。两条路径共享唯一的 + master close。 - 进程退出码经 `WaitCh` 返回,供状态机迁移到 `Stopped`/`Done`。 ## 约束 diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 02e0d76..d6a8032 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -286,6 +286,12 @@ func (s *Session) waitLoop() { s.beginClose() close(s.processExited) s.closeProcessGroup() + s.mu.Lock() + info.CleanupErr = s.groupCloseErr + s.mu.Unlock() + if info.CleanupErr != nil { + s.closeMaster() + } if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { @@ -295,9 +301,6 @@ func (s *Session) waitLoop() { } else { info.Code = 0 } - s.mu.Lock() - info.CleanupErr = s.groupCloseErr - s.mu.Unlock() if s.onExit != nil { s.onExit(info) } diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go index f412cee..53a8219 100644 --- a/internal/pty/pty_test.go +++ b/internal/pty/pty_test.go @@ -639,6 +639,100 @@ func TestCloseReportsInaccessibleProcessGroup(t *testing.T) { } } +func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { + const grace = time.Millisecond + childPIDPath := filepath.Join(t.TempDir(), "child.pid") + childReadyPath := filepath.Join(t.TempDir(), "child.ready") + exits := make(chan ExitInfo, 1) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create inherited output pipe: %v", err) + } + command := exec.Command( + os.Args[0], + ptyHelperArgs( + "natural-group-leader", + childPIDPath, + childReadyPath, + )..., + ) + command.Env = append(os.Environ(), "DROVE_PTY_HELPER=1") + command.Stdout = writer + command.Stderr = writer + if err := command.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + t.Fatalf("start process helper: %v", err) + } + if err := writer.Close(); err != nil { + _ = command.Process.Kill() + _ = reader.Close() + t.Fatalf("close parent output writer: %v", err) + } + + sess := &Session{ + cmd: command, + ptmx: reader, + grace: grace, + groupSignal: func(_ int, _ syscall.Signal) error { return syscall.EPERM }, + onExit: func(info ExitInfo) { + exits <- info + }, + readDone: make(chan struct{}), + processExited: make(chan struct{}), + done: make(chan struct{}), + WaitCh: make(chan ExitInfo, 1), + } + go sess.readLoop() + go sess.waitLoop() + + select { + case <-sess.processExited: + case <-time.After(2 * time.Second): + t.Fatal("direct child did not exit") + } + rawPID, err := os.ReadFile(childPIDPath) + if err != nil { + t.Fatalf("read child pid: %v", err) + } + childPID, err := strconv.Atoi(string(rawPID)) + if err != nil { + t.Fatalf("parse child pid %q: %v", rawPID, err) + } + childNeedsCleanup := true + t.Cleanup(func() { + if childNeedsCleanup { + _ = syscall.Kill(childPID, syscall.SIGKILL) + } + }) + + select { + case <-sess.done: + case <-time.After(2 * time.Second): + _ = syscall.Kill(childPID, syscall.SIGKILL) + childNeedsCleanup = false + <-sess.done + t.Fatal("session waited for an inaccessible descendant to close the PTY") + } + if err := syscall.Kill(childPID, 0); err != nil { + t.Fatalf("descendant exited before PTY cleanup completed: %v", err) + } + if closeErr := sess.Close(); !errors.Is(closeErr, syscall.EPERM) { + t.Fatalf("close error = %v, want EPERM", closeErr) + } + if exit := waitExit(t, exits); !errors.Is(exit.CleanupErr, syscall.EPERM) { + t.Fatalf("exit cleanup error = %v, want EPERM", exit.CleanupErr) + } + + if err := syscall.Kill(childPID, syscall.SIGKILL); err != nil && + !errors.Is(err, syscall.ESRCH) { + t.Fatalf("kill descendant: %v", err) + } + childNeedsCleanup = false + waitForProcessNotRunning(t, childPID) +} + func TestCloseSuppressesTermPermissionErrorAfterProcessGroupExits(t *testing.T) { const grace = time.Second var signalsMu sync.Mutex From de97b08ee9e122168efa5fbed79bb9617941a731 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 22:04:31 +0800 Subject: [PATCH 047/104] fix: recover isolated worktree pointers --- internal/workspace/AGENTS.md | 4 +- internal/workspace/preparation_test.go | 113 ++++++++++++++++++ .../workspace/prepared_registration_bound.go | 15 +-- internal/workspace/prepared_rollback.go | 28 +++-- 4 files changed, 132 insertions(+), 28 deletions(-) diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 858a52a..e26cb3e 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -31,7 +31,9 @@ 和 gitlink。macOS/BSD 遇到普通文件的 `filter` 属性必须 fail-stop,因为 filter 无法同时取得固定 worktree cwd 与描述符绑定的私有 Git 上下文。初始化完成后把 staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 - `git worktree repair .`。仓库根目录的 + `git worktree repair .`。私有 `gitdir` 指针替换中断后,重启恢复必须先从绑定私有 + Git 目录校验并恢复 `old-*` artifact,不能先要求 Git worktree 列表仍能看到缺失 + `gitdir` 的 staging 注册;唯一允许的旧指针从 operation staging 路径推导。仓库根目录的 `.worktreeinclude` 使用 gitignore 语义,并从已打开文件读取一次。Unix 通过继承的 只读文件描述符交给 Git,其余平台通过受复核的私有临时副本交给 Git,禁止 Git 再按 源 manifest 路径打开。 diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index da61d22..00a1f98 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -2,8 +2,10 @@ package workspace import ( "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -353,6 +355,117 @@ func TestReconcilePreparationsRepairsPromotionBeforeDiscard(t *testing.T) { } } +func TestReconcilePreparationsRecoversIsolatedPrivateGitPointer( + t *testing.T, +) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "linux", + "netbsd", "openbsd", "windows": + default: + t.Skip("bound Git pointer recovery is unsupported") + } + + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + privatePointerPath := filepath.Join(record.GitDirectory, "gitdir") + stalePointer := filepath.ToSlash(filepath.Join(stagingPath, ".git")) + "\n" + if err := os.WriteFile( + privatePointerPath, + []byte(stalePointer), + 0o600, + ); err != nil { + t.Fatalf("restore stale private Git pointer: %v", err) + } + digest := sha256.Sum256( + []byte(filepath.Base(record.GitDirectory) + "\x00gitdir"), + ) + backupName := fmt.Sprintf( + ".drove-git-pointer-%x-old-%s", + digest[:8], + "78787878-7878-4787-8787-787878787878", + ) + backupPath := filepath.Join(record.GitDirectory, backupName) + if err := os.Rename(privatePointerPath, backupPath); err != nil { + t.Fatalf("isolate stale private Git pointer: %v", err) + } + if _, err := os.Lstat(privatePointerPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("private Git pointer still exists: %v", err) + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("isolated private pointer remained registered:\n%s", listing) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile isolated private pointer: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing = runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } + command := exec.Command( + "git", + "-C", + repository, + "show-ref", + "--verify", + "--quiet", + "refs/heads/"+prepared.Branch, + ) + if err := command.Run(); !isExitCode(err, 1) { + t.Fatalf("pending branch remains after recovery: %v", err) + } +} + func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( t *testing.T, ) { diff --git a/internal/workspace/prepared_registration_bound.go b/internal/workspace/prepared_registration_bound.go index f6e19c4..54de3c9 100644 --- a/internal/workspace/prepared_registration_bound.go +++ b/internal/workspace/prepared_registration_bound.go @@ -70,20 +70,7 @@ func repairBoundPreparedWorktreeRegistration( err, ) } - oldPointer, err := readBoundRegularFile(gitRoot, "gitdir") - if err != nil { - return true, err - } - if err := validateGitPointerContent( - oldPointer, - "", - filepath.Join(stalePath, ".git"), - ); err != nil { - return true, fmt.Errorf( - "workspace: validate prepared private Git pointer: %w", - err, - ) - } + oldPointer := filepath.ToSlash(filepath.Join(stalePath, ".git")) + "\n" newPointer := filepath.ToSlash(filepath.Join(target.Path, ".git")) + "\n" if err := ensureBoundGitPointer( gitRoot, diff --git a/internal/workspace/prepared_rollback.go b/internal/workspace/prepared_rollback.go index 522d03e..9258f9d 100644 --- a/internal/workspace/prepared_rollback.go +++ b/internal/workspace/prepared_rollback.go @@ -162,21 +162,23 @@ func (m *Manager) repairPromotedPreparedWorktree( } stagingPath := filepath.Join(filepath.Dir(target.Path), stagingName) stale, exists, err := repository.worktreeRegistration(ctx, stagingPath) - if err != nil || !exists { + if err != nil { return false, err } - stagingTarget := target - stagingTarget.Path = stagingPath - if err := repository.verifyPreparedWorktree( - ctx, - stagingTarget, - record.GitDirectory, - stale, - ); err != nil { - return false, fmt.Errorf( - "workspace: verify promoted worktree staging registration: %w", - err, - ) + if exists { + stagingTarget := target + stagingTarget.Path = stagingPath + if err := repository.verifyPreparedWorktree( + ctx, + stagingTarget, + record.GitDirectory, + stale, + ); err != nil { + return false, fmt.Errorf( + "workspace: verify promoted worktree staging registration: %w", + err, + ) + } } bucket, err := m.openManagedBucketRoot(target) From acc414312591c3d02fc3d79a9434a5e101f57511 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 22:33:18 +0800 Subject: [PATCH 048/104] fix: fence uncertain process cleanup --- internal/pty/AGENTS.md | 2 + internal/pty/process_group_unix.go | 9 + internal/session/AGENTS.md | 5 + internal/session/detector.go | 41 ++++- internal/session/managed.go | 30 ++++ internal/session/process_group.go | 81 +++++++++ internal/session/projection.go | 135 +++++++++++++-- internal/session/projection_test.go | 87 ++++++++++ internal/session/session.go | 147 +++++++++++++--- internal/session/session_test.go | 257 +++++++++++++++++++++++++++- internal/session/workspace.go | 17 +- internal/session/workspace_test.go | 36 ++++ 12 files changed, 797 insertions(+), 50 deletions(-) create mode 100644 internal/session/process_group.go diff --git a/internal/pty/AGENTS.md b/internal/pty/AGENTS.md index 803b5e8..d1a0fa0 100644 --- a/internal/pty/AGENTS.md +++ b/internal/pty/AGENTS.md @@ -29,6 +29,8 @@ 完整的 termination grace。 - 进程退出回调通过 `ExitInfo.CleanupErr` 暴露进程组清理失败;直接子进程退出成功 不得掩盖仍存活但当前用户无权终止的后代。 +- `ProcessGroupAlive` 是 session 层解除持久化 cleanup fence 的唯一探测入口;只有 + signal 0 返回 `ESRCH` 才报告不存在,`EPERM` 仍报告存活。 - PTY master 只在直接子进程已回收后关闭;`Close` 幂等,并等待读取、进程退出和 全部回调及 writer 完成。健康的自然退出先排空读取再关闭 master;进程组清理失败时 先关闭 master 打断继承 slave 的不可清理后代,再等待读取结束。两条路径共享唯一的 diff --git a/internal/pty/process_group_unix.go b/internal/pty/process_group_unix.go index 9f47688..eafe67d 100644 --- a/internal/pty/process_group_unix.go +++ b/internal/pty/process_group_unix.go @@ -4,6 +4,7 @@ package pty import ( "errors" + "fmt" "syscall" ) @@ -45,6 +46,14 @@ func processGroupAlive(pid int) (bool, error) { return processGroupAliveWith(unixProcessGroupSignal, pid) } +// ProcessGroupAlive reports whether the process group remains reachable. +func ProcessGroupAlive(pid int) (bool, error) { + if pid <= 0 { + return false, fmt.Errorf("pty: process group PID must be positive") + } + return processGroupAlive(pid) +} + func processGroupAliveWith(signal processGroupSignalFunc, pid int) (bool, error) { err := signal(pid, 0) switch { diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index 8b66a67..3227fab 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -77,6 +77,11 @@ - oneshot 自然成功退出为 `done`;interactive、失败退出和已登记的主动停止为 `stopped`。 - PTY 进程组清理失败优先于自然成功或主动停止原因,必须持久化 error 并进入 `stopped`,不得把仍有不可控后代的会话记录为 `done`。 +- PTY 进程组清理失败还必须把 leader PID 作为 typed + `session_lifecycle(process_group_cleanup_failed)` 与退出终态同批持久化。运行时和恢复 + 投影据此关闭 `Status.Resumable`、手动/启动恢复及 workspace cleanup;操作前只有 + `internal/pty` 以 signal 0 明确确认进程组不存在,才能先持久化 + `process_group_cleanup_completed` 再解除门禁。 - `Close()`:拒绝新 Start → 等待进行中的 Start 和 workspace cleanup → 关闭全部 PTY 并等待回调 → 幂等关闭 recording/terminal/observation actor → 清空运行中会话索引。 - Manager 把同一 `terminationGrace` 传给新建和恢复的 PTY;关闭顺序仍按 Agent ID diff --git a/internal/session/detector.go b/internal/session/detector.go index 4e816a3..8b5a00a 100644 --- a/internal/session/detector.go +++ b/internal/session/detector.go @@ -59,6 +59,7 @@ func (t *systemObservationTimer) Stop() bool { type observationRequest struct { observation detect.Observation + drafts []event.Draft result chan error } @@ -148,6 +149,13 @@ func (a *observationActor) Deliver( } func (a *observationActor) Terminate(observation detect.Observation) error { + return a.terminate(observation, nil) +} + +func (a *observationActor) terminate( + observation detect.Observation, + drafts []event.Draft, +) error { a.admissionMu.Lock() if a.closing { a.admissionMu.Unlock() @@ -158,6 +166,7 @@ func (a *observationActor) Terminate(observation detect.Observation) error { request := observationRequest{ observation: observation, + drafts: append([]event.Draft(nil), drafts...), result: make(chan error, 1), } select { @@ -199,12 +208,18 @@ func (a *observationActor) run() { for { select { case request := <-a.requests: - err := a.handle(request.observation, &timer, &timerC, &timerRef) + err := a.handle( + request.observation, + request.drafts, + &timer, + &timerC, + &timerRef, + ) request.result <- err case firedAt := <-timerC: observation, err := detect.ObserveTimer(timerRef, firedAt) if err == nil { - _ = a.handle(observation, &timer, &timerC, &timerRef) + _ = a.handle(observation, nil, &timer, &timerC, &timerRef) } case <-a.stop: for { @@ -212,6 +227,7 @@ func (a *observationActor) run() { case request := <-a.requests: err := a.handle( request.observation, + request.drafts, &timer, &timerC, &timerRef, @@ -230,6 +246,7 @@ func (a *observationActor) run() { func (a *observationActor) handle( observation detect.Observation, + drafts []event.Draft, timer *observationTimer, timerC *<-chan time.Time, timerRef *detect.TimerRef, @@ -243,6 +260,10 @@ func (a *observationActor) handle( return err } if decision.Duplicate() { + if len(drafts) > 0 { + _, err := a.committer.CommitEvents(context.Background(), drafts) + return err + } return nil } signal, outcome, ok := decision.Signal() @@ -253,18 +274,20 @@ func (a *observationActor) handle( if err != nil { return err } + decisionDrafts := []event.Draft{ + event.NewAgentSignalDraft( + string(a.target.ID()), + string(a.target.ID()), + string(payload), + ), + } + decisionDrafts = append(decisionDrafts, drafts...) _, err = a.committer.CommitDecision( context.Background(), a.managed, a.state, decision, - []event.Draft{ - event.NewAgentSignalDraft( - string(a.target.ID()), - string(a.target.ID()), - string(payload), - ), - }, + decisionDrafts, ) if err != nil { return fmt.Errorf("%w: %v", errObservationCommit, err) diff --git a/internal/session/managed.go b/internal/session/managed.go index 60f0f83..a2fc428 100644 --- a/internal/session/managed.go +++ b/internal/session/managed.go @@ -14,6 +14,9 @@ type managedAgent struct { workspaceMu sync.RWMutex workspace workspaceRuntimeState + + processGroupMu sync.Mutex + processGroupCleanup processGroupCleanupState } type workspaceRuntimeState struct { @@ -23,6 +26,11 @@ type workspaceRuntimeState struct { removed bool } +type processGroupCleanupState struct { + pending bool + pid int +} + func newManagedAgent(target *agent.Agent) *managedAgent { return &managedAgent{agent: target} } @@ -78,6 +86,13 @@ func (m *managedAgent) applyWorkspaceRemoved() { } func (m *managedAgent) shouldResumeOnStart() bool { + if m.processGroupCleanupPending() { + return false + } + return m.hasResumeOnStart() +} + +func (m *managedAgent) hasResumeOnStart() bool { m.workspaceMu.RLock() defer m.workspaceMu.RUnlock() return m.workspace.resumeOnStart && @@ -90,3 +105,18 @@ func (m *managedAgent) consumeResumeOnStart() { m.workspace.resumeOnStart = false m.workspaceMu.Unlock() } + +func (m *managedAgent) setProcessGroupCleanupPending(pid int) { + m.processGroupMu.Lock() + m.processGroupCleanup = processGroupCleanupState{ + pending: true, + pid: pid, + } + m.processGroupMu.Unlock() +} + +func (m *managedAgent) processGroupCleanupPending() bool { + m.processGroupMu.Lock() + defer m.processGroupMu.Unlock() + return m.processGroupCleanup.pending +} diff --git a/internal/session/process_group.go b/internal/session/process_group.go new file mode 100644 index 0000000..8d05f0e --- /dev/null +++ b/internal/session/process_group.go @@ -0,0 +1,81 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Duang777/drove/internal/agent" + "github.com/Duang777/drove/internal/event" +) + +func (m *Manager) resolveProcessGroupCleanup( + ctx context.Context, + id agent.ID, + managed *managedAgent, +) (bool, error) { + if ctx == nil { + ctx = context.Background() + } + managed.processGroupMu.Lock() + defer managed.processGroupMu.Unlock() + + state := managed.processGroupCleanup + if !state.pending { + return false, nil + } + if state.pid <= 0 { + return true, fmt.Errorf( + "%w: agent %q has invalid process group PID %d", + errProcessGroupCleanupPending, + id, + state.pid, + ) + } + alive, err := m.processGroupAlive(state.pid) + if err != nil { + return true, errors.Join( + errProcessGroupCleanupPending, + fmt.Errorf( + "session: inspect process group %d for agent %q: %w", + state.pid, + id, + err, + ), + ) + } + if alive { + return true, nil + } + + payload, err := json.Marshal(processGroupCleanupPayload{ + Version: 1, + PID: state.pid, + }) + if err != nil { + return true, fmt.Errorf( + "session: encode process group cleanup completion: %w", + err, + ) + } + receipt, commitErr := m.committer.CommitEvents( + ctx, + []event.Draft{event.NewSessionLifecycleDraft( + string(id), + string(id), + processGroupCleanupCompleted, + string(payload), + )}, + ) + if receipt.Durable { + managed.processGroupCleanup = processGroupCleanupState{} + } + if commitErr != nil { + return !receipt.Durable, fmt.Errorf( + "session: persist process group cleanup completion: %w", + commitErr, + ) + } + return false, nil +} diff --git a/internal/session/projection.go b/internal/session/projection.go index 46dcd83..7611855 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -59,6 +59,7 @@ type sessionDraft struct { workingDir string workspace *workspaceMetadata workspaceRemoved bool + processGroupCleanupPID int restartStopped bool startupResumePending bool hasCreated bool @@ -66,14 +67,15 @@ type sessionDraft struct { } type recoveryPlan struct { - Snapshots []agent.RestoreSnapshot - VendorSessionRefs map[string]string - WorkingDirs map[string]string - Workspaces map[string]workspaceMetadata - ResumeOnStart map[string]bool - WorkspaceRemoved map[string]bool - Reconciliation []store.EventRow - Report RecoveryReport + Snapshots []agent.RestoreSnapshot + VendorSessionRefs map[string]string + WorkingDirs map[string]string + Workspaces map[string]workspaceMetadata + ResumeOnStart map[string]bool + WorkspaceRemoved map[string]bool + ProcessGroupCleanup map[string]int + Reconciliation []store.EventRow + Report RecoveryReport } func newRecoveryProjector() *recoveryProjector { @@ -170,6 +172,10 @@ func (p *recoveryProjector) applyLifecycle(row store.EventRow) error { return p.applyStartupResumeCompleted(row) case workspaceRemovedReason: return p.applyWorkspaceRemoved(row) + case processGroupCleanupFailed: + return p.applyProcessGroupCleanupFailed(row) + case processGroupCleanupCompleted: + return p.applyProcessGroupCleanupCompleted(row) default: return projectionError(row, "unknown lifecycle reason %q", row.Reason) } @@ -328,6 +334,12 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { if draft.workspaceRemoved { return projectionError(row, "duplicate workspace removal") } + if draft.processGroupCleanupPID != 0 { + return projectionError( + row, + "workspace removal precedes process group cleanup completion", + ) + } var payload workspaceRemovedPayload if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { return projectionWrapError(row, "decode workspace removal", err) @@ -345,6 +357,86 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { return nil } +func (p *recoveryProjector) applyProcessGroupCleanupFailed( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "process group cleanup failure has no session history", + ) + } + if draft.workspaceRemoved { + return projectionError( + row, + "process group cleanup failure follows workspace removal", + ) + } + if draft.processGroupCleanupPID != 0 { + return projectionError(row, "duplicate process group cleanup failure") + } + payload, err := decodeProcessGroupCleanupPayload(row) + if err != nil { + return err + } + draft.processGroupCleanupPID = payload.PID + return nil +} + +func (p *recoveryProjector) applyProcessGroupCleanupCompleted( + row store.EventRow, +) error { + draft := p.draft(row) + if draft.processGroupCleanupPID == 0 { + return projectionError( + row, + "process group cleanup completion has no pending failure", + ) + } + payload, err := decodeProcessGroupCleanupPayload(row) + if err != nil { + return err + } + if payload.PID != draft.processGroupCleanupPID { + return projectionError( + row, + "process group cleanup completion PID %d does not match pending PID %d", + payload.PID, + draft.processGroupCleanupPID, + ) + } + draft.processGroupCleanupPID = 0 + return nil +} + +func decodeProcessGroupCleanupPayload( + row store.EventRow, +) (processGroupCleanupPayload, error) { + var payload processGroupCleanupPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return processGroupCleanupPayload{}, projectionWrapError( + row, + "decode process group cleanup lifecycle", + err, + ) + } + if payload.Version != 1 { + return processGroupCleanupPayload{}, projectionError( + row, + "unsupported process group cleanup version %d", + payload.Version, + ) + } + if payload.PID <= 0 { + return processGroupCleanupPayload{}, projectionError( + row, + "process group cleanup PID must be positive", + ) + } + return payload, nil +} + func (p *recoveryProjector) applyState(row store.EventRow) error { if row.SessionID == "" { return projectionError(row, "state event has empty session ID") @@ -362,6 +454,13 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { return projectionError(row, "invalid to state %q", row.To) } isResume := from == agent.StateStopped && to == agent.StateStarting + draft := p.draft(row) + if isResume && draft.processGroupCleanupPID != 0 { + return projectionError( + row, + "stopped -> starting precedes process group cleanup completion", + ) + } if isResume && (p.lastResumeSeq+1 != row.Seq || p.lastResumeID != row.SessionID) { return projectionError( @@ -380,7 +479,6 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { p.report.UnknownStateEvidenceVersions++ } - draft := p.draft(row) if !draft.hasState { draft.state = from } else if draft.state != from { @@ -707,13 +805,14 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) }) plan := recoveryPlan{ - Snapshots: make([]agent.RestoreSnapshot, 0, len(drafts)), - VendorSessionRefs: make(map[string]string), - WorkingDirs: make(map[string]string), - Workspaces: make(map[string]workspaceMetadata), - ResumeOnStart: make(map[string]bool), - WorkspaceRemoved: make(map[string]bool), - Report: p.report, + Snapshots: make([]agent.RestoreSnapshot, 0, len(drafts)), + VendorSessionRefs: make(map[string]string), + WorkingDirs: make(map[string]string), + Workspaces: make(map[string]workspaceMetadata), + ResumeOnStart: make(map[string]bool), + WorkspaceRemoved: make(map[string]bool), + ProcessGroupCleanup: make(map[string]int), + Report: p.report, } nextSeq := p.lastSeq for _, draft := range drafts { @@ -748,6 +847,10 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) if draft.workspace != nil { plan.Workspaces[draft.id] = *draft.workspace } + if draft.processGroupCleanupPID != 0 { + plan.ProcessGroupCleanup[draft.id] = + draft.processGroupCleanupPID + } lastError := draft.lastError updatedAt := draft.updatedAt if state != agent.StateStopped { diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 64023ed..96bc9da 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -322,6 +322,93 @@ func TestRecoveryProjectorRestoresLatestVendorSessionReference(t *testing.T) { } } +func TestRecoveryProjectorRestoresProcessGroupCleanupFence(t *testing.T) { + base := time.Date(2026, time.October, 6, 15, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 5, + Timestamp: base.Add(4 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "stopped", + }, + } + project := func(t *testing.T, rows []store.EventRow) recoveryPlan { + t.Helper() + projector := newRecoveryProjector() + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + return plan + } + + pending := project(t, rows) + if got := pending.ProcessGroupCleanup["agent-1"]; got != 4242 { + t.Fatalf("restored process group PID = %d, want 4242", got) + } + + rows = append(rows, store.EventRow{ + Seq: 6, + Timestamp: base.Add(5 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupCompleted, + Payload: `{"version":1,"pid":4242}`, + }) + completed := project(t, rows) + if _, exists := completed.ProcessGroupCleanup["agent-1"]; exists { + t.Fatalf( + "completed process group cleanup remained pending: %+v", + completed.ProcessGroupCleanup, + ) + } +} + func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( t *testing.T, ) { diff --git a/internal/session/session.go b/internal/session/session.go index 19aedb6..8e3a611 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -93,12 +93,19 @@ const ( initialTerminalRows = 40 initialTerminalColumns = 120 startupResumeCompletedReason = "startup_resume_completed" + processGroupCleanupFailed = "process_group_cleanup_failed" + processGroupCleanupCompleted = "process_group_cleanup_completed" ) type startupResumeCompletedPayload struct { Version int `json:"version"` } +type processGroupCleanupPayload struct { + Version int `json:"version"` + PID int `json:"pid"` +} + var ( // ErrUnknownAgent 表示目标 Agent 不存在。 ErrUnknownAgent = errors.New("session: unknown agent") @@ -121,7 +128,10 @@ var ( // ErrInputAudit 表示输入已送达,但审计事件持久化失败。 ErrInputAudit = errors.New("session: input delivered but audit failed") // ErrResumeConflict means the Agent cannot begin a native resume now. - ErrResumeConflict = errors.New("session: agent cannot be resumed") + ErrResumeConflict = errors.New("session: agent cannot be resumed") + errProcessGroupCleanupPending = errors.New( + "session: process group cleanup remains unresolved", + ) ) type stopCause uint8 @@ -178,21 +188,22 @@ type Manager struct { committer *committer archive *recording.Archive - signalOrigin string - originConfigured bool - detectConfig detect.Config - clock observationClock - newCredential signalCredentialSource - startPTY func(pty.Config) (launchedSession, error) - terminationGrace time.Duration - injectionEnabled bool - injectionDataDir string - injectionRelay string - injectionModes map[string]agent.SignalInjectionMode - injectionFS signalInjectionFS - signalSocketPath string - workspaces workspaceLifecycle - workspaceErr error + signalOrigin string + originConfigured bool + detectConfig detect.Config + clock observationClock + newCredential signalCredentialSource + startPTY func(pty.Config) (launchedSession, error) + processGroupAlive func(int) (bool, error) + terminationGrace time.Duration + injectionEnabled bool + injectionDataDir string + injectionRelay string + injectionModes map[string]agent.SignalInjectionMode + injectionFS signalInjectionFS + signalSocketPath string + workspaces workspaceLifecycle + workspaceErr error mu sync.RWMutex agents map[agent.ID]*managedAgent @@ -252,7 +263,8 @@ func NewManager( startPTY: func(config pty.Config) (launchedSession, error) { return pty.Start(config) }, - injectionFS: defaultSignalInjectionFS(), + processGroupAlive: pty.ProcessGroupAlive, + injectionFS: defaultSignalInjectionFS(), } for _, option := range options { option(manager) @@ -291,6 +303,9 @@ func Bootstrap( resumeOnStart: plan.ResumeOnStart[string(snapshot.ID)], removed: plan.WorkspaceRemoved[string(snapshot.ID)], }) + if pid := plan.ProcessGroupCleanup[string(snapshot.ID)]; pid > 0 { + managed.setProcessGroupCleanupPending(pid) + } restoredAgents[snapshot.ID] = managed } @@ -687,7 +702,7 @@ func (m *Manager) Resume(ctx context.Context, id agent.ID) (*Status, error) { } defer m.endStart() - managed, entry, ref, err := m.reserveResume(id) + managed, entry, ref, err := m.reserveResume(ctx, id) if err != nil { return nil, err } @@ -806,7 +821,7 @@ func (m *Manager) resumeReserved( }); err != nil { return nil, err } - if managed.shouldResumeOnStart() { + if managed.hasResumeOnStart() { if err := m.completeStartupResume(id, managed); err != nil { return nil, err } @@ -829,8 +844,24 @@ func closePreparedRuntime(running *runningSession) error { } func (m *Manager) reserveResume( + ctx context.Context, id agent.ID, ) (*managedAgent, adapter.Entry, string, error) { + managed, ok := m.managed(id) + if !ok { + return nil, adapter.Entry{}, "", fmt.Errorf("%w: %q", ErrUnknownAgent, id) + } + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + return nil, adapter.Entry{}, "", errors.Join(ErrResumeConflict, err) + } + if pending { + return nil, adapter.Entry{}, "", fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) + } m.mu.Lock() defer m.mu.Unlock() return m.reserveResumeLocked(id) @@ -879,6 +910,13 @@ func (m *Manager) reserveResumeLocked( id, ) } + if managed.processGroupCleanupPending() { + return nil, adapter.Entry{}, "", fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) + } if managed.agent.State() != agent.StateStopped { return nil, adapter.Entry{}, "", fmt.Errorf( "%w: agent %q is not stopped", @@ -1056,6 +1094,7 @@ func (m *Manager) Status(id agent.ID) (*Status, error) { !cleaning && !workspaceState.removalPending && !workspaceState.removed && + !managed.processGroupCleanupPending() && ref != "" && exactVendor && entry.SupportsResume() @@ -1122,7 +1161,7 @@ func (m *Manager) ResumeOnStart(ctx context.Context) []StartupResumeResult { m.mu.RLock() candidates := make([]candidate, 0) for id, managed := range m.agents { - if !managed.shouldResumeOnStart() { + if !managed.hasResumeOnStart() { continue } candidates = append(candidates, candidate{ @@ -1170,7 +1209,7 @@ func (m *Manager) resumeOnStart( m.mu.Lock() managed, exists := m.agents[id] - if !exists || !managed.shouldResumeOnStart() { + if !exists || !managed.hasResumeOnStart() { m.mu.Unlock() m.endStart() return false, nil @@ -1189,6 +1228,29 @@ func (m *Manager) resumeOnStart( ) } } + m.mu.Unlock() + + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + m.endStart() + return true, errors.Join(ErrResumeConflict, err) + } + if pending { + m.endStart() + return true, fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) + } + + m.mu.Lock() + current, exists := m.agents[id] + if !exists || current != managed || !managed.shouldResumeOnStart() { + m.mu.Unlock() + m.endStart() + return false, nil + } managed, entry, ref, err := m.reserveResumeLocked(id) m.mu.Unlock() if err != nil { @@ -1538,6 +1600,19 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo } defer m.markExitHandled(id, running) defer m.finalizeSignalInjection(id, running) + cleanupPending := info.CleanupErr != nil + cleanupPID := info.PID + if cleanupPending && cleanupPID <= 0 && running.process != nil { + cleanupPID = running.process.PID() + } + defer func() { + if !cleanupPending { + return + } + if managed, exists := m.managed(id); exists { + managed.setProcessGroupCleanupPending(cleanupPID) + } + }() if running.observer == nil { return } @@ -1546,6 +1621,7 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo exitKind := detect.ExitFailure reason := "" errorMessage := "" + var lifecycleDrafts []event.Draft switch { case info.CleanupErr != nil: exitErr := info.CleanupErr @@ -1553,6 +1629,33 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo exitErr = errors.Join(info.Err, info.CleanupErr) } errorMessage = exitErr.Error() + if cleanupPID <= 0 { + m.committer.Fail(fmt.Errorf( + "session: process group cleanup failed for agent %q without a valid PID", + id, + )) + break + } + payload, err := json.Marshal(processGroupCleanupPayload{ + Version: 1, + PID: cleanupPID, + }) + if err != nil { + m.committer.Fail(fmt.Errorf( + "session: encode process group cleanup failure: %w", + err, + )) + break + } + lifecycleDrafts = append( + lifecycleDrafts, + event.NewSessionLifecycleDraft( + string(id), + string(id), + processGroupCleanupFailed, + string(payload), + ), + ) case cause == stopCauseUser: exitKind = detect.ExitStopped reason = "user stop" @@ -1580,7 +1683,7 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo if err != nil { return } - _ = running.observer.Terminate(observation) + _ = running.observer.terminate(observation, lifecycleDrafts) } func processObservation( diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a570b96..666c5e4 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -206,6 +206,121 @@ func TestBootstrapRestoresLegacySessionIdempotently(t *testing.T) { } } +func TestBootstrapRestoresProcessGroupCleanupFence(t *testing.T) { + st := newTestStore(t) + base := time.Date(2026, time.October, 6, 15, 30, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":2,"name":"agent","vendor":"claude",` + + `"mode":"interactive","hook_policy":"off"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-06T15:30:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 5, + Timestamp: base.Add(4 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 6, + Timestamp: base.Add(5 * time.Second), + Type: string(event.TypeError), + SessionID: "agent-1", + AgentID: "agent-1", + Payload: "process group remains", + }, + { + Seq: 7, + Timestamp: base.Add(6 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "stopped", + }, + } + if _, err := st.AppendEvents(context.Background(), 0, rows); err != nil { + t.Fatalf("seed cleanup failure history: %v", err) + } + + result, err := Bootstrap(context.Background(), adapter.NewRegistry(), st) + if err != nil { + t.Fatalf("bootstrap cleanup failure: %v", err) + } + defer result.Manager.Close() + result.Manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + status, err := result.Manager.Status("agent-1") + if err != nil { + t.Fatalf("restored status: %v", err) + } + if status.State != agent.StateStopped || status.Resumable { + t.Fatalf("restored status = %+v", status) + } + if _, err := result.Manager.Resume( + context.Background(), + "agent-1", + ); !errors.Is(err, ErrResumeConflict) { + t.Fatalf("resume restored cleanup fence = %v", err) + } + + result.Manager.processGroupAlive = func(int) (bool, error) { + return false, nil + } + result.Manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + resumed, err := result.Manager.Resume(context.Background(), "agent-1") + if err != nil { + t.Fatalf("resume after restored process group disappeared: %v", err) + } + if resumed.State != agent.StateWorking { + t.Fatalf("resumed status = %+v", resumed) + } +} + func TestBootstrapAcceptsPreUpgradeUnicodeVendorSessionID(t *testing.T) { st := newTestStore(t) base := time.Date(2026, time.October, 3, 6, 0, 0, 0, time.UTC) @@ -2009,6 +2124,7 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { cleanupErr := errors.New("process group remains after permission error") manager.onExit(id, running, pty.ExitInfo{ + PID: 4242, Code: test.code, Err: test.processErr, CleanupErr: cleanupErr, @@ -2040,8 +2156,20 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { t.Fatalf("replay: %v", err) } errorIndex := -1 + cleanupIndex := -1 stoppedIndex := -1 for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == processGroupCleanupFailed { + cleanupIndex = index + var payload processGroupCleanupPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + t.Fatalf("decode cleanup failure payload: %v", err) + } + if payload.Version != 1 || payload.PID != 4242 { + t.Fatalf("cleanup failure payload = %+v", payload) + } + } if row.Type == string(event.TypeError) && strings.Contains(row.Payload, cleanupErr.Error()) { errorIndex = index @@ -2051,9 +2179,14 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { stoppedIndex = index } } - if errorIndex < 0 || stoppedIndex < 0 || errorIndex >= stoppedIndex { + if cleanupIndex < 0 || + errorIndex < 0 || + stoppedIndex < 0 || + cleanupIndex >= errorIndex || + errorIndex >= stoppedIndex { t.Fatalf( - "error index = %d, stopped index = %d, rows = %+v", + "cleanup index = %d, error index = %d, stopped index = %d, rows = %+v", + cleanupIndex, errorIndex, stoppedIndex, rows, @@ -2063,6 +2196,126 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { } } +func TestProcessGroupCleanupFenceBlocksAndThenAllowsResume(t *testing.T) { + manager, _ := newTestManager(t) + id := agent.ID("cleanup-resume") + target := agent.New( + id, + agent.WithName(string(id)), + agent.WithVendor("claude"), + agent.WithRunMode(agent.RunModeInteractive), + agent.WithHookPolicy(agent.HooksOff), + ) + managed := newManagedAgent(target) + managed.setVendorSessionReference("vendor-ref") + manager.mu.Lock() + manager.agents[id] = managed + manager.mu.Unlock() + commitTestState(t, manager, target, agent.StateStarting, "test start") + commitTestState(t, manager, target, agent.StateWorking, "test working") + running := attachTestRuntime(t, manager, target, manager.reg.For("claude")) + + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + manager.onExit(id, running, pty.ExitInfo{ + PID: 4242, + CleanupErr: errors.New("process group remains"), + }) + manager.markOutputDrained(id, running) + + status, err := manager.Status(id) + if err != nil { + t.Fatalf("status after cleanup failure: %v", err) + } + if status.State != agent.StateStopped || status.Resumable { + t.Fatalf("status after cleanup failure = %+v", status) + } + if _, err := manager.Resume(context.Background(), id); !errors.Is( + err, + ErrResumeConflict, + ) { + t.Fatalf("resume while process group is alive = %v", err) + } + + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return false, nil + } + manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + resumed, err := manager.Resume(context.Background(), id) + if err != nil { + t.Fatalf("resume after process group disappeared: %v", err) + } + if resumed.State != agent.StateWorking || resumed.Resumable { + t.Fatalf("resumed status = %+v", resumed) + } + + rows, err := manager.Replay(string(id)) + if err != nil { + t.Fatalf("replay cleanup fence: %v", err) + } + completedIndex := -1 + resumedIndex := -1 + for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == processGroupCleanupCompleted { + completedIndex = index + } + if row.Type == string(event.TypeAgentResumed) { + resumedIndex = index + } + } + if completedIndex < 0 || + resumedIndex < 0 || + completedIndex >= resumedIndex { + t.Fatalf( + "cleanup completion index = %d, resume index = %d, rows = %+v", + completedIndex, + resumedIndex, + rows, + ) + } +} + +func TestStartupResumeReportsUnresolvedProcessGroupCleanup(t *testing.T) { + manager, _ := newTestManager(t) + managed := addStoppedAgent( + t, + manager, + "cleanup-startup-resume", + "claude", + "vendor-ref", + ) + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + managed.setProcessGroupCleanupPending(4242) + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + + if managed.shouldResumeOnStart() { + t.Fatal("cleanup fence did not block startup resume eligibility") + } + results := manager.ResumeOnStart(context.Background()) + if len(results) != 1 || + results[0].AgentID != managed.agent.ID() || + !errors.Is(results[0].Err, ErrResumeConflict) { + t.Fatalf("startup resume results = %+v", results) + } +} + func TestUserStopOneshotEndsStoppedWithoutProcessError(t *testing.T) { manager, _ := newTestManager(t) diff --git a/internal/session/workspace.go b/internal/session/workspace.go index b7c3aa4..c9a5c48 100644 --- a/internal/session/workspace.go +++ b/internal/session/workspace.go @@ -140,7 +140,7 @@ func (m *Manager) CleanupWorkspace( return workspace.Workspace{}, ErrWorkspaceUnavailable } agentID := agent.ID(id) - completion, err := m.reserveWorkspaceCleanup(agentID) + completion, err := m.reserveWorkspaceCleanup(ctx, agentID) if err != nil { return workspace.Workspace{}, err } @@ -234,8 +234,22 @@ func mapWorkspaceRemovalError(err error) error { } func (m *Manager) reserveWorkspaceCleanup( + ctx context.Context, id agent.ID, ) (chan struct{}, error) { + if managed, ok := m.managed(id); ok { + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + return nil, errors.Join(ErrWorkspaceInUse, err) + } + if pending { + return nil, fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrWorkspaceInUse, + id, + ) + } + } m.mu.Lock() defer m.mu.Unlock() if m.closed { @@ -260,6 +274,7 @@ func (m *Manager) reserveWorkspaceCleanup( _, resuming := m.resuming[id] state := managed.agent.State() if attached || resuming || + managed.processGroupCleanupPending() || (state != agent.StateDone && state != agent.StateStopped) { return nil, fmt.Errorf( "%w: agent %q is %s", diff --git a/internal/session/workspace_test.go b/internal/session/workspace_test.go index dcbea4b..c8b0d18 100644 --- a/internal/session/workspace_test.go +++ b/internal/session/workspace_test.go @@ -519,6 +519,42 @@ func TestCleanupWorkspacePreventsConcurrentResume(t *testing.T) { } } +func TestCleanupWorkspaceRejectsUnresolvedProcessGroupCleanup(t *testing.T) { + manager, _ := newTestManager(t) + id := agent.ID("11111111-1111-4111-8111-111111111111") + managed := addStoppedAgent(t, manager, id, "claude", "vendor-session") + managed.setProcessGroupCleanupPending(4242) + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + workspaces := &fakeWorkspaceLifecycle{} + manager.workspaces = workspaces + + if _, err := manager.CleanupWorkspace( + context.Background(), + string(id), + true, + ); !errors.Is(err, ErrWorkspaceInUse) { + t.Fatalf("cleanup with unresolved process group = %v", err) + } + if workspaces.cleanupCount != 0 { + t.Fatalf( + "unresolved process group reached workspace cleanup %d times", + workspaces.cleanupCount, + ) + } + status, err := manager.Status(id) + if err != nil { + t.Fatalf("status with unresolved process group: %v", err) + } + if status.Resumable { + t.Fatalf("status with unresolved process group = %+v", status) + } +} + func TestCleanupWorkspaceAcceptsCompletedRemovalWithCommandError(t *testing.T) { manager, _ := newTestManager(t) id := "11111111-1111-4111-8111-111111111111" From a2762cb37a7f270726d85edd4e793809c4c9c80b Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 22:58:40 +0800 Subject: [PATCH 049/104] fix: clean failed BSD worktree registration --- .../workspace/prepared_registration_bsd.go | 32 +++++--- .../prepared_registration_bsd_test.go | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/internal/workspace/prepared_registration_bsd.go b/internal/workspace/prepared_registration_bsd.go index 046cf3f..66f8997 100644 --- a/internal/workspace/prepared_registration_bsd.go +++ b/internal/workspace/prepared_registration_bsd.go @@ -103,19 +103,25 @@ func finalizePreparedWorktreeAdd( "", ) } + finalized := false + defer func() { + if !finalized { + result = errors.Join( + result, + cleanupBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + worktreePath, + ), + ) + } + }() if err := errors.Join( repository.verifyBinding(ctx), verifyRealPathRoot(worktreePath, worktreeRoot), ); err != nil { - return errors.Join( - err, - cleanupBSDPreparedWorktreeStage( - ctx, - repository, - stageName, - "", - ), - ) + return err } privatePath := filepath.Join( repository.commonPath, @@ -221,10 +227,14 @@ func finalizePreparedWorktreeAdd( err, ) } - return errors.Join( + if err := errors.Join( repository.verifyBinding(ctx), verifyRealPathRoot(worktreePath, worktreeRoot), - ) + ); err != nil { + return err + } + finalized = true + return nil } func repairBoundPreparedWorktreeRegistration( diff --git a/internal/workspace/prepared_registration_bsd_test.go b/internal/workspace/prepared_registration_bsd_test.go index 14af372..6d6d09f 100644 --- a/internal/workspace/prepared_registration_bsd_test.go +++ b/internal/workspace/prepared_registration_bsd_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -170,6 +171,84 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestBSDPrepareCleansRegistrationWhenFinalizationFails(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + stageFile := filepath.Join(parent, "common-stage") + wrapper := filepath.Join(parent, "git-wrapper-finalization-failure") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + stage=${target#./} + printf '%s/%s' "$PWD" "$stage" > "$DROVE_TEST_STAGE" || exit 90 + mkdir "$PWD/worktrees/$stage-unexpected" || exit 91 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_STAGE", stageFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after finalization validation failed") + } + + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read internal stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + if _, err := os.Lstat(stagePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal common-root stage remains: %v", err) + } + privatePath := filepath.Join( + repository, + ".git", + "worktrees", + filepath.Base(stagePath), + ) + if _, err := os.Lstat(privatePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("private Git registration remains: %v", err) + } + if output := runGit( + t, + repository, + "worktree", + "list", + "--porcelain", + ); strings.Contains(output, stagePath) { + t.Fatalf("failed worktree remains registered:\n%s", output) + } +} + func TestEnsureBoundGitPointerRecoversTemporary(t *testing.T) { path := t.TempDir() root, err := os.OpenRoot(path) From 6823764b748215537384bd91febde72d33c68f7d Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 23:04:44 +0800 Subject: [PATCH 050/104] fix: bind acknowledgement record deletion --- internal/workspace/record.go | 32 +++++---- .../workspace/record_removal_security_test.go | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/internal/workspace/record.go b/internal/workspace/record.go index ab6e57a..2282362 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -987,11 +987,8 @@ func (m *Manager) removeAcknowledgedWorkspaceRecordWithHooks( if err != nil { return fmt.Errorf("workspace: open removal record %q: %w", recordPath, err) } - fileOpen := true defer func() { - if fileOpen { - result = errors.Join(result, file.Close()) - } + result = errors.Join(result, file.Close()) }() opened, err := m.verifyRemovalRecordFile( file, @@ -1058,14 +1055,6 @@ func (m *Manager) removeAcknowledgedWorkspaceRecordWithHooks( restoreErr, ) } - if err := file.Close(); err != nil { - return fmt.Errorf( - "workspace: close quarantined removal record %q: %w", - recordPath, - err, - ) - } - fileOpen = false if afterQuarantine != nil { if err := afterQuarantine(); err != nil { return fmt.Errorf( @@ -1074,11 +1063,12 @@ func (m *Manager) removeAcknowledgedWorkspaceRecordWithHooks( ) } } - return m.removeRecordAcknowledgementQuarantine( + return m.removeRecordAcknowledgementQuarantineWithFile( bucket, quarantineName, recordPath, removal, + file, ) } @@ -1468,6 +1458,22 @@ func (m *Manager) removeRecordAcknowledgementQuarantine( defer func() { result = errors.Join(result, file.Close()) }() + return m.removeRecordAcknowledgementQuarantineWithFile( + bucket, + name, + recordPath, + removal, + file, + ) +} + +func (m *Manager) removeRecordAcknowledgementQuarantineWithFile( + bucket *os.Root, + name string, + recordPath string, + removal Removal, + file *os.File, +) error { deleteName := recordAcknowledgementPrefix( removal.Workspace.AgentID, removal.operationID, diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go index 13ed61e..8e53567 100644 --- a/internal/workspace/record_removal_security_test.go +++ b/internal/workspace/record_removal_security_test.go @@ -1,8 +1,11 @@ package workspace import ( + "context" + "errors" "os" "path/filepath" + "strings" "testing" ) @@ -113,3 +116,70 @@ func TestRemoveWorkspaceRecordPreservesReplacementAfterValidation(t *testing.T) t.Fatal("original sidecar changed") } } + +func TestAcknowledgedRecordRemovalPreservesQuarantineReplacement(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + recordPath := workspaceRecordPath(prepared.Path) + recordContents, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read removal record: %v", err) + } + prefix := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + var quarantinePath, originalPath string + err = manager.removeAcknowledgedWorkspaceRecordAfterQuarantine( + result.Removal, + func() error { + entries, err := os.ReadDir(filepath.Dir(recordPath)) + if err != nil { + return err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + quarantinePath = filepath.Join( + filepath.Dir(recordPath), + entry.Name(), + ) + break + } + } + if quarantinePath == "" { + return errors.New("acknowledgement quarantine is missing") + } + originalPath = quarantinePath + ".original" + if err := os.Rename(quarantinePath, originalPath); err != nil { + return err + } + return os.WriteFile(quarantinePath, recordContents, 0o600) + }, + ) + if err == nil { + t.Fatal("acknowledgement removed a replacement quarantine") + } + assertFileContents(t, originalPath, string(recordContents)) + assertFileContents(t, quarantinePath, string(recordContents)) +} From e64cbf33caa072536eb496231d828e59977b8f1e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 23:25:05 +0800 Subject: [PATCH 051/104] fix: cancel CLI on SIGTERM --- cmd/drove/main.go | 11 ++++++++++- cmd/drove/main_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/drove/main.go b/cmd/drove/main.go index df0d8a2..711ca92 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -16,6 +16,7 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "time" "unicode/utf8" @@ -59,7 +60,7 @@ func main() { // CLI 静默日志,避免污染输出。 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := newRootContext() defer stop() root := newRootCmd() root.SetContext(ctx) @@ -69,6 +70,14 @@ func main() { } } +func newRootContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) +} + func executeRoot(root *cobra.Command, args []string) error { root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index 34ee810..0069608 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -11,7 +11,9 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "syscall" "testing" "time" "unicode/utf8" @@ -32,6 +34,29 @@ import ( "github.com/Duang777/drove/internal/workspace" ) +func TestRootContextCancelsOnSIGTERM(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not deliver POSIX SIGTERM") + } + ctx, stop := newRootContext() + defer stop() + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("find test process: %v", err) + } + if err := process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("send SIGTERM: %v", err) + } + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("root context error = %v, want context canceled", ctx.Err()) + } + case <-time.After(time.Second): + t.Fatal("SIGTERM did not cancel the root context") + } +} + func TestSessionStartRequestMapsRunMode(t *testing.T) { tests := []struct { name string From 8527edd7226576ff330503dfa37e467b21233cb3 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 23:30:09 +0800 Subject: [PATCH 052/104] fix: serialize startup resume completion --- internal/session/managed.go | 25 ++++++++++++- internal/session/session.go | 64 +++++++++++++++++++++----------- internal/session/session_test.go | 43 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/internal/session/managed.go b/internal/session/managed.go index a2fc428..70c1df8 100644 --- a/internal/session/managed.go +++ b/internal/session/managed.go @@ -100,10 +100,31 @@ func (m *managedAgent) hasResumeOnStart() bool { !m.workspace.removed } -func (m *managedAgent) consumeResumeOnStart() { +func (m *managedAgent) completeResumeOnStart( + commit func() (commitReceipt, error), +) (bool, error) { m.workspaceMu.Lock() + defer m.workspaceMu.Unlock() + if !m.workspace.resumeOnStart || + m.workspace.removalPending || + m.workspace.removed { + return false, nil + } + receipt, err := commit() + if receipt.Durable { + m.workspace.resumeOnStart = false + } + return true, err +} + +func (m *managedAgent) cancelResumeOnStartAfter(commit func() error) error { + m.workspaceMu.Lock() + defer m.workspaceMu.Unlock() + if err := commit(); err != nil { + return err + } m.workspace.resumeOnStart = false - m.workspaceMu.Unlock() + return nil } func (m *managedAgent) setProcessGroupCleanupPending(pid int) { diff --git a/internal/session/session.go b/internal/session/session.go index 8e3a611..f978032 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -702,11 +702,11 @@ func (m *Manager) Resume(ctx context.Context, id agent.ID) (*Status, error) { } defer m.endStart() - managed, entry, ref, err := m.reserveResume(ctx, id) + managed, entry, ref, startupResume, err := m.reserveResume(ctx, id) if err != nil { return nil, err } - return m.resumeReserved(ctx, id, managed, entry, ref) + return m.resumeReserved(ctx, id, managed, entry, ref, startupResume) } func (m *Manager) resumeReserved( @@ -715,6 +715,7 @@ func (m *Manager) resumeReserved( managed *managedAgent, entry adapter.Entry, ref string, + startupResume bool, ) (*Status, error) { defer m.releaseResume(id) @@ -821,7 +822,7 @@ func (m *Manager) resumeReserved( }); err != nil { return nil, err } - if managed.hasResumeOnStart() { + if startupResume { if err := m.completeStartupResume(id, managed); err != nil { return nil, err } @@ -846,17 +847,24 @@ func closePreparedRuntime(running *runningSession) error { func (m *Manager) reserveResume( ctx context.Context, id agent.ID, -) (*managedAgent, adapter.Entry, string, error) { +) (*managedAgent, adapter.Entry, string, bool, error) { managed, ok := m.managed(id) if !ok { - return nil, adapter.Entry{}, "", fmt.Errorf("%w: %q", ErrUnknownAgent, id) + return nil, adapter.Entry{}, "", false, fmt.Errorf( + "%w: %q", + ErrUnknownAgent, + id, + ) } pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) if err != nil { - return nil, adapter.Entry{}, "", errors.Join(ErrResumeConflict, err) + return nil, adapter.Entry{}, "", false, errors.Join( + ErrResumeConflict, + err, + ) } if pending { - return nil, adapter.Entry{}, "", fmt.Errorf( + return nil, adapter.Entry{}, "", false, fmt.Errorf( "%w: agent %q process group cleanup remains unresolved", ErrResumeConflict, id, @@ -864,7 +872,11 @@ func (m *Manager) reserveResume( } m.mu.Lock() defer m.mu.Unlock() - return m.reserveResumeLocked(id) + managed, entry, ref, err := m.reserveResumeLocked(id) + if err != nil { + return nil, adapter.Entry{}, "", false, err + } + return managed, entry, ref, managed.hasResumeOnStart(), nil } func (m *Manager) reserveResumeLocked( @@ -1258,7 +1270,7 @@ func (m *Manager) resumeOnStart( return false, err } - _, err = m.resumeReserved(ctx, id, managed, entry, ref) + _, err = m.resumeReserved(ctx, id, managed, entry, ref, true) if err != nil { m.endStart() return true, err @@ -1279,18 +1291,19 @@ func (m *Manager) completeStartupResume( err, ) } - receipt, commitErr := m.committer.CommitEvents( - context.Background(), - []event.Draft{event.NewSessionLifecycleDraft( - string(id), - string(id), - startupResumeCompletedReason, - string(payload), - )}, + _, commitErr := managed.completeResumeOnStart( + func() (commitReceipt, error) { + return m.committer.CommitEvents( + context.Background(), + []event.Draft{event.NewSessionLifecycleDraft( + string(id), + string(id), + startupResumeCompletedReason, + string(payload), + )}, + ) + }, ) - if receipt.Durable { - managed.consumeResumeOnStart() - } if commitErr != nil { return fmt.Errorf( "session: persist startup resume completion: %w", @@ -1683,7 +1696,16 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo if err != nil { return } - _ = running.observer.terminate(observation, lifecycleDrafts) + terminate := func() error { + return running.observer.terminate(observation, lifecycleDrafts) + } + if cause == stopCauseUser && info.CleanupErr == nil { + if managed, exists := m.managed(id); exists { + _ = managed.cancelResumeOnStartAfter(terminate) + return + } + } + _ = terminate() } func processObservation( diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 666c5e4..8b2d56c 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1395,6 +1395,49 @@ func TestManualResumeCompletesStartupResumeIntent(t *testing.T) { } } +func TestUserStopConsumesLiveStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + var started pty.Config + manager.startPTY = func(config pty.Config) (launchedSession, error) { + started = config + return &fakeProcessSession{}, nil + } + if _, err := manager.Resume( + context.Background(), + managed.agent.ID(), + ); err != nil { + t.Fatalf("resume agent: %v", err) + } + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + manager.mu.RLock() + running := manager.sessions[managed.agent.ID()] + manager.mu.RUnlock() + if running == nil { + t.Fatal("resumed session is missing") + } + manager.requestStop(managed.agent.ID(), running, stopCauseUser) + started.OnExit(pty.ExitInfo{PID: 1, Code: 0}) + started.OnOutputEnd(0) + + if managed.hasResumeOnStart() { + t.Fatal("durable user stop retained the live startup resume intent") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay stopped resume: %v", err) + } + for _, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == startupResumeCompletedReason { + t.Fatalf("user-stopped resume committed stale completion: %+v", row) + } + } +} + func TestNormalizeRunMode(t *testing.T) { tests := []struct { name string From 2caed3ea1fda52a032f9a6117d8216a07c660143 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Tue, 6 Oct 2026 23:32:39 +0800 Subject: [PATCH 053/104] fix: fence output on PTY cleanup failure --- internal/pty/AGENTS.md | 4 +++- internal/pty/pty.go | 13 +++++++++---- internal/pty/pty_test.go | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/pty/AGENTS.md b/internal/pty/AGENTS.md index d1a0fa0..6fced01 100644 --- a/internal/pty/AGENTS.md +++ b/internal/pty/AGENTS.md @@ -17,7 +17,9 @@ 正常块不拆分合法 UTF-8 码点,EOF 原样送出无效或不完整尾部字节。 - `Config.OnOutputEnd` 在最后一个输出块后调用一次;它与 `Config.OnExit` 没有顺序保证。 - `Config.OnOutput`、`Config.OnOutputEnd` 与 `Config.OnExit` 在读取和等待 - goroutine 启动前固定,运行中不得替换。 + goroutine 启动前固定,运行中不得替换。正常退出时 `OnOutputEnd` 与 `OnExit` + 没有顺序保证;进程组清理失败时必须先调用 `OnExit` 建立进程退出 fence,再关闭 + master 触发 `OnOutputEnd`。 - `creack/pty` 为子进程创建独立 session;主动停止先向 PID 对应的进程组发送 SIGTERM,等待 `Config.TerminationGrace`,超时再发送 SIGKILL。默认宽限 5 秒。 - 直接子进程自然退出后也通过同一个幂等步骤清理仍存活的进程组后代,避免遗留 diff --git a/internal/pty/pty.go b/internal/pty/pty.go index d6a8032..212acdc 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -289,9 +289,6 @@ func (s *Session) waitLoop() { s.mu.Lock() info.CleanupErr = s.groupCloseErr s.mu.Unlock() - if info.CleanupErr != nil { - s.closeMaster() - } if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { @@ -301,7 +298,15 @@ func (s *Session) waitLoop() { } else { info.Code = 0 } - if s.onExit != nil { + exitNotified := false + if info.CleanupErr != nil && s.onExit != nil { + s.onExit(info) + exitNotified = true + } + if info.CleanupErr != nil { + s.closeMaster() + } + if !exitNotified && s.onExit != nil { s.onExit(info) } select { diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go index 53a8219..3749554 100644 --- a/internal/pty/pty_test.go +++ b/internal/pty/pty_test.go @@ -644,6 +644,9 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { childPIDPath := filepath.Join(t.TempDir(), "child.pid") childReadyPath := filepath.Join(t.TempDir(), "child.ready") exits := make(chan ExitInfo, 1) + exitStarted := make(chan struct{}) + releaseExit := make(chan struct{}) + outputEnded := make(chan struct{}) reader, writer, err := os.Pipe() if err != nil { @@ -676,7 +679,12 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { ptmx: reader, grace: grace, groupSignal: func(_ int, _ syscall.Signal) error { return syscall.EPERM }, + onOutputEnd: func(uint64) { + close(outputEnded) + }, onExit: func(info ExitInfo) { + close(exitStarted) + <-releaseExit exits <- info }, readDone: make(chan struct{}), @@ -700,6 +708,21 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { if err != nil { t.Fatalf("parse child pid %q: %v", rawPID, err) } + select { + case <-exitStarted: + case <-time.After(2 * time.Second): + t.Fatal("exit callback did not start") + } + outputPassedExitFence := false + select { + case <-outputEnded: + outputPassedExitFence = true + case <-time.After(50 * time.Millisecond): + } + close(releaseExit) + if outputPassedExitFence { + t.Fatal("output end ran before the cleanup-failure exit callback") + } childNeedsCleanup := true t.Cleanup(func() { if childNeedsCleanup { From c388cc303d266bc5ffd5b4a9792b02491eb4e929 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 00:48:50 +0800 Subject: [PATCH 054/104] fix: bind BSD prepared registration stages --- internal/workspace/preparation.go | 2 +- internal/workspace/preparation_lease.go | 2 +- internal/workspace/preparation_test.go | 63 ++++ .../workspace/prepared_registration_bound.go | 12 +- .../workspace/prepared_registration_bsd.go | 351 ++++++++++++++---- .../prepared_registration_bsd_test.go | 121 ++++++ .../workspace/prepared_registration_direct.go | 13 +- internal/workspace/prepared_worktree.go | 26 +- internal/workspace/record.go | 77 ++-- internal/workspace/removal.go | 6 +- internal/workspace/repository_capability.go | 65 +++- .../rooted_git_descriptor_unix_test.go | 1 + internal/workspace/rooted_git_linux.go | 1 + internal/workspace/rooted_git_other.go | 1 + internal/workspace/rooted_git_unix.go | 28 +- internal/workspace/rooted_git_windows.go | 1 + internal/workspace/workspace.go | 15 +- internal/workspace/workspace_test.go | 29 ++ 18 files changed, 694 insertions(+), 120 deletions(-) diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index 8c735ad..54f3894 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -32,7 +32,7 @@ func (m *Manager) acknowledgePreparation( ) } if record.PreparationCommitted && - record.Version < workspaceRecordVersion { + record.Version < removalWorkspaceRecordVersion { return target.preparation.Close() } if record.PreparationCommitted && diff --git a/internal/workspace/preparation_lease.go b/internal/workspace/preparation_lease.go index a23911d..9009631 100644 --- a/internal/workspace/preparation_lease.go +++ b/internal/workspace/preparation_lease.go @@ -244,7 +244,7 @@ func openRecordedPreparationLease( manager *Manager, record workspaceRecord, ) (*preparationLease, error) { - if record.Version < workspaceRecordVersion || + if record.Version < removalWorkspaceRecordVersion || record.RepositoryEvidence == nil { return nil, errors.New( "workspace: pending preparation has no repository identity evidence", diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 00a1f98..c255273 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -151,6 +151,69 @@ func TestReconcilePreparationsDiscardsPendingAfterRestart(t *testing.T) { } } +func TestReconcilePreparationsAcceptsVersionFiveEvidence(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf( + "read preparation record: exists=%v err=%v record=%+v", + exists, + err, + record, + ) + } + record.Version = removalWorkspaceRecordVersion + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 5 preparation record: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + []Workspace{{ + AgentID: prepared.AgentID, + Repository: prepared.Repository, + Path: prepared.Path, + Branch: prepared.Branch, + }}, + ); err != nil { + t.Fatalf("adopt version 5 preparation: %v", err) + } + adopted, exists, err := restarted.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read adopted preparation: exists=%v err=%v", + exists, + err, + ) + } + if adopted.Version != removalWorkspaceRecordVersion || + !adopted.PreparationCommitted || + adopted.RepositoryEvidence == nil { + t.Fatalf("adopted version 5 preparation = %+v", adopted) + } +} + func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( t *testing.T, ) { diff --git a/internal/workspace/prepared_registration_bound.go b/internal/workspace/prepared_registration_bound.go index 54de3c9..47f8215 100644 --- a/internal/workspace/prepared_registration_bound.go +++ b/internal/workspace/prepared_registration_bound.go @@ -16,11 +16,19 @@ import ( "github.com/google/uuid" ) +func preparePreparedWorktreeAdd( + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, +) (bool, error) { + return false, nil +} + func finalizePreparedWorktreeAdd( _ context.Context, _ repositoryCapability, - _ string, - _ *os.Root, + _ Workspace, + _ *preparedWorktreeTarget, _ bool, ) error { return nil diff --git a/internal/workspace/prepared_registration_bsd.go b/internal/workspace/prepared_registration_bsd.go index 66f8997..93502a6 100644 --- a/internal/workspace/prepared_registration_bsd.go +++ b/internal/workspace/prepared_registration_bsd.go @@ -84,37 +84,187 @@ func validateBSDPreparedWorktreeNamesAvailable( return nil } +func preparePreparedWorktreeAdd( + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, +) (_ bool, result error) { + stageName, err := bsdPreparedWorktreeStageName(prepared.path) + if err != nil { + return false, err + } + if err := validateBSDPreparedWorktreeNamesAvailable( + repository.commonRoot, + stageName, + ); err != nil { + return false, err + } + if err := repository.commonRoot.Mkdir(stageName, 0o700); err != nil { + return false, fmt.Errorf( + "workspace: create prepared registration stage: %w", + err, + ) + } + stageRoot, err := openRealRootFromRoot( + repository.commonRoot, + stageName, + ) + if err != nil { + return false, fmt.Errorf( + "workspace: open prepared registration stage: %w", + err, + ) + } + identityPersisted := false + defer func() { + if stageRoot == nil { + return + } + if !identityPersisted { + entries, readErr := readRootDirectory(stageRoot) + if readErr == nil && len(entries) == 0 { + result = errors.Join( + result, + removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + stageRoot, + ), + ) + stageRoot = nil + } else { + result = errors.Join(result, readErr) + } + } + if stageRoot != nil { + result = errors.Join(result, stageRoot.Close()) + } + }() + stageIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return false, fmt.Errorf( + "workspace: inspect prepared registration stage identity: %w", + err, + ) + } + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return false, err + } + if err := syncRecordBucket( + repository.commonRoot, + stageName, + ); err != nil { + return false, err + } + record, exists, err := repository.manager.readWorkspaceRecord( + target.Path, + ) + if err == nil && !exists { + err = errors.New("workspace: preparation record is missing") + } + if err != nil { + return false, err + } + if target.branchOperationID == "" || + record.BranchOperationID != target.branchOperationID || + record.PreparedStageDirectoryIdentity != "" || + !sameWorkspace(record.workspace(), target) { + return false, errors.New( + "workspace: preparation record does not accept a registration stage", + ) + } + record.PreparedStageDirectoryIdentity = stageIdentity + if err := repository.manager.replaceWorkspaceRecord(record); err != nil { + return false, fmt.Errorf( + "workspace: persist prepared registration stage identity: %w", + err, + ) + } + identityPersisted = true + persisted, exists, err := repository.manager.readWorkspaceRecord( + target.Path, + ) + if err != nil { + return false, err + } + if !exists || + persisted.PreparedStageDirectoryIdentity != stageIdentity || + persisted.BranchOperationID != target.branchOperationID || + !sameWorkspace(persisted.workspace(), target) { + return false, errors.New( + "workspace: prepared registration stage identity was not persisted", + ) + } + currentIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return false, err + } + if currentIdentity != stageIdentity { + return false, errors.New( + "workspace: prepared registration stage identity changed after persistence", + ) + } + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return false, err + } + prepared.registrationStageRoot = stageRoot + stageRoot = nil + return true, nil +} + func finalizePreparedWorktreeAdd( ctx context.Context, repository repositoryCapability, - worktreePath string, - worktreeRoot *os.Root, + target Workspace, + prepared *preparedWorktreeTarget, commandSucceeded bool, ) (result error) { + worktreePath := prepared.path + worktreeRoot := prepared.root stageName, err := bsdPreparedWorktreeStageName(worktreePath) if err != nil { return err } + stageRoot := prepared.registrationStageRoot + prepared.registrationStageRoot = nil + if stageRoot == nil { + return errors.New( + "workspace: prepared registration stage is unavailable", + ) + } if !commandSucceeded { - return cleanupBSDPreparedWorktreeStage( + return cleanupOpenedBSDPreparedWorktreeStage( ctx, repository, stageName, + target.AgentID, "", + stageRoot, ) } finalized := false defer func() { - if !finalized { + if !finalized && stageRoot != nil { result = errors.Join( result, - cleanupBSDPreparedWorktreeStage( + cleanupOpenedBSDPreparedWorktreeStage( ctx, repository, stageName, + target.AgentID, worktreePath, + stageRoot, ), ) + stageRoot = nil } }() if err := errors.Join( @@ -123,39 +273,12 @@ func finalizePreparedWorktreeAdd( ); err != nil { return err } - privatePath := filepath.Join( - repository.commonPath, - "worktrees", - stageName, - ) - internalPath := filepath.Join(repository.commonPath, stageName) - stageRoot, err := openRealRootFromRoot(repository.commonRoot, stageName) - if err != nil { - return fmt.Errorf( - "workspace: open prepared registration stage: %w", - err, - ) - } - stageOwned := true - defer func() { - if stageOwned { - result = errors.Join(result, stageRoot.Close()) - } - }() - if err := validateBoundGitPointer( - stageRoot, - ".git", - "gitdir: "+privatePath+"\n", - ); err != nil { - return fmt.Errorf( - "workspace: validate prepared registration stage: %w", - err, + privatePath, privateName, privateRoot, err := + openBSDPreparedPrivateGitDirectory( + repository, + stageRoot, + target.AgentID, ) - } - privateRoot, err := openRealRootFromRoot( - repository.commonRoot, - filepath.Join("worktrees", stageName), - ) if err != nil { return fmt.Errorf( "workspace: open prepared private Git directory: %w", @@ -168,9 +291,11 @@ func finalizePreparedWorktreeAdd( if err := validateBSDPrivateAdminSet( repository.commonRoot, stageName, + privateName, ); err != nil { return err } + internalPath := filepath.Join(repository.commonPath, stageName) if err := ensureBoundGitPointer( worktreeRoot, ".git", @@ -186,7 +311,7 @@ func finalizePreparedWorktreeAdd( if err := ensureBoundGitPointer( privateRoot, "gitdir", - boundGitPointerTempPrefix(stageName, "gitdir"), + boundGitPointerTempPrefix(privateName, "gitdir"), worktreePath+string(filepath.Separator)+".git\n", internalPath+string(filepath.Separator)+".git\n", ); err != nil { @@ -216,11 +341,12 @@ func finalizePreparedWorktreeAdd( ); err != nil { return err } - stageOwned = false + ownedStageRoot := stageRoot + stageRoot = nil if err := removeOpenedDirectoryFromRoot( repository.commonRoot, stageName, - stageRoot, + ownedStageRoot, ); err != nil { return fmt.Errorf( "workspace: remove prepared registration stage: %w", @@ -244,29 +370,26 @@ func repairBoundPreparedWorktreeRegistration( repository repositoryCapability, stalePath string, ) (handled bool, result error) { - stageName, err := bsdPreparedWorktreeStageName(stalePath) - if err != nil { + if _, err := bsdPreparedWorktreeStageName(stalePath); err != nil { return true, err } - privatePath := filepath.Join( - repository.commonPath, - "worktrees", - stageName, - ) - if prepared.gitDirectory != privatePath { - return true, errors.New( - "workspace: prepared private Git directory does not match its operation", - ) - } if err := errors.Join( repository.verifyBinding(ctx), verifyRealPathRoot(target.Path, prepared.root), ); err != nil { return true, err } + relativePrivatePath, err := repository.boundGitDirectory( + prepared.gitDirectory, + target.AgentID, + ) + if err != nil { + return true, err + } + privatePath := prepared.gitDirectory privateRoot, err := openRealRootFromRoot( repository.commonRoot, - filepath.Join("worktrees", stageName), + relativePrivatePath, ) if err != nil { return true, err @@ -284,7 +407,10 @@ func repairBoundPreparedWorktreeRegistration( if err := ensureBoundGitPointer( privateRoot, "gitdir", - boundGitPointerTempPrefix(stageName, "gitdir"), + boundGitPointerTempPrefix( + filepath.Base(relativePrivatePath), + "gitdir", + ), target.Path+string(filepath.Separator)+".git\n", stalePath+string(filepath.Separator)+".git\n", ); err != nil { @@ -342,7 +468,9 @@ func cleanupPreparedWorktreeAddDebris( ctx, repository, stageName, + target.AgentID, target.Path, + record.PreparedStageDirectoryIdentity, ) } @@ -350,7 +478,9 @@ func cleanupBSDPreparedWorktreeStage( ctx context.Context, repository repositoryCapability, stageName string, + agentID string, allowedTargetPath string, + expectedIdentity string, ) (result error) { if err := removeRootDeletionDebris( repository.commonRoot, @@ -365,27 +495,77 @@ func cleanupBSDPreparedWorktreeStage( if err != nil { return err } - stageOwned := true defer func() { - if stageOwned { + if stageRoot != nil { result = errors.Join(result, stageRoot.Close()) } }() - privatePath := filepath.Join( - repository.commonPath, - "worktrees", + if expectedIdentity == "" { + return errors.New( + "workspace: prepared registration stage identity is unavailable", + ) + } + stageIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return err + } + if stageIdentity != expectedIdentity { + return errors.New( + "workspace: prepared registration stage identity changed", + ) + } + ownedStageRoot := stageRoot + stageRoot = nil + return cleanupOpenedBSDPreparedWorktreeStage( + ctx, + repository, stageName, + agentID, + allowedTargetPath, + ownedStageRoot, ) - if err := validateBoundGitPointer( +} + +func cleanupOpenedBSDPreparedWorktreeStage( + ctx context.Context, + repository repositoryCapability, + stageName string, + agentID string, + allowedTargetPath string, + stageRoot *os.Root, +) (result error) { + defer func() { + if stageRoot != nil { + result = errors.Join(result, stageRoot.Close()) + } + }() + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, stageRoot, - ".git", - "gitdir: "+privatePath+"\n", ); err != nil { return err } - privateRoot, err := openRealRootFromRoot( - repository.commonRoot, - filepath.Join("worktrees", stageName), + entries, err := readRootDirectory(stageRoot) + if err != nil { + return err + } + if len(entries) == 0 { + ownedStageRoot := stageRoot + stageRoot = nil + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + ownedStageRoot, + ); err != nil { + return err + } + return repository.pruneWorktrees(ctx) + } + _, _, privateRoot, err := openBSDPreparedPrivateGitDirectory( + repository, + stageRoot, + agentID, ) if err != nil { return err @@ -412,11 +592,12 @@ func cleanupBSDPreparedWorktreeStage( if err := validateBSDStageContents(stageRoot); err != nil { return err } - stageOwned = false + ownedStageRoot := stageRoot + stageRoot = nil if err := removeOpenedDirectoryFromRoot( repository.commonRoot, stageName, - stageRoot, + ownedStageRoot, ); err != nil { return err } @@ -426,6 +607,7 @@ func cleanupBSDPreparedWorktreeStage( func validateBSDPrivateAdminSet( commonRoot *os.Root, stageName string, + privateName string, ) (result error) { worktrees, err := openRealRootFromRoot(commonRoot, "worktrees") if err != nil { @@ -440,7 +622,7 @@ func validateBSDPrivateAdminSet( } found := false for _, entry := range entries { - if entry.Name() == stageName { + if entry.Name() == privateName { found = true continue } @@ -459,6 +641,39 @@ func validateBSDPrivateAdminSet( return nil } +func openBSDPreparedPrivateGitDirectory( + repository repositoryCapability, + stageRoot *os.Root, + agentID string, +) (string, string, *os.Root, error) { + pointer, err := readBoundRegularFile(stageRoot, ".git") + if err != nil { + return "", "", nil, err + } + privatePath, found := strings.CutPrefix(pointer, "gitdir: ") + if !found || !strings.HasSuffix(privatePath, "\n") { + return "", "", nil, errors.New( + "workspace: prepared registration stage has an invalid Git pointer", + ) + } + privatePath = strings.TrimSuffix(privatePath, "\n") + relativePath, err := repository.boundGitDirectory( + privatePath, + agentID, + ) + if err != nil { + return "", "", nil, err + } + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + relativePath, + ) + if err != nil { + return "", "", nil, err + } + return privatePath, filepath.Base(relativePath), privateRoot, nil +} + func validateBSDStageContents(stageRoot *os.Root) error { if err := recoverRecordRenameDebris(stageRoot); err != nil { return err diff --git a/internal/workspace/prepared_registration_bsd_test.go b/internal/workspace/prepared_registration_bsd_test.go index 6d6d09f..36dfa14 100644 --- a/internal/workspace/prepared_registration_bsd_test.go +++ b/internal/workspace/prepared_registration_bsd_test.go @@ -166,6 +166,17 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if _, err := os.Lstat(repairInvoked); !errors.Is(err, os.ErrNotExist) { t.Fatalf("public worktree repair was invoked: %v", err) } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read prepared workspace record: exists=%v err=%v", + exists, + err, + ) + } + if record.PreparedStageDirectoryIdentity != "" { + t.Fatal("completed preparation retained registration stage identity") + } if err := manager.Discard(context.Background(), prepared); err != nil { t.Fatalf("discard prepared worktree: %v", err) } @@ -249,6 +260,116 @@ exit "$status" } } +func TestBSDPreparePreservesReplacedRegistrationStage(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + stageFile := filepath.Join(parent, "common-stage") + originalFile := filepath.Join(parent, "original-common-stage") + wrapper := filepath.Join(parent, "git-wrapper-stage-replacement") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + stage=$(CDPATH= cd -- "$target" && pwd -P) || exit 90 + original=$stage-original + mv "$stage" "$original" || exit 91 + mkdir "$stage" || exit 92 + cp "$original/.git" "$stage/.git" || exit 93 + printf '%s' "$stage" > "$DROVE_TEST_STAGE" || exit 94 + printf '%s' "$original" > "$DROVE_TEST_ORIGINAL_STAGE" || exit 95 + name=${stage##*/} + mkdir "${stage%/*}/worktrees/$name-unexpected" || exit 96 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_STAGE", stageFile) + t.Setenv("DROVE_TEST_ORIGINAL_STAGE", originalFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after registration stage replacement") + } + + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read replacement stage path: %v", err) + } + rawOriginal, err := os.ReadFile(originalFile) + if err != nil { + t.Fatalf("read original stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + originalPath := filepath.Clean(string(rawOriginal)) + replacementInfo, err := os.Stat(stagePath) + if err != nil { + t.Fatalf("replacement registration stage was removed: %v", err) + } + originalInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("original registration stage was removed: %v", err) + } + if os.SameFile(replacementInfo, originalInfo) { + t.Fatal("replacement and original registration stages share identity") + } + replacementPointer, err := os.ReadFile(filepath.Join(stagePath, ".git")) + if err != nil { + t.Fatalf("read replacement Git pointer: %v", err) + } + originalPointer, err := os.ReadFile(filepath.Join(originalPath, ".git")) + if err != nil { + t.Fatalf("read original Git pointer: %v", err) + } + if string(replacementPointer) != string(originalPointer) { + t.Fatal("replacement Git pointer does not match the original") + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err == nil { + t.Fatal("reconcile accepted a replaced registration stage") + } + current, err := os.Stat(stagePath) + if err != nil { + t.Fatalf("reconcile removed the replacement registration stage: %v", err) + } + if !os.SameFile(current, replacementInfo) { + t.Fatal("reconcile changed the replacement registration stage") + } +} + func TestEnsureBoundGitPointerRecoversTemporary(t *testing.T) { path := t.TempDir() root, err := os.OpenRoot(path) diff --git a/internal/workspace/prepared_registration_direct.go b/internal/workspace/prepared_registration_direct.go index 2cfd1ae..8d73578 100644 --- a/internal/workspace/prepared_registration_direct.go +++ b/internal/workspace/prepared_registration_direct.go @@ -4,14 +4,21 @@ package workspace import ( "context" - "os" ) +func preparePreparedWorktreeAdd( + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, +) (bool, error) { + return false, nil +} + func finalizePreparedWorktreeAdd( _ context.Context, _ repositoryCapability, - _ string, - _ *os.Root, + _ Workspace, + _ *preparedWorktreeTarget, _ bool, ) error { return nil diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index f5790c6..0ec119c 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -12,10 +12,11 @@ import ( ) type preparedWorktreeTarget struct { - root *os.Root - name string - path string - gitDirectory string + root *os.Root + registrationStageRoot *os.Root + name string + path string + gitDirectory string } func preparedWorktreeStagingName( @@ -545,12 +546,23 @@ func (m *Manager) cleanupPreparedWorktreeTarget( } func (prepared *preparedWorktreeTarget) Close() error { - if prepared == nil || prepared.root == nil { + if prepared == nil { return nil } - err := prepared.root.Close() + var result error + if prepared.registrationStageRoot != nil { + result = errors.Join( + result, + prepared.registrationStageRoot.Close(), + ) + prepared.registrationStageRoot = nil + } + if prepared.root == nil { + return result + } + result = errors.Join(result, prepared.root.Close()) prepared.root = nil - return err + return result } func (m *Manager) preparedWorktreeStagingPaths( diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 2282362..3a7afcc 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -20,26 +20,28 @@ const ( protectedWorkspaceRecordVersion = 2 preparationWorkspaceRecordVersion = 3 repositoryWorkspaceRecordVersion = 4 - workspaceRecordVersion = 5 + removalWorkspaceRecordVersion = 5 + workspaceRecordVersion = 6 maxWorkspaceRecordSize = 64 * 1024 ) type workspaceRecord struct { - Version int `json:"version"` - AgentID string `json:"agent_id"` - Repository string `json:"repository"` - Path string `json:"path"` - Branch string `json:"branch"` - ProtectionKnown bool `json:"protection_known"` - IncludedPaths []string `json:"included_paths"` - PreparationCommitted bool `json:"preparation_committed"` - CreatedBranch bool `json:"created_branch"` - BranchOperationID string `json:"branch_operation_id,omitempty"` - ExpectedHeadOID string `json:"expected_head_oid,omitempty"` - GitDirectory string `json:"git_directory,omitempty"` - DirectoryIdentity string `json:"directory_identity,omitempty"` - RepositoryEvidence *repositoryEvidence `json:"repository_evidence,omitempty"` - Removal *workspaceRemovalRecord `json:"removal,omitempty"` + Version int `json:"version"` + AgentID string `json:"agent_id"` + Repository string `json:"repository"` + Path string `json:"path"` + Branch string `json:"branch"` + ProtectionKnown bool `json:"protection_known"` + IncludedPaths []string `json:"included_paths"` + PreparationCommitted bool `json:"preparation_committed"` + CreatedBranch bool `json:"created_branch"` + BranchOperationID string `json:"branch_operation_id,omitempty"` + ExpectedHeadOID string `json:"expected_head_oid,omitempty"` + GitDirectory string `json:"git_directory,omitempty"` + DirectoryIdentity string `json:"directory_identity,omitempty"` + PreparedStageDirectoryIdentity string `json:"prepared_stage_directory_identity,omitempty"` + RepositoryEvidence *repositoryEvidence `json:"repository_evidence,omitempty"` + Removal *workspaceRemovalRecord `json:"removal,omitempty"` } type repositoryEvidence struct { @@ -132,11 +134,15 @@ func upgradeWorkspaceRecord(record *workspaceRecord) { if record.Version >= workspaceRecordVersion { return } + previousVersion := record.Version record.Version = workspaceRecordVersion - record.RepositoryEvidence = nil - record.ExpectedHeadOID = "" - if record.PreparationCommitted { - record.BranchOperationID = "" + record.PreparedStageDirectoryIdentity = "" + if previousVersion < removalWorkspaceRecordVersion { + record.RepositoryEvidence = nil + record.ExpectedHeadOID = "" + if record.PreparationCommitted { + record.BranchOperationID = "" + } } } @@ -560,13 +566,20 @@ func (m *Manager) decodeWorkspaceRecordFile( recordPath, ) } - case workspaceRecordVersion: + case removalWorkspaceRecordVersion: if record.IncludedPaths == nil { return workspaceRecord{}, fmt.Errorf( "workspace: version 5 record %q has no included paths", recordPath, ) } + case workspaceRecordVersion: + if record.IncludedPaths == nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: version 6 record %q has no included paths", + recordPath, + ) + } default: return workspaceRecord{}, fmt.Errorf( "workspace: record %q has unsupported version %d", @@ -580,6 +593,13 @@ func (m *Manager) decodeWorkspaceRecordFile( recordPath, ) } + if record.Version < workspaceRecordVersion && + record.PreparedStageDirectoryIdentity != "" { + return workspaceRecord{}, fmt.Errorf( + "workspace: record %q contains version 6 fields", + recordPath, + ) + } if err := m.validateWorkspaceRecord(record); err != nil { return workspaceRecord{}, fmt.Errorf( "workspace: validate record %q: %w", @@ -595,6 +615,7 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { record.Version != protectedWorkspaceRecordVersion && record.Version != preparationWorkspaceRecordVersion && record.Version != repositoryWorkspaceRecordVersion && + record.Version != removalWorkspaceRecordVersion && record.Version != workspaceRecordVersion { return fmt.Errorf("workspace: unsupported record version %d", record.Version) } @@ -642,7 +663,7 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: quarantined removal has not been started", ) } - if record.Version >= workspaceRecordVersion && + if record.Version >= removalWorkspaceRecordVersion && record.Removal.Started && !record.Removal.Quarantined && !record.Removal.PathAbsent { @@ -700,6 +721,18 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: directory identity is invalid", ) } + if record.PreparedStageDirectoryIdentity != "" && + !validDirectoryIdentity(record.PreparedStageDirectoryIdentity) { + return errors.New( + "workspace: prepared stage directory identity is invalid", + ) + } + if record.PreparedStageDirectoryIdentity != "" && + (record.BranchOperationID == "" || record.PreparationCommitted) { + return errors.New( + "workspace: prepared stage identity has no pending preparation", + ) + } if record.RepositoryEvidence != nil { var err error if record.Version == repositoryWorkspaceRecordVersion { diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index 962f3e8..24f227c 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -39,7 +39,7 @@ type RemovalResult struct { func workspaceForRemoval(record workspaceRecord) Workspace { target := record.workspace() - if record.Version < workspaceRecordVersion { + if record.Version < removalWorkspaceRecordVersion { target.repositoryEvidence = nil } return target @@ -314,7 +314,7 @@ func (m *Manager) removalRepository( ctx context.Context, record workspaceRecord, ) (repositoryCapability, func() error, error) { - if record.Version < workspaceRecordVersion || + if record.Version < removalWorkspaceRecordVersion || record.RepositoryEvidence == nil { return repositoryCapability{}, nil, errors.New( "workspace: pending removal has no repository identity evidence", @@ -331,7 +331,7 @@ func (m *Manager) ensureRemovalRepositoryEvidence( ctx context.Context, record workspaceRecord, ) (_ workspaceRecord, result error) { - if record.Version >= workspaceRecordVersion && + if record.Version >= removalWorkspaceRecordVersion && record.RepositoryEvidence != nil { return record, nil } diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index c549580..dd415f4 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -169,11 +169,13 @@ func (r repositoryCapability) runForward( func (r repositoryCapability) addPreparedWorktree( ctx context.Context, - path string, - root *os.Root, + target Workspace, + prepared *preparedWorktreeTarget, branch string, expectedHeadOID string, ) ([]byte, error) { + path := prepared.path + root := prepared.root verifyBranch := func() error { oid, exists, err := r.refOID(ctx, "refs/heads/"+branch) if err != nil { @@ -189,6 +191,14 @@ func (r repositoryCapability) addPreparedWorktree( if err := errors.Join(r.verifyBinding(ctx), verifyBranch()); err != nil { return nil, err } + stagePrepared, err := preparePreparedWorktreeAdd( + target, + prepared, + r, + ) + if err != nil { + return nil, err + } command, cleanup, err := rootedPreparedWorktreeGitCommand( ctx, r.manager.git, @@ -198,6 +208,7 @@ func (r repositoryCapability) addPreparedWorktree( r.commonRoot, path, root, + prepared.registrationStageRoot, []string{ "worktree", "add", @@ -214,10 +225,14 @@ func (r repositoryCapability) addPreparedWorktree( finalizeErr := finalizePreparedWorktreeAdd( ctx, r, - path, - root, + target, + prepared, commandErr == nil, ) + var clearStageErr error + if stagePrepared && finalizeErr == nil { + clearStageErr = r.clearPreparedWorktreeStageIdentity(target) + } verifyErr := errors.Join( r.verifyBinding(ctx), verifyRealPathRoot(path, root), @@ -226,11 +241,53 @@ func (r repositoryCapability) addPreparedWorktree( return output, errors.Join( commandErr, finalizeErr, + clearStageErr, verifyErr, cleanup(), ) } +func (r repositoryCapability) clearPreparedWorktreeStageIdentity( + target Workspace, +) error { + record, exists, err := r.manager.readWorkspaceRecord(target.Path) + if err == nil && !exists { + err = errors.New("workspace: preparation record is missing") + } + if err != nil { + return err + } + if record.PreparedStageDirectoryIdentity == "" { + return errors.New( + "workspace: prepared stage directory identity is missing", + ) + } + if target.branchOperationID == "" || + record.BranchOperationID != target.branchOperationID || + !sameWorkspace(record.workspace(), target) { + return errors.New( + "workspace: preparation record changed while clearing stage identity", + ) + } + record.PreparedStageDirectoryIdentity = "" + if err := r.manager.replaceWorkspaceRecord(record); err != nil { + return fmt.Errorf( + "workspace: clear prepared stage directory identity: %w", + err, + ) + } + persisted, exists, err := r.manager.readWorkspaceRecord(target.Path) + if err != nil { + return err + } + if !exists || persisted.PreparedStageDirectoryIdentity != "" { + return errors.New( + "workspace: prepared stage directory identity was not cleared", + ) + } + return nil +} + func (r repositoryCapability) runPrivateGit( ctx context.Context, input string, diff --git a/internal/workspace/rooted_git_descriptor_unix_test.go b/internal/workspace/rooted_git_descriptor_unix_test.go index 2472a5e..1f47a3b 100644 --- a/internal/workspace/rooted_git_descriptor_unix_test.go +++ b/internal/workspace/rooted_git_descriptor_unix_test.go @@ -100,6 +100,7 @@ func TestRootedPreparedWorktreeGitCommandBindsAllDirectories(t *testing.T) { commonRoot, worktreePath, worktreeRoot, + nil, []string{"version"}, ) if err != nil { diff --git a/internal/workspace/rooted_git_linux.go b/internal/workspace/rooted_git_linux.go index c186147..cd54d0b 100644 --- a/internal/workspace/rooted_git_linux.go +++ b/internal/workspace/rooted_git_linux.go @@ -174,6 +174,7 @@ func rootedPreparedWorktreeGitCommand( commonRoot *os.Root, _ string, worktreeRoot *os.Root, + _ *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { worktree, err := worktreeRoot.Open(".") diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 72c652c..4ea485d 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -16,6 +16,7 @@ func rootedGitCommand( _ *os.Root, _ string, _ *os.Root, + _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index 158c7e3..a00cf0b 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -222,6 +222,7 @@ func rootedPreparedWorktreeGitCommand( commonRoot *os.Root, worktreePath string, worktreeRoot *os.Root, + stageRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { if err := verifyRealPathRoot(repositoryPath, repositoryRoot); err != nil { @@ -240,23 +241,36 @@ func rootedPreparedWorktreeGitCommand( if err != nil { return nil, nil, err } - if err := validateBSDPreparedWorktreeNamesAvailable( + if stageRoot == nil { + return nil, nil, errors.New( + "workspace: prepared registration stage is unavailable", + ) + } + if err := verifyRootEntryUnchanged( commonRoot, stageName, + stageRoot, ); err != nil { return nil, nil, err } + entries, err := readRootDirectory(stageRoot) + if err != nil { + return nil, nil, err + } + if len(entries) != 0 { + return nil, nil, errors.New( + "workspace: prepared registration stage is not empty", + ) + } if len(arguments) < 2 || arguments[len(arguments)-2] != "." { return nil, nil, errors.New( "workspace: prepared worktree command has an unexpected target", ) } - arguments = append([]string(nil), arguments...) - arguments[len(arguments)-2] = "./" + stageName - directory, err := commonRoot.Open(".") + directory, err := stageRoot.Open(".") if err != nil { return nil, nil, fmt.Errorf( - "workspace: open rooted common Git directory: %w", + "workspace: open rooted prepared registration stage: %w", err, ) } @@ -291,8 +305,8 @@ func rootedPreparedWorktreeGitCommand( command.ExtraFiles = []*os.File{directory} command.Env = boundGitEnvironment( command.Environ(), - ".", - ".", + "..", + "..", ) cleanup := func() error { return errors.Join( diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 5dc7b1b..d77e2fd 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -144,6 +144,7 @@ func rootedPreparedWorktreeGitCommand( commonRoot *os.Root, worktreePath string, worktreeRoot *os.Root, + _ *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { worktreeGuard, err := openRepositoryGuard(worktreePath, worktreeRoot) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 453188b..e54da7c 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -418,8 +418,8 @@ func (m *Manager) prepare( } if _, err := sourceRepository.addPreparedWorktree( ctx, - preparedTarget.path, - preparedTarget.root, + result, + preparedTarget, result.Branch, expectedHeadOID, ); err != nil { @@ -774,6 +774,17 @@ func (m *Manager) discardWithRepository( "workspace: preparation record does not match discard request", ) } + if err := cleanupPreparedWorktreeAddDebris( + ctx, + repository, + target, + record, + ); err != nil { + return fmt.Errorf( + "workspace: clean prepared worktree registration stage: %w", + err, + ) + } stagingPath, stagingRegistrationPath, stagingExists, err := m.preparedWorktreeStagingPaths(target, record) if err != nil { diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index f62f9f5..da1223a 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -966,6 +966,35 @@ func TestRemoveUpgradesCommittedVersionFourRecord(t *testing.T) { } } +func TestUpgradeVersionFiveRecordPreservesRecoveryEvidence(t *testing.T) { + evidence := &repositoryEvidence{ + SourcePath: "/source", + SourceDirectoryIdentity: "source-identity", + GitDirectory: "/source/.git", + GitDirectoryIdentity: "git-identity", + CommonGitDirectory: "/source/.git", + CommonGitDirectoryIdentity: "common-identity", + } + record := workspaceRecord{ + Version: removalWorkspaceRecordVersion, + PreparationCommitted: true, + BranchOperationID: "11111111-1111-4111-8111-111111111111", + ExpectedHeadOID: "expected-head", + RepositoryEvidence: evidence, + } + + upgradeWorkspaceRecord(&record) + + if record.Version != workspaceRecordVersion || + record.BranchOperationID != + "11111111-1111-4111-8111-111111111111" || + record.ExpectedHeadOID != "expected-head" || + record.RepositoryEvidence != evidence || + record.PreparedStageDirectoryIdentity != "" { + t.Fatalf("upgraded version 5 record = %+v", record) + } +} + func TestRemoveReturnsPendingAfterPartialGitMutation(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test requires a POSIX shell") From 9eb2a54a2371458741de486fb932257f6b1ab260 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 05:53:54 +0800 Subject: [PATCH 055/104] fix: restore workspace platform compilation --- internal/workspace/rooted_git_other.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 4ea485d..20f9d84 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -16,7 +16,6 @@ func rootedGitCommand( _ *os.Root, _ string, _ *os.Root, - _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( @@ -64,6 +63,7 @@ func rootedPreparedWorktreeGitCommand( _ *os.Root, _ string, _ *os.Root, + _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( From edbefa77dbae51c5399ae1e5dcb881f6a451b08a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:14:22 +0800 Subject: [PATCH 056/104] fix: bind BSD checkout temporary files Hold one checkout-index temporary at a time, verify its Git blob ID before copying, and remove it only through the bound file identity. This prevents replacement content from reaching a prepared worktree without making descriptor use grow with repository size.\n\nRegression coverage proves the replacement attack failed before the fix and guards low-file-limit repositories against the intermediate bulk-open design. --- internal/workspace/AGENTS.md | 5 +- ...prepared_checkout_replacement_unix_test.go | 83 ++++ internal/workspace/prepared_checkout_unix.go | 386 ++++++++++++------ .../workspace/prepared_checkout_unix_test.go | 157 +++++++ 4 files changed, 511 insertions(+), 120 deletions(-) create mode 100644 internal/workspace/prepared_checkout_replacement_unix_test.go diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index e26cb3e..d0eb5d3 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -28,7 +28,10 @@ 文件系统目录身份,再持久化 Git 私有目录。Linux 和 Windows 从同时固定的目标根与 Git 根执行 `read-tree --reset -u`;macOS/BSD 只从固定私有 Git 根更新 index, 用 `checkout-index --temp` 导出,并由 Drove 通过固定目标根安装普通文件、符号链接 - 和 gitlink。macOS/BSD 遇到普通文件的 `filter` 属性必须 fail-stop,因为 filter + 和 gitlink。普通文件临时项必须逐个固定已打开文件身份并按 index object ID 校验, + 复制期间持续使用同一文件句柄,不能让打开的描述符数量随 tracked 文件数增长;清理时 + 只能按该身份删除,禁止仅凭临时文件名删除。 + macOS/BSD 遇到普通文件的 `filter` 属性必须 fail-stop,因为 filter 无法同时取得固定 worktree cwd 与描述符绑定的私有 Git 上下文。初始化完成后把 staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 `git worktree repair .`。私有 `gitdir` 指针替换中断后,重启恢复必须先从绑定私有 diff --git a/internal/workspace/prepared_checkout_replacement_unix_test.go b/internal/workspace/prepared_checkout_replacement_unix_test.go new file mode 100644 index 0000000..ef7a3b3 --- /dev/null +++ b/internal/workspace/prepared_checkout_replacement_unix_test.go @@ -0,0 +1,83 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPreparedCheckoutRejectsReplacedGitTemporary(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + outputFile := filepath.Join(parent, "checkout-output") + wrapper := filepath.Join(parent, "git-wrapper-checkout-temp") + script := `#!/bin/sh +checkout= +for argument in "$@"; do + [ "$argument" = "checkout-index" ] && checkout=1 +done +if [ -n "$checkout" ]; then + "$DROVE_TEST_REAL_GIT" "$@" > "$DROVE_TEST_CHECKOUT_OUTPUT" + status=$? + [ "$status" -eq 0 ] || exit "$status" + temp=$( + tr '\000' '\n' < "$DROVE_TEST_CHECKOUT_OUTPUT" | + awk -F ' ' 'NF >= 2 { print $1; exit }' + ) || exit 90 + [ -n "$temp" ] || exit 91 + original=$temp.original + mv "$temp" "$original" || exit 92 + printf 'replacement\n' > "$temp" || exit 93 + cat "$DROVE_TEST_CHECKOUT_OUTPUT" + exit 0 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_CHECKOUT_OUTPUT", outputFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil || !strings.Contains( + err.Error(), + "does not match the index", + ) { + t.Fatalf("prepare after temporary replacement error = %v", err) + } + + canonicalRepository, err := resolvePath(repository) + if err != nil { + t.Fatalf("resolve repository: %v", err) + } + target := filepath.Join( + manager.root, + repositoryHash(canonicalRepository), + testAgentID, + ) + if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement bytes reached a prepared worktree: %v", err) + } +} diff --git a/internal/workspace/prepared_checkout_unix.go b/internal/workspace/prepared_checkout_unix.go index 7e2294b..c766329 100644 --- a/internal/workspace/prepared_checkout_unix.go +++ b/internal/workspace/prepared_checkout_unix.go @@ -5,10 +5,12 @@ package workspace import ( "bytes" "context" + "crypto/sha1" "crypto/sha256" "encoding/hex" "errors" "fmt" + "hash" "io" "os" "path/filepath" @@ -29,7 +31,14 @@ type preparedCheckoutEntry struct { mode string oid string path string - temp string + temp *preparedCheckoutTemp +} + +type preparedCheckoutTemp struct { + name string + file *os.File + info os.FileInfo + verified bool } func checkoutPreparedWorktree( @@ -71,7 +80,8 @@ func checkoutPreparedWorktree( var paths strings.Builder for _, entry := range entries { - if entry.mode == preparedCheckoutGitlinkMode { + if entry.mode != preparedCheckoutRegularMode && + entry.mode != preparedCheckoutExecutableMode { continue } paths.WriteString(entry.path) @@ -87,13 +97,6 @@ func checkoutPreparedWorktree( "--stdin", "-z", ) - tempNames := preparedCheckoutTempNames(output) - defer func() { - result = errors.Join( - result, - removePreparedCheckoutTemps(repository.gitRoot, tempNames), - ) - }() if checkoutErr != nil { return checkoutErr } @@ -312,32 +315,70 @@ func validatePreparedCheckoutPath(path string) (string, error) { return clean, nil } -func preparedCheckoutTempNames(raw []byte) []string { - seen := make(map[string]struct{}) - var names []string - for _, record := range bytes.Split(raw, []byte{0}) { - rawName, _, ok := bytes.Cut(record, []byte{'\t'}) - if !ok { - continue - } - name := string(rawName) - if !validPreparedCheckoutTempName(name) { - continue - } - if _, exists := seen[name]; exists { - continue - } - seen[name] = struct{}{} - names = append(names, name) - } - return names -} - func validPreparedCheckoutTempName(name string) bool { return filepath.Base(name) == name && name != "." && - (strings.HasPrefix(name, ".merge_file_") || - strings.HasPrefix(name, ".merge_link_")) + strings.HasPrefix(name, ".merge_file_") +} + +func openPreparedCheckoutTemp( + root *os.Root, + temp *preparedCheckoutTemp, +) error { + if temp == nil || temp.file != nil || + !validPreparedCheckoutTempName(temp.name) { + return errors.New( + "workspace: prepared checkout temporary handle is invalid", + ) + } + info, err := root.Lstat(temp.name) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout temporary path %q: %w", + temp.name, + err, + ) + } + if !info.Mode().IsRegular() || + info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: prepared checkout temporary path %q is not a regular file", + temp.name, + ) + } + file, err := root.Open(temp.name) + if err != nil { + return fmt.Errorf( + "workspace: open prepared checkout temporary path %q: %w", + temp.name, + err, + ) + } + opened, err := file.Stat() + if err != nil { + return errors.Join( + fmt.Errorf( + "workspace: inspect opened prepared checkout temporary path %q: %w", + temp.name, + err, + ), + file.Close(), + ) + } + if !opened.Mode().IsRegular() || + opened.Mode()&os.ModeSymlink != 0 || + !os.SameFile(info, opened) { + return errors.Join( + fmt.Errorf( + "workspace: prepared checkout temporary path %q changed while opening", + temp.name, + ), + file.Close(), + ) + } + temp.file = file + temp.info = opened + return nil } func bindPreparedCheckoutTemps( @@ -351,7 +392,10 @@ func bindPreparedCheckoutTemps( } byPath := make(map[string]*preparedCheckoutEntry, len(entries)) for _, entry := range entries { - byPath[entry.path] = entry + if entry.mode == preparedCheckoutRegularMode || + entry.mode == preparedCheckoutExecutableMode { + byPath[entry.path] = entry + } } seenTemps := make(map[string]struct{}, len(entries)) for _, record := range bytes.Split(raw[:len(raw)-1], []byte{0}) { @@ -378,36 +422,24 @@ func bindPreparedCheckoutTemps( return err } entry, exists := byPath[path] - if !exists || entry.mode == preparedCheckoutGitlinkMode { + if !exists { return fmt.Errorf( "workspace: prepared checkout returned unexpected path %q", path, ) } - if entry.temp != "" { + if entry.temp != nil { return fmt.Errorf( "workspace: prepared checkout returned path %q twice", path, ) } - if entry.mode == preparedCheckoutSymlinkMode && - !strings.HasPrefix(temp, ".merge_link_") { - return fmt.Errorf( - "workspace: prepared checkout path %q is not a symlink", - path, - ) - } - if entry.mode != preparedCheckoutSymlinkMode && - !strings.HasPrefix(temp, ".merge_file_") { - return fmt.Errorf( - "workspace: prepared checkout path %q is not a regular file", - path, - ) - } - entry.temp = temp + entry.temp = &preparedCheckoutTemp{name: temp} } for _, entry := range entries { - if entry.mode != preparedCheckoutGitlinkMode && entry.temp == "" { + if (entry.mode == preparedCheckoutRegularMode || + entry.mode == preparedCheckoutExecutableMode) && + entry.temp == nil { return fmt.Errorf( "workspace: prepared checkout omitted path %q", entry.path, @@ -417,28 +449,149 @@ func bindPreparedCheckoutTemps( return nil } -func removePreparedCheckoutTemps(root *os.Root, names []string) error { +func verifyPreparedCheckoutTemp(entry *preparedCheckoutEntry) error { + if entry.temp == nil || entry.temp.file == nil { + return fmt.Errorf( + "workspace: prepared checkout source for %q is not open", + entry.path, + ) + } + oid, err := preparedCheckoutTempBlobOID( + entry.temp, + entry.path, + entry.oid, + ) + if err != nil { + return err + } + if !strings.EqualFold(oid, entry.oid) { + return fmt.Errorf( + "workspace: prepared checkout source for %q does not match the index", + entry.path, + ) + } + entry.temp.verified = true + return nil +} + +func preparedCheckoutTempBlobOID( + temp *preparedCheckoutTemp, + path string, + expectedOID string, +) (string, error) { + before, err := temp.file.Stat() + if err != nil { + return "", fmt.Errorf( + "workspace: inspect prepared checkout source for %q: %w", + path, + err, + ) + } + if !samePreparedCheckoutTempState(temp.info, before) { + return "", fmt.Errorf( + "workspace: prepared checkout source for %q changed before reading", + path, + ) + } + if _, err := temp.file.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf( + "workspace: rewind prepared checkout source for %q: %w", + path, + err, + ) + } + digest, err := newPreparedCheckoutBlobHash(expectedOID, before.Size()) + if err != nil { + return "", err + } + read, err := io.Copy(digest, temp.file) + if err != nil { + return "", fmt.Errorf( + "workspace: hash prepared checkout source for %q: %w", + path, + err, + ) + } + after, err := temp.file.Stat() + if err != nil { + return "", fmt.Errorf( + "workspace: inspect prepared checkout source for %q after reading: %w", + path, + err, + ) + } + if read != before.Size() || + !samePreparedCheckoutTempState(before, after) || + !samePreparedCheckoutTempState(temp.info, after) { + return "", fmt.Errorf( + "workspace: prepared checkout source for %q changed while reading", + path, + ) + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func newPreparedCheckoutBlobHash( + expectedOID string, + size int64, +) (hash.Hash, error) { + var digest hash.Hash + switch len(expectedOID) { + case sha1.Size * 2: + digest = sha1.New() + case sha256.Size * 2: + digest = sha256.New() + default: + return nil, errors.New( + "workspace: prepared checkout object ID uses an unsupported hash", + ) + } + header := fmt.Sprintf("blob %d\x00", size) + if _, err := io.WriteString(digest, header); err != nil { + return nil, fmt.Errorf( + "workspace: initialize prepared checkout blob hash: %w", + err, + ) + } + return digest, nil +} + +func samePreparedCheckoutTempState( + expected os.FileInfo, + current os.FileInfo, +) bool { + return expected.Mode().IsRegular() && + current.Mode().IsRegular() && + expected.Mode()&os.ModeSymlink == 0 && + current.Mode()&os.ModeSymlink == 0 && + os.SameFile(expected, current) && + expected.Size() == current.Size() && + expected.Mode() == current.Mode() && + expected.ModTime().Equal(current.ModTime()) +} + +func removePreparedCheckoutTemps( + root *os.Root, + temps []*preparedCheckoutTemp, +) error { var result error - for _, name := range names { - info, err := root.Lstat(name) - switch { - case errors.Is(err, os.ErrNotExist): - continue - case err != nil: - result = errors.Join(result, err) + for _, temp := range temps { + if temp == nil || temp.file == nil { continue - case !info.Mode().IsRegular() && - info.Mode()&os.ModeSymlink == 0: + } + if temp.verified { result = errors.Join( result, - fmt.Errorf( - "workspace: prepared checkout temporary path %q changed type", - name, + removeOwnedRecordPathIfSame( + root, + temp.name, + temp.file, + ".drove-checkout-temp-"+uuid.NewString(), + nil, ), ) - continue } - result = errors.Join(result, root.Remove(name)) + result = errors.Join(result, temp.file.Close()) } return result } @@ -448,40 +601,39 @@ func installPreparedCheckoutFile( worktreeRoot *os.Root, entry *preparedCheckoutEntry, ) (result error) { - sourceInfo, err := gitRoot.Lstat(entry.temp) - if err != nil { + if entry.temp == nil { return fmt.Errorf( - "workspace: inspect prepared checkout source for %q: %w", + "workspace: prepared checkout source for %q is unavailable", entry.path, - err, ) } - if !sourceInfo.Mode().IsRegular() || - sourceInfo.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf( - "workspace: prepared checkout source for %q is not a regular file", - entry.path, + if err := openPreparedCheckoutTemp(gitRoot, entry.temp); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + removePreparedCheckoutTemps( + gitRoot, + []*preparedCheckoutTemp{entry.temp}, + ), ) + }() + if err := verifyPreparedCheckoutTemp(entry); err != nil { + return err } - input, err := gitRoot.Open(entry.temp) + input := entry.temp.file + sourceInfo, err := input.Stat() if err != nil { return fmt.Errorf( - "workspace: open prepared checkout source for %q: %w", + "workspace: inspect prepared checkout source for %q: %w", entry.path, err, ) } - defer func() { - result = errors.Join(result, input.Close()) - }() - openedInfo, err := input.Stat() - if err != nil { - return err - } - if !openedInfo.Mode().IsRegular() || - !os.SameFile(sourceInfo, openedInfo) { + if !samePreparedCheckoutTempState(entry.temp.info, sourceInfo) { return fmt.Errorf( - "workspace: prepared checkout source for %q changed while opening", + "workspace: prepared checkout source for %q changed before copying", entry.path, ) } @@ -535,7 +687,20 @@ func installPreparedCheckoutFile( } }() - copiedDigest := sha256.New() + if _, err := input.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf( + "workspace: rewind prepared checkout source for %q: %w", + entry.path, + err, + ) + } + copiedDigest, err := newPreparedCheckoutBlobHash( + entry.oid, + sourceInfo.Size(), + ) + if err != nil { + return err + } copied, err := io.Copy(io.MultiWriter(output, copiedDigest), input) if err != nil { return fmt.Errorf( @@ -548,34 +713,28 @@ func installPreparedCheckoutFile( if err != nil { return err } - if copied != openedInfo.Size() || - afterCopy.Size() != openedInfo.Size() || - afterCopy.Mode() != openedInfo.Mode() || - !afterCopy.ModTime().Equal(openedInfo.ModTime()) { + copiedOID := hex.EncodeToString(copiedDigest.Sum(nil)) + if copied != sourceInfo.Size() || + !samePreparedCheckoutTempState(sourceInfo, afterCopy) || + !samePreparedCheckoutTempState(entry.temp.info, afterCopy) || + !strings.EqualFold(copiedOID, entry.oid) { return fmt.Errorf( - "workspace: prepared checkout source for %q changed while copying", + "workspace: prepared checkout source for %q changed or did not match the index while copying", entry.path, ) } - if _, err := input.Seek(0, io.SeekStart); err != nil { - return err - } - verifiedDigest := sha256.New() - verified, err := io.Copy(verifiedDigest, input) - if err != nil { - return err - } - afterVerification, err := input.Stat() + verifiedOID, err := preparedCheckoutTempBlobOID( + entry.temp, + entry.path, + entry.oid, + ) if err != nil { return err } - if verified != openedInfo.Size() || - afterVerification.Size() != openedInfo.Size() || - afterVerification.Mode() != openedInfo.Mode() || - !afterVerification.ModTime().Equal(openedInfo.ModTime()) || - !bytes.Equal(copiedDigest.Sum(nil), verifiedDigest.Sum(nil)) { + if !strings.EqualFold(verifiedOID, entry.oid) || + !strings.EqualFold(verifiedOID, copiedOID) { return fmt.Errorf( - "workspace: prepared checkout source for %q changed while verifying", + "workspace: prepared checkout source for %q did not match the index while verifying", entry.path, ) } @@ -616,17 +775,6 @@ func installPreparedCheckoutSymlink( worktreeRoot *os.Root, entry *preparedCheckoutEntry, ) (result error) { - tempInfo, err := repository.gitRoot.Lstat(entry.temp) - if err != nil { - return err - } - if !tempInfo.Mode().IsRegular() && - tempInfo.Mode()&os.ModeSymlink == 0 { - return fmt.Errorf( - "workspace: prepared checkout source for %q has an invalid type", - entry.path, - ) - } target, err := repository.runPrivateGitAt( ctx, ".", diff --git a/internal/workspace/prepared_checkout_unix_test.go b/internal/workspace/prepared_checkout_unix_test.go index 6b912ff..b361503 100644 --- a/internal/workspace/prepared_checkout_unix_test.go +++ b/internal/workspace/prepared_checkout_unix_test.go @@ -5,11 +5,14 @@ package workspace import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" "strings" "testing" + + "golang.org/x/sys/unix" ) func TestPreparedCheckoutMaterializesTrackedEntryTypes(t *testing.T) { @@ -157,6 +160,160 @@ func TestPreparedCheckoutRejectsWorkingTreeFilter(t *testing.T) { } } +func TestRemovePreparedCheckoutTempsPreservesReplacement(t *testing.T) { + path := t.TempDir() + const ( + name = ".merge_file_owned" + original = ".merge_file_owned.original" + content = "trusted\n" + ) + if err := os.WriteFile( + filepath.Join(path, name), + []byte(content), + 0o600, + ); err != nil { + t.Fatalf("write prepared checkout temporary: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open temporary root: %v", err) + } + defer root.Close() + temp := &preparedCheckoutTemp{name: name} + if err := openPreparedCheckoutTemp(root, temp); err != nil { + t.Fatalf("open prepared checkout temporary: %v", err) + } + temps := []*preparedCheckoutTemp{temp} + closed := false + defer func() { + if !closed { + for _, temp := range temps { + _ = temp.file.Close() + } + } + }() + entry := &preparedCheckoutEntry{ + mode: preparedCheckoutRegularMode, + oid: "3136b9e8f996037b1178065c3d107c5053690d7f", + path: "tracked.txt", + temp: temps[0], + } + if err := verifyPreparedCheckoutTemp(entry); err != nil { + t.Fatalf("verify prepared checkout temporary: %v", err) + } + if err := os.Rename( + filepath.Join(path, name), + filepath.Join(path, original), + ); err != nil { + t.Fatalf("rename original temporary: %v", err) + } + if err := os.WriteFile( + filepath.Join(path, name), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement temporary: %v", err) + } + err = removePreparedCheckoutTemps(root, temps) + closed = true + if err == nil || !strings.Contains(err.Error(), "changed identity") { + t.Fatalf("remove replaced prepared checkout temporary error = %v", err) + } + assertFileContents(t, filepath.Join(path, name), "replacement\n") + assertFileContents(t, filepath.Join(path, original), content) +} + +func TestPreparedCheckoutKeepsOpenFilesBounded(t *testing.T) { + var original unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &original); err != nil { + t.Skipf("read open file limit: %v", err) + } + if original.Cur < 64 { + t.Skipf("open file limit %d is already below test limit", original.Cur) + } + limited := original + limited.Cur = 64 + if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &limited); err != nil { + t.Skipf("lower open file limit: %v", err) + } + defer func() { + if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &original); err != nil { + t.Errorf("restore open file limit: %v", err) + } + }() + + repository := newTestRepository(t) + for index := range 96 { + name := filepath.Join(repository, fmt.Sprintf("tracked-%03d.txt", index)) + if err := os.WriteFile(name, []byte("tracked\n"), 0o600); err != nil { + t.Fatalf("write tracked file %d: %v", index, err) + } + } + runGit(t, repository, "add", "--all") + runGit(t, repository, "commit", "-m", "add many tracked files") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err != nil { + t.Fatalf("prepare worktree with bounded open files: %v", err) + } +} + +func TestPreparedCheckoutSupportsSHA256Objects(t *testing.T) { + repository := filepath.Join(t.TempDir(), "repository") + if err := os.MkdirAll(repository, 0o700); err != nil { + t.Fatalf("create SHA-256 repository: %v", err) + } + command := exec.Command( + "git", + "-C", + repository, + "init", + "--object-format=sha256", + "--initial-branch=main", + ) + if output, err := command.CombinedOutput(); err != nil { + t.Skipf("Git does not support SHA-256 repositories: %v\n%s", err, output) + } + runGit(t, repository, "config", "user.name", "Drove Test") + runGit(t, repository, "config", "user.email", "drove@example.invalid") + if err := os.WriteFile( + filepath.Join(repository, "tracked.txt"), + []byte("sha256\n"), + 0o600, + ); err != nil { + t.Fatalf("write SHA-256 tracked file: %v", err) + } + runGit(t, repository, "add", "tracked.txt") + runGit(t, repository, "commit", "-m", "initial") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare SHA-256 worktree: %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, "tracked.txt"), + "sha256\n", + ) +} + func TestParsePreparedCheckoutIndexRejectsInvalidEntries(t *testing.T) { validOID := strings.Repeat("0", 40) tests := map[string]string{ From e0a97d8453c33e51d5d191ed1cf97f71e73bbcdd Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:25:11 +0800 Subject: [PATCH 057/104] fix: reject replaced Git directories in includes --- internal/workspace/include.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index dba8b83..0c64856 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -572,9 +572,20 @@ func (m *Manager) runIncludeManifest( output, commandErr := runCommand(command, cleanupCommand) var sourceVerificationErr error - if commandErr == nil && - repository.gitRoot != nil && - verifyRealPathRoot(repository.gitPath, repository.gitRoot) == nil { + if commandErr == nil && repository.gitRoot != nil { + sourceVerificationErr = verifyRealPathRoot( + repository.gitPath, + repository.gitRoot, + ) + if sourceVerificationErr == nil && repository.commonRoot != nil { + sourceVerificationErr = verifyRealPathRoot( + repository.commonPath, + repository.commonRoot, + ) + } + } + if commandErr == nil && sourceVerificationErr == nil && + repository.gitRoot != nil { if _, err := reader.Seek(0, io.SeekStart); err != nil { sourceVerificationErr = err } else { From 5ae65f352455e97c1b275a68f8e9a92d5acdf077 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:28:41 +0800 Subject: [PATCH 058/104] fix: reopen pinned Windows directories --- internal/workspace/record_rename_windows.go | 46 ++++++++------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 4ad1cb8..52d0253 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -18,6 +18,8 @@ type recordRenameInformation struct { FileName [1]uint16 } +var reopenFile = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReOpenFile") + func renameRecordFile( directory *os.File, temporary *os.File, @@ -255,36 +257,22 @@ func openRecordDirectory(root *os.Root) (_ *os.File, result error) { if err != nil { return nil, fmt.Errorf("inspect directory anchor: %w", err) } - objectName, err := windows.NewNTUnicodeString(".") - if err != nil { - return nil, err - } - attributes := &windows.OBJECT_ATTRIBUTES{ - Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), - RootDirectory: windows.Handle(anchor.Fd()), - ObjectName: objectName, - } - var ( - handle windows.Handle - status windows.IO_STATUS_BLOCK + rawHandle, _, callErr := reopenFile.Call( + anchor.Fd(), + uintptr(windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE), + uintptr( + windows.FILE_SHARE_READ| + windows.FILE_SHARE_WRITE| + windows.FILE_SHARE_DELETE, + ), + uintptr( + windows.FILE_FLAG_BACKUP_SEMANTICS| + windows.FILE_FLAG_OPEN_REPARSE_POINT, + ), ) - err = windows.NtCreateFile( - &handle, - windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, - attributes, - &status, - nil, - windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - windows.FILE_OPEN, - windows.FILE_SYNCHRONOUS_IO_NONALERT| - windows.FILE_DIRECTORY_FILE| - windows.FILE_OPEN_REPARSE_POINT, - 0, - 0, - ) - if err != nil { - return nil, err + handle := windows.Handle(rawHandle) + if handle == windows.InvalidHandle { + return nil, fmt.Errorf("reopen writable directory handle: %w", callErr) } directory := os.NewFile(uintptr(handle), ".") if directory == nil { From 3dd6f24a73a2871f396942055293fb256c8c497a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:33:55 +0800 Subject: [PATCH 059/104] fix: reopen Windows roots with NT semantics --- internal/workspace/record_rename_windows.go | 46 +++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 52d0253..e0cdf3e 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -18,8 +18,6 @@ type recordRenameInformation struct { FileName [1]uint16 } -var reopenFile = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReOpenFile") - func renameRecordFile( directory *os.File, temporary *os.File, @@ -257,22 +255,36 @@ func openRecordDirectory(root *os.Root) (_ *os.File, result error) { if err != nil { return nil, fmt.Errorf("inspect directory anchor: %w", err) } - rawHandle, _, callErr := reopenFile.Call( - anchor.Fd(), - uintptr(windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE), - uintptr( - windows.FILE_SHARE_READ| - windows.FILE_SHARE_WRITE| - windows.FILE_SHARE_DELETE, - ), - uintptr( - windows.FILE_FLAG_BACKUP_SEMANTICS| - windows.FILE_FLAG_OPEN_REPARSE_POINT, - ), + objectName, err := windows.NewNTUnicodeString("") + if err != nil { + return nil, err + } + attributes := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: windows.Handle(anchor.Fd()), + ObjectName: objectName, + } + var ( + handle windows.Handle + status windows.IO_STATUS_BLOCK + ) + err = windows.NtCreateFile( + &handle, + windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, + attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + windows.FILE_SYNCHRONOUS_IO_NONALERT| + windows.FILE_DIRECTORY_FILE| + windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, ) - handle := windows.Handle(rawHandle) - if handle == windows.InvalidHandle { - return nil, fmt.Errorf("reopen writable directory handle: %w", callErr) + if err != nil { + return nil, fmt.Errorf("reopen writable directory handle: %w", err) } directory := os.NewFile(uintptr(handle), ".") if directory == nil { From 191cdb7bca63e93c96e524a1c332d5debdac44bb Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:42:08 +0800 Subject: [PATCH 060/104] test: fix workspace repository line endings --- internal/workspace/workspace_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index da1223a..db0f235 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -2299,6 +2299,7 @@ func newTestRepository(t *testing.T) string { t.Fatalf("create repository: %v", err) } runGit(t, repository, "init", "--initial-branch=main") + runGit(t, repository, "config", "core.autocrlf", "false") runGit(t, repository, "config", "user.name", "Drove Test") runGit(t, repository, "config", "user.email", "drove@example.invalid") files := map[string]string{ @@ -2334,6 +2335,7 @@ func initTestRepositoryAt(t *testing.T, path string) { t.Fatalf("create repository: %v", err) } runGit(t, path, "init", "--initial-branch=main") + runGit(t, path, "config", "core.autocrlf", "false") runGit(t, path, "config", "user.name", "Drove Test") runGit(t, path, "config", "user.email", "drove@example.invalid") if err := os.WriteFile( From 43e6bbec42fd4e62913d93155ec1d44f14b76f83 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:49:47 +0800 Subject: [PATCH 061/104] fix: tolerate completed record source cleanup --- internal/workspace/record_rename_unix.go | 21 ++++++++--- internal/workspace/record_rename_unix_test.go | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index f91690f..8730d86 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -194,20 +194,31 @@ func unlinkLinkedRecordPath( if err := verifyRecordPathIdentity( directory, expected, - name, + witnessName, ); err != nil { - return err + return fmt.Errorf("verify linked record witness: %w", err) } if err := verifyRecordPathIdentity( directory, expected, - witnessName, - ); err != nil { - return fmt.Errorf("verify linked record witness: %w", err) + name, + ); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err } fd := int(directory.Fd()) isolatedName := ".drove-install-" + uuid.NewString() if err := unix.Renameat(fd, name, fd, isolatedName); err != nil { + if errors.Is(err, os.ErrNotExist) { + if witnessErr := verifyRecordPathIdentity( + directory, + expected, + witnessName, + ); witnessErr == nil { + return nil + } + } return fmt.Errorf("isolate linked record path %q: %w", name, err) } restore := func() error { diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index b46d9f1..c3bd981 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -125,6 +125,43 @@ func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { assertFileContents(t, movedPath, "original\n") } +func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( + t *testing.T, +) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + if err := os.Remove(sourcePath); err != nil { + t.Fatalf("remove source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + + if err := unlinkLinkedRecordPath( + directory, + expected, + "source", + "target", + ); err != nil { + t.Fatalf("accept missing linked source: %v", err) + } + assertFileContents(t, targetPath, "record\n") +} + func TestRecoverRecordInstallAliasUsesSameFileWitness(t *testing.T) { rootPath := t.TempDir() sourcePath := filepath.Join(rootPath, "source") From 1231d35dce7ee3aac97ce4c98501d85e0f038c19 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 06:57:53 +0800 Subject: [PATCH 062/104] fix: lock Windows include manifests for reading --- internal/workspace/include_manifest_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workspace/include_manifest_windows.go b/internal/workspace/include_manifest_windows.go index 36c909a..9d4a1cc 100644 --- a/internal/workspace/include_manifest_windows.go +++ b/internal/workspace/include_manifest_windows.go @@ -33,7 +33,7 @@ func configureIncludeManifestCommand( } handle, err := windows.CreateFile( name, - windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, From 726a8efc6b465488f0fc943f372c25fe0de0f705 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 14:34:31 +0800 Subject: [PATCH 063/104] test: accept locked Windows source roots --- internal/workspace/include_security_test.go | 50 ++++++++++++--------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 3f26ded..2c9cb81 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -54,18 +54,21 @@ func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { defer destination.Close() openedSourcePath := sourcePath + "-opened" - if err := os.Rename(sourcePath, openedSourcePath); err != nil { - t.Fatalf("move opened source: %v", err) + renameErr := os.Rename(sourcePath, openedSourcePath) + if renameErr != nil && runtime.GOOS != "windows" { + t.Fatalf("move opened source: %v", renameErr) } - if err := os.Mkdir(sourcePath, 0o700); err != nil { - t.Fatalf("replace source directory: %v", err) - } - if err := os.WriteFile( - filepath.Join(sourcePath, "secret"), - []byte("replacement\n"), - 0o600, - ); err != nil { - t.Fatalf("write replacement source: %v", err) + if renameErr == nil { + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("replace source directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(sourcePath, "secret"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement source: %v", err) + } } if err := copyIncludedPath( @@ -208,18 +211,21 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { } openedSourcePath := sourcePath + "-opened" - if err := os.Rename(sourcePath, openedSourcePath); err != nil { - t.Fatalf("move opened source: %v", err) + renameErr := os.Rename(sourcePath, openedSourcePath) + if renameErr != nil && runtime.GOOS != "windows" { + t.Fatalf("move opened source: %v", renameErr) } - if err := os.Mkdir(sourcePath, 0o700); err != nil { - t.Fatalf("replace source directory: %v", err) - } - if err := os.WriteFile( - filepath.Join(sourcePath, "secret"), - []byte("replacement\n"), - 0o600, - ); err != nil { - t.Fatalf("write replacement source: %v", err) + if renameErr == nil { + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("replace source directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(sourcePath, "secret"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement source: %v", err) + } } if _, err := manager.copyIncludedFiles( From 36e1910ef006c22fceead78ac003da80fe60627f Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 14:42:57 +0800 Subject: [PATCH 064/104] test: verify locked source identity on Windows --- internal/workspace/include_security_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 2c9cb81..869527f 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -236,9 +236,13 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { t.Fatalf("copy included files: %v", err) } assertFileContents(t, filepath.Join(target.Path, "secret"), "original\n") - if err := verifyRealPathRoot(sourcePath, source); err == nil { + verifyErr := verifyRealPathRoot(sourcePath, source) + if renameErr == nil && verifyErr == nil { t.Fatal("source replacement passed final identity verification") } + if renameErr != nil && verifyErr != nil { + t.Fatalf("locked source failed final identity verification: %v", verifyErr) + } } func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { From ed23add30d2f49c8b35b4c34c2b540ca23786b74 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 14:50:35 +0800 Subject: [PATCH 065/104] test: release preparation leases before restart --- internal/workspace/preparation_test.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index c255273..5ee1b08 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -17,7 +17,8 @@ import ( func TestReconcilePreparationsAdoptsDurableAndDiscardsOrphan(t *testing.T) { repository := newTestRepository(t) - manager, err := New(filepath.Join(t.TempDir(), "data")) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) if err != nil { t.Fatalf("new manager: %v", err) } @@ -39,15 +40,27 @@ func TestReconcilePreparationsAdoptsDurableAndDiscardsOrphan(t *testing.T) { if err != nil { t.Fatalf("prepare orphan workspace: %v", err) } + if err := durable.preparation.Close(); err != nil { + t.Fatalf("close durable preparation lease: %v", err) + } + durable.preparation = nil + if err := orphan.preparation.Close(); err != nil { + t.Fatalf("close orphan preparation lease: %v", err) + } + orphan.preparation = nil + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } - if err := manager.ReconcilePreparations( + if err := restarted.ReconcilePreparations( context.Background(), []Workspace{durable}, ); err != nil { t.Fatalf("reconcile preparations: %v", err) } - record, exists, err := manager.readWorkspaceRecord(durable.Path) + record, exists, err := restarted.readWorkspaceRecord(durable.Path) if err != nil || !exists || !record.PreparationCommitted { t.Fatalf( "durable preparation record = %+v, exists=%v err=%v", From cf2e94dd0fd8e66d971c5ec9ab188d8af4fa710e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 14:58:22 +0800 Subject: [PATCH 066/104] fix: close recovered Git pointer before cleanup --- .../workspace/prepared_registration_bound.go | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/workspace/prepared_registration_bound.go b/internal/workspace/prepared_registration_bound.go index 47f8215..5a537cd 100644 --- a/internal/workspace/prepared_registration_bound.go +++ b/internal/workspace/prepared_registration_bound.go @@ -221,12 +221,21 @@ func ensureBoundGitPointer( if err != nil { return err } - if oldFile != nil { - defer func() { - result = errors.Join(result, oldFile.Close()) - }() + closeOldFile := func() error { + if oldFile == nil { + return nil + } + closeErr := oldFile.Close() + oldFile = nil + return closeErr } + defer func() { + result = errors.Join(result, closeOldFile()) + }() if exists && current == content { + if err := closeOldFile(); err != nil { + return err + } return errors.Join( cleanupBoundGitPointerTemps(root, newPrefix, content), cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), @@ -304,6 +313,9 @@ func ensureBoundGitPointer( } } } + if err := closeOldFile(); err != nil { + return err + } tempName, tempFile, err := findBoundGitPointerTemp( root, newPrefix, From 37ad65b3b920b6d09b58443e400b37f9ed59ee98 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:06:28 +0800 Subject: [PATCH 067/104] test: use compatible Windows identity handle --- internal/workspace/record_rename_windows_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 69fb899..7fd9771 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -81,7 +81,12 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { t.Fatalf("write source: %v", err) } - expected, err := os.Open(sourcePath) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + expected, err := root.Open("source") if err != nil { t.Fatalf("open source: %v", err) } From 5b34014982095fa7991cc90caa53eb3143c40523 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:13:41 +0800 Subject: [PATCH 068/104] test: await Windows record deletion --- internal/workspace/record_rename_windows_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 7fd9771..213b3c7 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -90,7 +90,6 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if err != nil { t.Fatalf("open source: %v", err) } - defer expected.Close() directory, err := os.Open(rootPath) if err != nil { t.Fatalf("open parent directory: %v", err) @@ -100,6 +99,9 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if err := unlinkRecordPath(directory, expected, "source"); err != nil { t.Fatalf("unlink source while mutation handle is held: %v", err) } + if err := expected.Close(); err != nil { + t.Fatalf("close identity handle: %v", err) + } if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { t.Fatalf("source remains after unlink: %v", err) } From 00fa106cd56b9f602319f28195e6835f0e3fd877 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:20:48 +0800 Subject: [PATCH 069/104] test: release preparation before record cleanup --- internal/workspace/record_removal_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go index 8e53567..35bbf97 100644 --- a/internal/workspace/record_removal_security_test.go +++ b/internal/workspace/record_removal_security_test.go @@ -132,6 +132,9 @@ func TestAcknowledgedRecordRemovalPreservesQuarantineReplacement(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, From d7ab5f7bbb57908cb765d6997c3337350c1a4d61 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:27:44 +0800 Subject: [PATCH 070/104] test: enable Git long paths in repositories --- internal/workspace/workspace_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index db0f235..8f488ad 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -2300,6 +2300,7 @@ func newTestRepository(t *testing.T) string { } runGit(t, repository, "init", "--initial-branch=main") runGit(t, repository, "config", "core.autocrlf", "false") + runGit(t, repository, "config", "core.longpaths", "true") runGit(t, repository, "config", "user.name", "Drove Test") runGit(t, repository, "config", "user.email", "drove@example.invalid") files := map[string]string{ From 3b44f0e772715997ef0d910e730ee8b6d3c98534 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:33:20 +0800 Subject: [PATCH 071/104] test: release lease before bucket replacement --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 6ebccaa..ceba322 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -458,6 +458,9 @@ func TestAcknowledgedRecordRemovalDetectsBucketReplacementAfterOpening( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, From 8432ef242da4ecad7de30d7469a91fb1e5c091a3 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:40:40 +0800 Subject: [PATCH 072/104] test: release lease before repository bucket swap --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index ceba322..56ca609 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -396,6 +396,9 @@ func TestAcknowledgeRemovalRejectsReplacedRepositoryBucket(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, From 65e4fce0296ec4b5b8631e7e3154304fa7077867 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:47:59 +0800 Subject: [PATCH 073/104] test: release lease before source disappearance --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 56ca609..f374264 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -521,6 +521,9 @@ func TestAcknowledgeRemovalPreservesRecordWhenRepositoryDisappears( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, From 855ded436ae721c2044a3468afa74d6eb930dc95 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 15:55:11 +0800 Subject: [PATCH 074/104] test: retain workspace before data directory swap --- internal/workspace/recovery_security_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index f374264..bc95419 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1106,14 +1106,18 @@ func TestListRejectsReplacementDataDirectory(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - if _, err := manager.Prepare( + prepared, err := manager.Prepare( context.Background(), repository, "", testAgentID, - ); err != nil { + ) + if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } original := dataDir + "-original" if err := os.Rename(dataDir, original); err != nil { t.Fatalf("move data directory: %v", err) From 3209b02288831686124dd1e0168b0d04787d1dcf Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:02:53 +0800 Subject: [PATCH 075/104] test: release preparation before quarantine recovery --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index bc95419..eabe130 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1666,6 +1666,9 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 4df0f076c5f9b0e9210d801523246fd08723216d Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:08:57 +0800 Subject: [PATCH 076/104] test: report quarantine reconciliation failure --- internal/workspace/recovery_security_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index eabe130..dac49b0 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1695,12 +1695,17 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { t.Fatalf("write replacement sentinel: %v", err) } - if _, err := manager.ReconcileRemovals(context.Background()); err == nil { + _, reconcileErr := manager.ReconcileRemovals(context.Background()) + if reconcileErr == nil { t.Fatal("reconciliation accepted a replacement at the original path") } assertFileContents(t, sentinel, "replacement\n") if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("quarantined worktree remains or inspect failed: %v", err) + t.Fatalf( + "quarantined worktree remains or inspect failed: %v (reconcile error: %v)", + err, + reconcileErr, + ) } current, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists || From 674224a558b6556d33f2ee1dae6605f824ca6335 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:18:21 +0800 Subject: [PATCH 077/104] test: seed marker before quarantine recovery --- internal/workspace/recovery_security_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index dac49b0..97e1d24 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1681,6 +1681,17 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { if err := manager.replaceWorkspaceRecord(record); err != nil { t.Fatalf("write removal intent: %v", err) } + workspaceRoot, err := openRealPathRoot(prepared.Path) + if err != nil { + t.Fatalf("open workspace root: %v", err) + } + if err := ensureRemovalMarker(workspaceRoot, record); err != nil { + _ = workspaceRoot.Close() + t.Fatalf("install removal marker: %v", err) + } + if err := workspaceRoot.Close(); err != nil { + t.Fatalf("close workspace root: %v", err) + } quarantineName := removalQuarantinePrefix(record) + "57575757-5757-4757-8757-575757575757" quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) From 20b03c976866b3e35f2d887b0a8e6e5dfa8aeb3d Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:26:28 +0800 Subject: [PATCH 078/104] test: release lease before identity downgrade --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 97e1d24..df87b3d 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -959,6 +959,9 @@ func TestReconcilePreparationWithoutIdentityPreservesPresentPath(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 648306057b36b9fadf4299ac8216e91c3378c99c Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:34:03 +0800 Subject: [PATCH 079/104] test: normalize registered worktree path --- internal/workspace/workspace_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 8f488ad..9b4d4a1 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1348,7 +1348,7 @@ func TestReconcileNonForceRemovalPreservesMissingDetachedRegistration(t *testing t.Fatalf("reconcile missing detached worktree error = %v, want ErrDirty", err) } porcelain := runGit(t, repository, "worktree", "list", "--porcelain") - if !strings.Contains(porcelain, prepared.Path) || + if !strings.Contains(porcelain, filepath.ToSlash(prepared.Path)) || !strings.Contains(porcelain, detachedHead) || !strings.Contains(porcelain, "detached") { t.Fatalf("detached registration was not preserved:\n%s", porcelain) From b60036651f1f8835ab5262479ed14f712b83ed72 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:42:12 +0800 Subject: [PATCH 080/104] test: release lease before cleared recovery --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index df87b3d..ea3b84a 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1968,6 +1968,9 @@ func TestReconcileContentsClearedRemovalAcceptsMissingMarker(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 955b977c5ca6e4ebfa1145c923d6c2d78088a645 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 16:49:17 +0800 Subject: [PATCH 081/104] test: release lease before absent-path recovery --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index ea3b84a..31564f9 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -2140,6 +2140,9 @@ func TestReconcileAbsentPathRemovalPreservesReplacementPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 8a0c5a4a265d262381ddd5f9df7d72cb3ece3998 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 17:25:42 +0800 Subject: [PATCH 082/104] test: release lease before cleared rejection --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 31564f9..61d17cc 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -2029,6 +2029,9 @@ func TestReconcileContentsClearedRemovalRejectsUnexpectedEntry(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From f255bc3ac87d2ac2265fa7755ce587f9070169f6 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 17:33:13 +0800 Subject: [PATCH 083/104] test: acknowledge preparation before stale removal --- internal/workspace/recovery_security_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 61d17cc..58521c8 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -2447,6 +2447,9 @@ func TestStaleAcknowledgementPreservesNewWorkspaceRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) From 9881b42abd49689418e6ede5cbcca56cc05765c8 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 19:25:55 +0800 Subject: [PATCH 084/104] test: acknowledge preparation before reservation recovery --- internal/workspace/removal_acknowledgement_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/removal_acknowledgement_test.go b/internal/workspace/removal_acknowledgement_test.go index 6c02f86..62b6ba9 100644 --- a/internal/workspace/removal_acknowledgement_test.go +++ b/internal/workspace/removal_acknowledgement_test.go @@ -311,6 +311,9 @@ func TestAcknowledgeRemovalRecoversManagedPathReservation( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, From 6ecea3d5fa2468d33adcbf6c92a34e874dbf53e3 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 19:34:01 +0800 Subject: [PATCH 085/104] test: acknowledge preparation before cleanup flow --- internal/workspace/workspace_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 9b4d4a1..339f965 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -250,6 +250,9 @@ func TestPrepareListAndCleanupWorktree(t *testing.T) { if _, err := os.Lstat(filepath.Join(prepared.Path, "ignored.key")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("unlisted ignored file exists or inspect failed: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } listed, err := manager.List(context.Background()) if err != nil { From be92961e6d621aec2da270c6cd4a55989c84372e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 21:00:07 +0800 Subject: [PATCH 086/104] fix: harden Unix record deletion --- internal/workspace/include.go | 14 +- internal/workspace/include_manifest_unix.go | 2 +- internal/workspace/record_owned_unix.go | 11 +- internal/workspace/record_rename_unix.go | 66 +-- internal/workspace/record_rename_unix_test.go | 318 ++++++++++++ .../record_unlink_namespace_other.go | 9 + .../workspace/record_unlink_namespace_unix.go | 93 ++++ .../workspace/record_unlink_recovery_unix.go | 288 +++++++++++ internal/workspace/record_unlink_unix.go | 453 ++++++++++++++++++ internal/workspace/secure_remove.go | 16 +- 10 files changed, 1210 insertions(+), 60 deletions(-) create mode 100644 internal/workspace/record_unlink_namespace_other.go create mode 100644 internal/workspace/record_unlink_namespace_unix.go create mode 100644 internal/workspace/record_unlink_recovery_unix.go create mode 100644 internal/workspace/record_unlink_unix.go diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 0c64856..c0c0d29 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -420,12 +420,22 @@ func (m *Manager) runIncludeManifest( } defer func() { verifyErr := verifyRealPathRoot(directory, root) + var cleanupErr error + if verifyErr == nil { + cleanupErr = cleanupRecordDeletionNamespace(root) + } closeErr := root.Close() var removeErr error - if verifyErr == nil { + if verifyErr == nil && cleanupErr == nil { removeErr = os.Remove(directory) } - result = errors.Join(result, verifyErr, closeErr, removeErr) + result = errors.Join( + result, + verifyErr, + cleanupErr, + closeErr, + removeErr, + ) }() name := includeManifestTemporaryName() diff --git a/internal/workspace/include_manifest_unix.go b/internal/workspace/include_manifest_unix.go index 9b25bc3..8345532 100644 --- a/internal/workspace/include_manifest_unix.go +++ b/internal/workspace/include_manifest_unix.go @@ -19,7 +19,7 @@ func configureIncludeManifestCommand( if runtime.GOOS == "linux" { return fmt.Sprintf("/proc/self/fd/%d", fd), true, noCleanup, nil } - return fmt.Sprintf("/dev/fd/%d", fd), true, noCleanup, nil + return fmt.Sprintf("/dev/fd/%d", fd), false, noCleanup, nil } func noCleanup() error { diff --git a/internal/workspace/record_owned_unix.go b/internal/workspace/record_owned_unix.go index b83b47b..29f0b83 100644 --- a/internal/workspace/record_owned_unix.go +++ b/internal/workspace/record_owned_unix.go @@ -3,10 +3,7 @@ package workspace import ( - "fmt" "os" - - "golang.org/x/sys/unix" ) func unlinkOwnedRecordPath( @@ -14,11 +11,5 @@ func unlinkOwnedRecordPath( expected *os.File, name string, ) error { - if err := verifyRecordPathIdentity(directory, expected, name); err != nil { - return err - } - if err := unix.Unlinkat(int(directory.Fd()), name, 0); err != nil { - return fmt.Errorf("remove owned record path %q: %w", name, err) - } - return nil + return unlinkRecordPath(directory, expected, name) } diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index 8730d86..e4f9aad 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -190,6 +190,22 @@ func unlinkLinkedRecordPath( expected *os.File, name string, witnessName string, +) error { + return unlinkLinkedRecordPathAfterValidation( + directory, + expected, + name, + witnessName, + nil, + ) +} + +func unlinkLinkedRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + witnessName string, + afterValidation func(string), ) error { if err := verifyRecordPathIdentity( directory, @@ -207,51 +223,13 @@ func unlinkLinkedRecordPath( } else if err != nil { return err } - fd := int(directory.Fd()) - isolatedName := ".drove-install-" + uuid.NewString() - if err := unix.Renameat(fd, name, fd, isolatedName); err != nil { - if errors.Is(err, os.ErrNotExist) { - if witnessErr := verifyRecordPathIdentity( - directory, - expected, - witnessName, - ); witnessErr == nil { - return nil - } - } - return fmt.Errorf("isolate linked record path %q: %w", name, err) - } - restore := func() error { - if err := unix.Linkat(fd, isolatedName, fd, name, 0); err != nil { - return fmt.Errorf( - "restore linked record path %q: %w", - name, - err, - ) - } - if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { - return fmt.Errorf( - "remove restored linked record isolation %q: %w", - isolatedName, - err, - ) - } - return nil - } - if err := errors.Join( - verifyRecordPathIdentity(directory, expected, isolatedName), - verifyRecordPathIdentity(directory, expected, witnessName), + if err := unlinkRecordPathAfterValidation( + directory, + expected, + name, + afterValidation, ); err != nil { - return errors.Join( - fmt.Errorf("verify isolated linked record path %q: %w", name, err), - restore(), - ) - } - if err := unix.Unlinkat(fd, isolatedName, 0); err != nil { - return errors.Join( - fmt.Errorf("remove isolated linked record path %q: %w", name, err), - restore(), - ) + return fmt.Errorf("remove linked record path %q: %w", name, err) } return nil } diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index c3bd981..848c980 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "golang.org/x/sys/unix" ) func TestRenameRecordRejectsSourceReplacementAfterValidation(t *testing.T) { @@ -125,6 +127,322 @@ func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { assertFileContents(t, movedPath, "original\n") } +func TestUnlinkRecordPreservesReplacementAfterFinalValidation(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + source, err := os.OpenFile( + sourcePath, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + t.Fatalf("create source: %v", err) + } + defer source.Close() + if _, err := source.WriteString("original\n"); err != nil { + t.Fatalf("write source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + movedPath := filepath.Join(rootPath, "source-original") + + err = unlinkRecordPathAfterValidation( + directory, + source, + "source", + func(name string) { + path := filepath.Join(rootPath, name) + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + path, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("unlink accepted a final replacement") + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") +} + +func TestUnlinkLinkedRecordPreservesReplacementAfterFinalValidation( + t *testing.T, +) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + var replacementPath, movedPath string + + err = unlinkLinkedRecordPathAfterValidation( + directory, + expected, + "source", + "target", + func(name string) { + replacementPath = filepath.Join(rootPath, name) + movedPath = replacementPath + ".original" + if err := os.Rename(replacementPath, movedPath); err != nil { + t.Fatalf("move validated linked source: %v", err) + } + if err := os.WriteFile( + replacementPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement linked source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("linked unlink accepted a final replacement") + } + assertFileContents(t, replacementPath, "replacement\n") + assertFileContents(t, movedPath, "original\n") + assertFileContents(t, targetPath, "original\n") +} + +func TestRecoverRecordDeletionRestoresReplacement(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + transactionName := "12121212-1212-4121-8121-121212121212" + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + "source", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := unix.Linkat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + t.Fatalf("link deletion witness: %v", err) + } + if err := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionWitness, + ); err != nil { + t.Fatalf("verify deletion witness: %v", err) + } + if err := markRecordDeletionValidated(transaction); err != nil { + t.Fatalf("mark deletion validated: %v", err) + } + movedPath := filepath.Join(rootPath, "source-original") + if err := os.Rename(sourcePath, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + sourcePath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + if err := unix.Renameat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + t.Fatalf("move replacement candidate: %v", err) + } + if err := transaction.Close(); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + + namespace, err = openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("reopen deletion namespace: %v", err) + } + defer namespace.Close() + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover deletion transaction: %v", err) + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("recovered deletion artifacts remain: %v", entries) + } +} + +func TestRecoverRecordDeletionCompletesValidatedCleanup(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "34343434-3434-4343-8343-343434343434" + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + "source", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := unix.Linkat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + t.Fatalf("link deletion witness: %v", err) + } + if err := markRecordDeletionValidated(transaction); err != nil { + t.Fatalf("mark deletion validated: %v", err) + } + if err := unix.Renameat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + t.Fatalf("move deletion candidate: %v", err) + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionCandidate, + ); err != nil { + t.Fatalf("remove deletion candidate: %v", err) + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + t.Fatalf("remove deletion witness: %v", err) + } + if err := transaction.Sync(); err != nil { + t.Fatalf("sync interrupted deletion transaction: %v", err) + } + if err := transaction.Close(); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover validated deletion cleanup: %v", err) + } + if _, err := os.Lstat(sourcePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("deleted source returned: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("recovered deletion artifacts remain: %v", entries) + } +} + +func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "56565656-5656-4565-8565-565656565656" + if err := unix.Mkdirat( + int(namespace.Fd()), + transactionName, + 0o700, + ); err != nil { + t.Fatalf("create empty deletion transaction: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync empty deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover empty deletion transaction: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("empty deletion transaction remains: %v", entries) + } +} + func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( t *testing.T, ) { diff --git a/internal/workspace/record_unlink_namespace_other.go b/internal/workspace/record_unlink_namespace_other.go new file mode 100644 index 0000000..9d4a860 --- /dev/null +++ b/internal/workspace/record_unlink_namespace_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd + +package workspace + +import "os" + +func cleanupRecordDeletionNamespace(_ *os.Root) error { + return nil +} diff --git a/internal/workspace/record_unlink_namespace_unix.go b/internal/workspace/record_unlink_namespace_unix.go new file mode 100644 index 0000000..8656b7d --- /dev/null +++ b/internal/workspace/record_unlink_namespace_unix.go @@ -0,0 +1,93 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func cleanupRecordDeletionNamespace(root *os.Root) (result error) { + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, directory.Close()) + }() + namespace, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf( + "open private record deletion namespace for cleanup: %w", + err, + ) + } + defer func() { + result = errors.Join(result, namespace.Close()) + }() + if err := validateRecordDeletionDirectory(namespace); err != nil { + return err + } + if err := unix.Flock(int(namespace.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf( + "lock private record deletion namespace for cleanup: %w", + err, + ) + } + if err := recoverRecordDeletionTransactions(directory, namespace); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + return err + } + if len(entries) != 0 { + return errors.New( + "workspace: private record deletion namespace is not empty", + ) + } + current, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if err != nil { + return err + } + expectedInfo, expectedErr := namespace.Stat() + currentInfo, currentErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(expectedErr, currentErr, closeErr); err != nil { + return err + } + if !os.SameFile(expectedInfo, currentInfo) { + return errors.New( + "workspace: private record deletion namespace changed identity", + ) + } + if err := unix.Unlinkat( + int(directory.Fd()), + recordDeletionNamespace, + unix.AT_REMOVEDIR, + ); err != nil { + return fmt.Errorf( + "remove private record deletion namespace: %w", + err, + ) + } + if err := directory.Sync(); err != nil { + return fmt.Errorf( + "sync removed private record deletion namespace: %w", + err, + ) + } + return nil +} diff --git a/internal/workspace/record_unlink_recovery_unix.go b/internal/workspace/record_unlink_recovery_unix.go new file mode 100644 index 0000000..3de1080 --- /dev/null +++ b/internal/workspace/record_unlink_recovery_unix.go @@ -0,0 +1,288 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +func recoverRecordDeletionTransactions( + directory *os.File, + namespace *os.File, +) error { + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + return fmt.Errorf("read private record deletion namespace: %w", err) + } + for _, entry := range entries { + if !canonicalUUID(entry.Name()) { + return fmt.Errorf( + "workspace: private record deletion artifact %q is invalid", + entry.Name(), + ) + } + if err := settleRecordDeletionTransaction( + directory, + namespace, + entry.Name(), + ); err != nil { + return fmt.Errorf( + "workspace: recover record deletion transaction %q: %w", + entry.Name(), + err, + ) + } + } + return nil +} + +func settleRecordDeletionTransaction( + directory *os.File, + namespace *os.File, + name string, +) (result error) { + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + return err + } + transactionOpen := true + defer func() { + if transactionOpen { + result = errors.Join(result, transaction.Close()) + } + }() + if err := validateRecordDeletionDirectory(transaction); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return err + } + if len(entries) == 0 { + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction( + namespace, + name, + ) + } + sourceName, validated, witness, candidate, err := + inspectRecordDeletionTransaction(transaction) + if err != nil { + return err + } + closeFiles := func() error { + var closeErr error + if candidate != nil { + closeErr = errors.Join(closeErr, candidate.Close()) + candidate = nil + } + if witness != nil { + closeErr = errors.Join(closeErr, witness.Close()) + witness = nil + } + return closeErr + } + defer func() { + result = errors.Join(result, closeFiles()) + }() + + switch { + case witness == nil && candidate != nil: + return errors.New( + "workspace: record deletion candidate has no witness", + ) + case !validated && candidate != nil: + return errors.New( + "workspace: unvalidated record deletion has a candidate", + ) + case witness != nil && !validated: + if err := preserveRecordDeletionPath( + directory, + sourceName, + transaction, + recordDeletionWitness, + witness, + ); err != nil { + return err + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + case witness != nil && candidate != nil: + witnessInfo, statErr := witness.Stat() + candidateInfo, candidateErr := candidate.Stat() + if err := errors.Join(statErr, candidateErr); err != nil { + return err + } + if !os.SameFile(witnessInfo, candidateInfo) { + if err := preserveRecordDeletionPath( + directory, + sourceName, + transaction, + recordDeletionCandidate, + candidate, + ); err != nil { + return err + } + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionCandidate, + ); err != nil { + return err + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + case witness != nil: + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + } + if err := closeFiles(); err != nil { + return err + } + if validated { + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionValidated, + ); err != nil { + return err + } + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionSource, + ); err != nil { + return err + } + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction(namespace, name) +} + +func removeSettledRecordDeletionTransaction( + namespace *os.File, + name string, +) error { + if err := unix.Unlinkat( + int(namespace.Fd()), + name, + unix.AT_REMOVEDIR, + ); err != nil { + return fmt.Errorf("remove settled record deletion transaction: %w", err) + } + if err := namespace.Sync(); err != nil { + return fmt.Errorf("sync settled record deletion namespace: %w", err) + } + return nil +} + +func inspectRecordDeletionTransaction( + transaction *os.File, +) ( + sourceName string, + validated bool, + witness *os.File, + candidate *os.File, + result error, +) { + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return "", false, nil, nil, err + } + seen := make(map[string]bool, len(entries)) + for _, entry := range entries { + switch entry.Name() { + case recordDeletionSource, + recordDeletionValidated, + recordDeletionWitness, + recordDeletionCandidate: + default: + return "", false, nil, nil, fmt.Errorf( + "workspace: record deletion transaction entry %q is invalid", + entry.Name(), + ) + } + if seen[entry.Name()] || !entry.Type().IsRegular() { + return "", false, nil, nil, fmt.Errorf( + "workspace: record deletion transaction entry %q is invalid", + entry.Name(), + ) + } + seen[entry.Name()] = true + } + if !seen[recordDeletionSource] { + return "", false, nil, nil, errors.New( + "workspace: record deletion transaction has no source", + ) + } + sourceName, err = readRecordDeletionSource(transaction) + if err != nil { + return "", false, nil, nil, err + } + validated = seen[recordDeletionValidated] + if seen[recordDeletionWitness] { + witness, err = openRecordPath( + transaction, + recordDeletionWitness, + ) + if err != nil { + return "", false, nil, nil, err + } + } + if seen[recordDeletionCandidate] { + candidate, err = openRecordPath( + transaction, + recordDeletionCandidate, + ) + if err != nil { + if witness != nil { + err = errors.Join(err, witness.Close()) + } + return "", false, nil, nil, err + } + } + return sourceName, validated, witness, candidate, nil +} + +func readRecordDeletionSource(transaction *os.File) (string, error) { + file, err := openRecordPath(transaction, recordDeletionSource) + if err != nil { + return "", err + } + raw, readErr := io.ReadAll(io.LimitReader(file, 4097)) + closeErr := file.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New( + "workspace: record deletion source name is too long", + ) + } + name := string(raw) + if err := validateRecordDeletionSourceName(name); err != nil { + return "", err + } + return name, nil +} diff --git a/internal/workspace/record_unlink_unix.go b/internal/workspace/record_unlink_unix.go new file mode 100644 index 0000000..a117a67 --- /dev/null +++ b/internal/workspace/record_unlink_unix.go @@ -0,0 +1,453 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +const ( + recordDeletionNamespace = ".drove-delete-v1" + + recordDeletionSource = "source" + recordDeletionWitness = "witness" + recordDeletionValidated = "validated" + recordDeletionCandidate = "candidate" +) + +func unlinkRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + nil, + ) +} + +func unlinkRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + afterValidation func(string), +) (result error) { + if err := validateRecordDeletionSourceName(name); err != nil { + return err + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, namespace.Close()) + }() + if err := recoverRecordDeletionTransactions(directory, namespace); err != nil { + return err + } + + transactionName := uuid.NewString() + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + name, + ) + if err != nil { + return err + } + transactionPending := true + finish := func(operationErr error) error { + closeErr := transaction.Close() + transactionPending = false + settleErr := settleRecordDeletionTransaction( + directory, + namespace, + transactionName, + ) + return errors.Join(operationErr, closeErr, settleErr) + } + defer func() { + if transactionPending { + result = errors.Join(result, finish(nil)) + } + }() + + // The private witness lets recovery distinguish the owned file from a + // replacement moved into the transaction after validation. + if err := unix.Linkat( + int(directory.Fd()), + name, + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + return finish(fmt.Errorf( + "create record deletion witness for %q: %w", + name, + err, + )) + } + if err := transaction.Sync(); err != nil { + return finish(fmt.Errorf( + "sync record deletion witness for %q: %w", + name, + err, + )) + } + if err := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionWitness, + ); err != nil { + return finish(fmt.Errorf( + "verify record deletion witness for %q: %w", + name, + err, + )) + } + if err := markRecordDeletionValidated(transaction); err != nil { + return finish(err) + } + if afterValidation != nil { + afterValidation(name) + } + if err := unix.Renameat( + int(directory.Fd()), + name, + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + return finish(fmt.Errorf( + "move record deletion candidate %q: %w", + name, + err, + )) + } + verifyErr := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionCandidate, + ) + if verifyErr != nil { + verifyErr = fmt.Errorf( + "record deletion candidate %q changed identity: %w", + name, + verifyErr, + ) + } + return finish(verifyErr) +} + +func openRecordDeletionNamespace( + directory *os.File, +) (*os.File, error) { + parentFD := int(directory.Fd()) + created := false + if err := unix.Mkdirat( + parentFD, + recordDeletionNamespace, + 0o700, + ); err == nil { + created = true + } else if !errors.Is(err, unix.EEXIST) { + return nil, fmt.Errorf( + "create private record deletion namespace: %w", + err, + ) + } + namespace, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if err != nil { + return nil, fmt.Errorf( + "open private record deletion namespace: %w", + err, + ) + } + if created { + if err := unix.Fchmod(int(namespace.Fd()), 0o700); err != nil { + return nil, errors.Join( + fmt.Errorf( + "protect private record deletion namespace: %w", + err, + ), + namespace.Close(), + ) + } + } + if err := validateRecordDeletionDirectory(namespace); err != nil { + return nil, errors.Join(err, namespace.Close()) + } + if err := unix.Flock(int(namespace.Fd()), unix.LOCK_EX); err != nil { + return nil, errors.Join( + fmt.Errorf("lock private record deletion namespace: %w", err), + namespace.Close(), + ) + } + if created { + if err := errors.Join(namespace.Sync(), directory.Sync()); err != nil { + return nil, errors.Join( + fmt.Errorf( + "sync private record deletion namespace: %w", + err, + ), + namespace.Close(), + ) + } + } + return namespace, nil +} + +func openRecordDeletionDirectoryAt( + parent *os.File, + name string, +) (*os.File, error) { + fd, err := unix.Openat( + int(parent.Fd()), + name, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, + 0, + ) + if err != nil { + return nil, err + } + directory := os.NewFile(uintptr(fd), name) + if directory == nil { + _ = unix.Close(fd) + return nil, errors.New("create record deletion directory handle") + } + return directory, nil +} + +func validateRecordDeletionDirectory(directory *os.File) error { + var info unix.Stat_t + if err := unix.Fstat(int(directory.Fd()), &info); err != nil { + return fmt.Errorf("inspect private record deletion directory: %w", err) + } + mode := uint32(info.Mode) + if mode&unix.S_IFMT != unix.S_IFDIR { + return errors.New( + "workspace: private record deletion path is not a directory", + ) + } + if info.Uid != uint32(os.Geteuid()) { + return errors.New( + "workspace: private record deletion directory has the wrong owner", + ) + } + if mode&0o777 != 0o700 { + return errors.New( + "workspace: private record deletion directory has unsafe permissions", + ) + } + return nil +} + +func createRecordDeletionTransaction( + namespace *os.File, + name string, + sourceName string, +) (*os.File, error) { + if err := unix.Mkdirat(int(namespace.Fd()), name, 0o700); err != nil { + return nil, fmt.Errorf("create record deletion transaction: %w", err) + } + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + removeErr := unix.Unlinkat( + int(namespace.Fd()), + name, + unix.AT_REMOVEDIR, + ) + return nil, errors.Join( + fmt.Errorf("open record deletion transaction: %w", err), + removeErr, + namespace.Sync(), + ) + } + discard := func(cause error) (*os.File, error) { + removeSourceErr := unix.Unlinkat( + int(transaction.Fd()), + recordDeletionSource, + 0, + ) + if errors.Is(removeSourceErr, unix.ENOENT) { + removeSourceErr = nil + } + closeErr := transaction.Close() + removeTransactionErr := unix.Unlinkat( + int(namespace.Fd()), + name, + unix.AT_REMOVEDIR, + ) + return nil, errors.Join( + cause, + removeSourceErr, + closeErr, + removeTransactionErr, + namespace.Sync(), + ) + } + if err := unix.Fchmod(int(transaction.Fd()), 0o700); err != nil { + return discard(fmt.Errorf( + "protect record deletion transaction: %w", + err, + )) + } + if err := writeRecordDeletionFile( + transaction, + recordDeletionSource, + []byte(sourceName), + ); err != nil { + return discard(fmt.Errorf( + "write record deletion source: %w", + err, + )) + } + if err := errors.Join(transaction.Sync(), namespace.Sync()); err != nil { + return discard(fmt.Errorf( + "sync record deletion transaction: %w", + err, + )) + } + return transaction, nil +} + +func markRecordDeletionValidated(transaction *os.File) error { + if err := writeRecordDeletionFile( + transaction, + recordDeletionValidated, + nil, + ); err != nil { + return fmt.Errorf("mark record deletion witness validated: %w", err) + } + if err := transaction.Sync(); err != nil { + return fmt.Errorf("sync validated record deletion witness: %w", err) + } + return nil +} + +func writeRecordDeletionFile( + directory *os.File, + name string, + content []byte, +) error { + fd, err := unix.Openat( + int(directory.Fd()), + name, + unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0o600, + ) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return errors.New("create record deletion metadata handle") + } + written, writeErr := file.Write(content) + if writeErr == nil && written != len(content) { + writeErr = io.ErrShortWrite + } + syncErr := file.Sync() + closeErr := file.Close() + return errors.Join(writeErr, syncErr, closeErr) +} + +func validateRecordDeletionSourceName(name string) error { + if name == "" || + name == "." || + name == ".." || + name == recordDeletionNamespace || + strings.ContainsAny(name, "/\x00") { + return fmt.Errorf( + "workspace: record deletion source name %q is invalid", + name, + ) + } + return nil +} + +func preserveRecordDeletionPath( + directory *os.File, + name string, + transaction *os.File, + privateName string, + privateFile *os.File, +) error { + err := unix.Linkat( + int(transaction.Fd()), + privateName, + int(directory.Fd()), + name, + 0, + ) + if err != nil && !errors.Is(err, unix.EEXIST) { + return fmt.Errorf( + "restore record deletion path %q: %w", + name, + err, + ) + } + current, openErr := openRecordPath(directory, name) + if openErr != nil { + return fmt.Errorf( + "open restored record deletion path %q: %w", + name, + openErr, + ) + } + privateInfo, statErr := privateFile.Stat() + currentInfo, currentErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(statErr, currentErr, closeErr); err != nil { + return err + } + if !os.SameFile(privateInfo, currentInfo) { + return fmt.Errorf( + "workspace: record deletion path %q is occupied by another file", + name, + ) + } + if err := directory.Sync(); err != nil { + return fmt.Errorf( + "sync restored record deletion path %q: %w", + name, + err, + ) + } + return nil +} + +func unlinkPrivateRecordPath(directory *os.File, name string) error { + if err := unix.Unlinkat(int(directory.Fd()), name, 0); err != nil { + return fmt.Errorf( + "remove private record deletion path %q: %w", + name, + err, + ) + } + return nil +} + +func readRecordDeletionDirectory( + directory *os.File, +) ([]os.DirEntry, error) { + duplicate, err := openRecordDeletionDirectoryAt(directory, ".") + if err != nil { + return nil, err + } + entries, readErr := duplicate.ReadDir(-1) + closeErr := duplicate.Close() + return entries, errors.Join(readErr, closeErr) +} diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index 08bf85a..b836ba8 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -359,6 +359,12 @@ func ensureRemovalMarker( if err := cleanupRemovalMarkerTemps(root, record); err != nil { return err } + defer func() { + result = errors.Join( + result, + cleanupRecordDeletionNamespace(root), + ) + }() name := removalMarkerName(record) _, err := root.Lstat(name) switch { @@ -534,10 +540,11 @@ func cleanupRemovalMarkerTemps( } removed = true } + var syncErr error if removed { - return syncRecordBucket(root, removalMarkerName(record)) + syncErr = syncRecordBucket(root, removalMarkerName(record)) } - return nil + return errors.Join(syncErr, cleanupRecordDeletionNamespace(root)) } func removeIncompleteRemovalMarker( @@ -669,6 +676,9 @@ func validateClearedRemovalDirectory( record workspaceRecord, requireMarker bool, ) error { + if err := cleanupRecordDeletionNamespace(root); err != nil { + return err + } entries, err := readRootDirectory(root) if err != nil { return err @@ -765,7 +775,7 @@ func removeRemovalMarkerIfPresent( ); err != nil { return fmt.Errorf("workspace: remove removal marker: %w", err) } - return nil + return cleanupRecordDeletionNamespace(root) } func findRemovalQuarantine( From 2dfdf3ebb05ae217d4e41ffbe486c1203bd9cada Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:00:48 +0800 Subject: [PATCH 087/104] fix: fence PTY close behind exit callbacks --- internal/pty/AGENTS.md | 7 ++++--- internal/pty/pty.go | 4 ++++ internal/pty/pty_test.go | 15 ++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/pty/AGENTS.md b/internal/pty/AGENTS.md index 6fced01..5945d40 100644 --- a/internal/pty/AGENTS.md +++ b/internal/pty/AGENTS.md @@ -34,9 +34,10 @@ - `ProcessGroupAlive` 是 session 层解除持久化 cleanup fence 的唯一探测入口;只有 signal 0 返回 `ESRCH` 才报告不存在,`EPERM` 仍报告存活。 - PTY master 只在直接子进程已回收后关闭;`Close` 幂等,并等待读取、进程退出和 - 全部回调及 writer 完成。健康的自然退出先排空读取再关闭 master;进程组清理失败时 - 先关闭 master 打断继承 slave 的不可清理后代,再等待读取结束。两条路径共享唯一的 - master close。 + 全部回调及 writer 完成。并发 `Close` 在关闭 master 前还必须等待 `OnExit` 返回, + 不能越过进程组清理失败建立的退出 fence。健康的自然退出先排空读取再关闭 master; + 进程组清理失败时先关闭 master 打断继承 slave 的不可清理后代,再等待读取结束。 + 两条路径共享唯一的 master close。 - 进程退出码经 `WaitCh` 返回,供状态机迁移到 `Stopped`/`Done`。 ## 约束 diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 212acdc..12c51a2 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -122,6 +122,7 @@ type Session struct { onExit func(info ExitInfo) readDone chan struct{} processExited chan struct{} + exitNotified chan struct{} done chan struct{} // WaitCh 返回进程退出信息(Close 后仍可读取一次)。 @@ -184,6 +185,7 @@ func startWithProcessGroupSignal( onExit: cfg.OnExit, readDone: make(chan struct{}), processExited: make(chan struct{}), + exitNotified: make(chan struct{}), done: make(chan struct{}), WaitCh: make(chan ExitInfo, 1), } @@ -309,6 +311,7 @@ func (s *Session) waitLoop() { if !exitNotified && s.onExit != nil { s.onExit(info) } + close(s.exitNotified) select { case s.WaitCh <- info: default: @@ -532,6 +535,7 @@ func (s *Session) Close() error { s.beginClose() s.closeProcessGroup() <-s.processExited + <-s.exitNotified s.closeMaster() s.joinWriter() <-s.done diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go index 3749554..705a7cd 100644 --- a/internal/pty/pty_test.go +++ b/internal/pty/pty_test.go @@ -689,6 +689,7 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { }, readDone: make(chan struct{}), processExited: make(chan struct{}), + exitNotified: make(chan struct{}), done: make(chan struct{}), WaitCh: make(chan ExitInfo, 1), } @@ -713,6 +714,10 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("exit callback did not start") } + closeResult := make(chan error, 1) + go func() { + closeResult <- sess.Close() + }() outputPassedExitFence := false select { case <-outputEnded: @@ -731,19 +736,19 @@ func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { }) select { - case <-sess.done: + case closeErr := <-closeResult: + if !errors.Is(closeErr, syscall.EPERM) { + t.Fatalf("close error = %v, want EPERM", closeErr) + } case <-time.After(2 * time.Second): _ = syscall.Kill(childPID, syscall.SIGKILL) childNeedsCleanup = false - <-sess.done + <-closeResult t.Fatal("session waited for an inaccessible descendant to close the PTY") } if err := syscall.Kill(childPID, 0); err != nil { t.Fatalf("descendant exited before PTY cleanup completed: %v", err) } - if closeErr := sess.Close(); !errors.Is(closeErr, syscall.EPERM) { - t.Fatalf("close error = %v, want EPERM", closeErr) - } if exit := waitExit(t, exits); !errors.Is(exit.CleanupErr, syscall.EPERM) { t.Fatalf("exit cleanup error = %v, want EPERM", exit.CleanupErr) } From b55494a6a702154e73c2ee4dda8652b2c4a7afeb Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:02:50 +0800 Subject: [PATCH 088/104] fix: persist startup resume cancellation --- internal/session/AGENTS.md | 6 ++- internal/session/managed.go | 6 ++- internal/session/projection.go | 38 +++++++++++++++ internal/session/projection_test.go | 34 ++++++++++++++ internal/session/session.go | 31 +++++++++++-- internal/session/session_test.go | 71 +++++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 8 deletions(-) diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index 3227fab..fa91cac 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -130,8 +130,10 @@ 均成功后必须用后台上下文持久化 `session_lifecycle(startup_resume_completed)`,只有 该事件 durable 后才清除内存资格并释放 resume reservation,避免短命进程退出后 workspace tombstone 抢先提交。取消等待、PTY 启动失败、required hook 失败及完成事件 - 未落库都保留候选。daemon 重启生成并持久化的 recovery `Stopped` 在投影读到完成事件 - 前始终保留该资格,连续启动失败不能把候选静默降级为普通停止会话。 + 未落库都保留候选。用户停止仍在进程组清理失败时以同批 + `session_lifecycle(startup_resume_cancelled)` 持久取消候选,不能在 cleanup fence + 解除后再次自动恢复。daemon 重启生成并持久化的 recovery `Stopped` 在投影读到完成 + 或取消事件前始终保留该资格,连续启动失败不能把候选静默降级为普通停止会话。 - 信号与状态证据 reader 同时接受 v1、v2、typed screen v3 和 typed terminal v4;v2 的 notify 只在 fallback 下确认 Idle。未知补充版本按既有计数策略跳过,已知畸形版本报错。 adapter 标记为忽略的厂商内部通知不提交事件。 diff --git a/internal/session/managed.go b/internal/session/managed.go index 70c1df8..9c9e35b 100644 --- a/internal/session/managed.go +++ b/internal/session/managed.go @@ -117,10 +117,12 @@ func (m *managedAgent) completeResumeOnStart( return true, err } -func (m *managedAgent) cancelResumeOnStartAfter(commit func() error) error { +func (m *managedAgent) cancelResumeOnStartAfter( + commit func(pending bool) error, +) error { m.workspaceMu.Lock() defer m.workspaceMu.Unlock() - if err := commit(); err != nil { + if err := commit(m.workspace.resumeOnStart); err != nil { return err } m.workspace.resumeOnStart = false diff --git a/internal/session/projection.go b/internal/session/projection.go index 7611855..f12295f 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -170,6 +170,8 @@ func (p *recoveryProjector) applyLifecycle(row store.EventRow) error { return p.applyCreated(row) case startupResumeCompletedReason: return p.applyStartupResumeCompleted(row) + case startupResumeCancelledReason: + return p.applyStartupResumeCancelled(row) case workspaceRemovedReason: return p.applyWorkspaceRemoved(row) case processGroupCleanupFailed: @@ -326,6 +328,42 @@ func (p *recoveryProjector) applyStartupResumeCompleted( return nil } +func (p *recoveryProjector) applyStartupResumeCancelled( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "startup resume cancellation has no session history", + ) + } + var payload startupResumeCancelledPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return projectionWrapError( + row, + "decode startup resume cancellation", + err, + ) + } + if payload.Version != 1 { + return projectionError( + row, + "unsupported startup resume cancellation version %d", + payload.Version, + ) + } + if !draft.startupResumePending { + return projectionError( + row, + "startup resume cancellation has no restart resume candidate", + ) + } + draft.restartStopped = false + draft.startupResumePending = false + return nil +} + func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { draft := p.draft(row) if !draft.hasCreated && !draft.hasState { diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index 96bc9da..a5fcf23 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -670,6 +670,40 @@ func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( }}, wantResume: false, }, + { + name: "user stop with process group cleanup failure", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCancelledReason, + Payload: `{"version":1}`, + }, + { + Seq: 11, Timestamp: base.Add(10 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "stopped", + Reason: "process exited code=0", + }, + { + Seq: 12, Timestamp: base.Add(11 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: processGroupCleanupCompleted, + Payload: `{"version":1,"pid":4242}`, + }, + }, + wantResume: false, + }, { name: "required hook failure", finalRows: []store.EventRow{{ diff --git a/internal/session/session.go b/internal/session/session.go index f978032..f6a1e38 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -93,6 +93,7 @@ const ( initialTerminalRows = 40 initialTerminalColumns = 120 startupResumeCompletedReason = "startup_resume_completed" + startupResumeCancelledReason = "startup_resume_cancelled" processGroupCleanupFailed = "process_group_cleanup_failed" processGroupCleanupCompleted = "process_group_cleanup_completed" ) @@ -101,6 +102,10 @@ type startupResumeCompletedPayload struct { Version int `json:"version"` } +type startupResumeCancelledPayload struct { + Version int `json:"version"` +} + type processGroupCleanupPayload struct { Version int `json:"version"` PID int `json:"pid"` @@ -1696,16 +1701,34 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo if err != nil { return } - terminate := func() error { - return running.observer.terminate(observation, lifecycleDrafts) + terminate := func(cancelStartupResume bool) error { + drafts := append([]event.Draft(nil), lifecycleDrafts...) + if cancelStartupResume && info.CleanupErr != nil { + payload, err := json.Marshal(startupResumeCancelledPayload{ + Version: 1, + }) + if err != nil { + return fmt.Errorf( + "session: encode startup resume cancellation: %w", + err, + ) + } + drafts = append(drafts, event.NewSessionLifecycleDraft( + string(id), + string(id), + startupResumeCancelledReason, + string(payload), + )) + } + return running.observer.terminate(observation, drafts) } - if cause == stopCauseUser && info.CleanupErr == nil { + if cause == stopCauseUser { if managed, exists := m.managed(id); exists { _ = managed.cancelResumeOnStartAfter(terminate) return } } - _ = terminate() + _ = terminate(false) } func processObservation( diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8b2d56c..eda364f 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1438,6 +1438,77 @@ func TestUserStopConsumesLiveStartupResumeIntent(t *testing.T) { } } +func TestUserStopWithCleanupFailureCancelsStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + var started pty.Config + manager.startPTY = func(config pty.Config) (launchedSession, error) { + started = config + return &fakeProcessSession{}, nil + } + if _, err := manager.Resume( + context.Background(), + managed.agent.ID(), + ); err != nil { + t.Fatalf("resume agent: %v", err) + } + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + manager.mu.RLock() + running := manager.sessions[managed.agent.ID()] + manager.mu.RUnlock() + if running == nil { + t.Fatal("resumed session is missing") + } + manager.requestStop(managed.agent.ID(), running, stopCauseUser) + started.OnExit(pty.ExitInfo{ + PID: 4242, + CleanupErr: errors.New("process group remains"), + }) + started.OnOutputEnd(0) + + if managed.hasResumeOnStart() { + t.Fatal("cleanup failure retained the live startup resume intent") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay stopped resume: %v", err) + } + cancellationIndex := -1 + cleanupIndex := -1 + stoppedIndex := -1 + for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) { + switch row.Reason { + case startupResumeCancelledReason: + cancellationIndex = index + if row.Payload != `{"version":1}` { + t.Fatalf("startup resume cancellation = %+v", row) + } + case processGroupCleanupFailed: + cleanupIndex = index + } + } + if row.Type == string(event.TypeStateChanged) && + row.To == string(agent.StateStopped) { + stoppedIndex = index + } + } + if cleanupIndex < 0 || + cancellationIndex <= cleanupIndex || + stoppedIndex <= cancellationIndex { + t.Fatalf( + "cleanup index = %d, cancellation index = %d, stopped index = %d, rows = %+v", + cleanupIndex, + cancellationIndex, + stoppedIndex, + rows, + ) + } +} + func TestNormalizeRunMode(t *testing.T) { tests := []struct { name string From 21c8093af99303bf9c311510017a34386fbfd288 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:06:07 +0800 Subject: [PATCH 089/104] fix: harden include path identity checks --- internal/workspace/include.go | 2 +- .../workspace/include_manifest_windows.go | 20 ++++++++- .../include_manifest_windows_test.go | 43 +++++++++++++++++++ internal/workspace/include_security_test.go | 14 ++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/internal/workspace/include.go b/internal/workspace/include.go index c0c0d29..744451c 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -270,7 +270,7 @@ func repositoryIgnoreCase( func trackedPathKey(path string, ignoreCase bool) string { key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) - if ignoreCase || runtime.GOOS == "windows" { + if ignoreCase { key = cases.Fold().String(key) } if runtime.GOOS == "darwin" { diff --git a/internal/workspace/include_manifest_windows.go b/internal/workspace/include_manifest_windows.go index 9d4a1cc..d4155c5 100644 --- a/internal/workspace/include_manifest_windows.go +++ b/internal/workspace/include_manifest_windows.go @@ -37,7 +37,8 @@ func configureIncludeManifestCommand( windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_ATTRIBUTE_NORMAL| + windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, ) if err != nil { @@ -46,6 +47,23 @@ func configureIncludeManifestCommand( err, ) } + var handleInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + handle, + &handleInfo, + ); err != nil { + _ = windows.CloseHandle(handle) + return "", false, nil, fmt.Errorf( + "workspace: inspect include manifest handle: %w", + err, + ) + } + if handleInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return "", false, nil, errors.New( + "workspace: include manifest is a reparse point", + ) + } guard := os.NewFile(uintptr(handle), path) if guard == nil { _ = windows.CloseHandle(handle) diff --git a/internal/workspace/include_manifest_windows_test.go b/internal/workspace/include_manifest_windows_test.go index d405242..994b2cf 100644 --- a/internal/workspace/include_manifest_windows_test.go +++ b/internal/workspace/include_manifest_windows_test.go @@ -3,12 +3,55 @@ package workspace import ( + "errors" "os" "os/exec" "path/filepath" "testing" + + "golang.org/x/sys/windows" ) +func TestConfigureIncludeManifestCommandRejectsReparsePoint(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest") + if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + t.Fatalf("open manifest parent: %v", err) + } + defer root.Close() + manifest, err := root.Open(filepath.Base(path)) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer manifest.Close() + + movedPath := path + ".moved" + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("rename opened manifest: %v", err) + } + if err := os.Symlink(movedPath, path); err != nil { + if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { + t.Skipf("creating symlinks requires additional privilege: %v", err) + } + t.Fatalf("replace manifest with symlink: %v", err) + } + + _, _, cleanup, err := configureIncludeManifestCommand( + exec.Command("cmd"), + manifest, + path, + ) + if cleanup != nil { + defer cleanup() + } + if err == nil { + t.Fatal("configure manifest command accepted a reparse point") + } +} + func TestConfigureIncludeManifestCommandLocksManifestIdentity(t *testing.T) { path := filepath.Join(t.TempDir(), "manifest") if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 869527f..cfbe189 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -11,6 +11,20 @@ import ( "testing" ) +func TestTrackedPathKeyPreservesCaseForCaseSensitiveRepository(t *testing.T) { + got := trackedPathKey("Directory/Secret.local", false) + if got != "Directory/Secret.local" { + t.Fatalf("tracked path key = %q, want case preserved", got) + } +} + +func TestTrackedPathKeyFoldsCaseForCaseInsensitiveRepository(t *testing.T) { + got := trackedPathKey("Directory/Secret.local", true) + if got != "directory/secret.local" { + t.Fatalf("tracked path key = %q, want folded case", got) + } +} + func TestValidateIncludedPathRejectsInvalidUTF8(t *testing.T) { path := string([]byte{'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 0xff}) if _, err := validateIncludedPath(path); err == nil { From 8e90d536eae196c73beb2696311a52d827b1bc8e Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:07:12 +0800 Subject: [PATCH 090/104] fix: validate branch names literally --- internal/workspace/workspace.go | 7 ++++++- internal/workspace/workspace_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index e54da7c..1a3bc58 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1301,11 +1301,16 @@ func (m *Manager) repositoryPaths( func (m *Manager) validateBranch(ctx context.Context, branch string) error { if branch == "" || + branch == "HEAD" || strings.HasPrefix(branch, "-") || strings.ContainsRune(branch, '\x00') { return fmt.Errorf("%w: %q", ErrInvalidBranch, branch) } - if _, err := m.run(ctx, "check-ref-format", "--branch", branch); err != nil { + if _, err := m.run( + ctx, + "check-ref-format", + "refs/heads/"+branch, + ); err != nil { if isExitCode(err, 1) || isExitCode(err, 128) { return errors.Join( fmt.Errorf("%w: %q", ErrInvalidBranch, branch), diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 339f965..3a7398d 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -2292,6 +2292,24 @@ func TestValidateBranchRejectsHEAD(t *testing.T) { } } +func TestValidateBranchRejectsPreviousCheckoutExpression(t *testing.T) { + repository := newTestRepository(t) + runGit(t, repository, "checkout", "-b", "current") + t.Chdir(repository) + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + err = manager.validateBranch(context.Background(), "@{-1}") + if !errors.Is(err, ErrInvalidBranch) { + t.Fatalf( + "validate previous checkout expression error = %v, want ErrInvalidBranch", + err, + ) + } +} + func newTestRepository(t *testing.T) string { t.Helper() if _, err := exec.LookPath("git"); err != nil { From a911ebcb59ceae14f3125049ff13653a6c7333f6 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:12:44 +0800 Subject: [PATCH 091/104] fix: migrate historical preparation records --- internal/workspace/preparation.go | 47 ++++++++++++++++- internal/workspace/preparation_test.go | 57 ++++++++++++++++++++- internal/workspace/repository_capability.go | 41 +++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index 54f3894..bb7dce7 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -43,12 +43,13 @@ func (m *Manager) acknowledgePreparation( } if record.PreparationCommitted && record.BranchOperationID == "" && + record.Version >= workspaceRecordVersion && target.preparation.isClosed() { return target.preparation.Close() } lease := target.preparation temporaryLease := false - if lease == nil { + if lease == nil || lease.isClosed() { lease, err = openRecordedPreparationLease( context.Background(), m, @@ -62,6 +63,31 @@ func (m *Manager) acknowledgePreparation( result = errors.Join(result, lease.Close()) }() } + if record.Version == removalWorkspaceRecordVersion && + record.ExpectedHeadOID == "" { + deriveCtx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + expectedHeadOID, deriveErr := + lease.repository.derivePreparedWorktreeExpectedHead( + deriveCtx, + record.workspace(), + ) + cancel() + if deriveErr != nil { + return fmt.Errorf( + "workspace: derive historical preparation HEAD: %w", + deriveErr, + ) + } + record.ExpectedHeadOID = expectedHeadOID + if !sameWorkspace(record.workspace(), target) { + return errors.New( + "workspace: derived preparation acknowledgement does not match target", + ) + } + } verifyRepository := func(stage string) error { ctx, cancel := context.WithTimeout( context.Background(), @@ -114,6 +140,25 @@ func (m *Manager) acknowledgePreparation( verifyErr, ) } + if record.Version == removalWorkspaceRecordVersion { + upgradeWorkspaceRecord(&record) + if err := m.replaceWorkspaceRecord(record); err != nil { + return fmt.Errorf( + "workspace: migrate historical preparation record: %w", + err, + ) + } + if err := verifyRepository( + "after migrating historical preparation", + ); err != nil { + return err + } + if err := verifyWorktree( + "after migrating historical preparation", + ); err != nil { + return err + } + } if !record.PreparationCommitted { record.PreparationCommitted = true if err := m.replaceWorkspaceRecord(record); err != nil { diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 5ee1b08..7ae078c 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -189,7 +189,9 @@ func TestReconcilePreparationsAcceptsVersionFiveEvidence(t *testing.T) { record, ) } + expectedHeadOID := record.ExpectedHeadOID record.Version = removalWorkspaceRecordVersion + record.ExpectedHeadOID = "" if err := manager.replaceWorkspaceRecord(record); err != nil { t.Fatalf("write version 5 preparation record: %v", err) } @@ -220,13 +222,66 @@ func TestReconcilePreparationsAcceptsVersionFiveEvidence(t *testing.T) { err, ) } - if adopted.Version != removalWorkspaceRecordVersion || + if adopted.Version != workspaceRecordVersion || !adopted.PreparationCommitted || + adopted.ExpectedHeadOID != expectedHeadOID || + adopted.BranchOperationID != "" || adopted.RepositoryEvidence == nil { t.Fatalf("adopted version 5 preparation = %+v", adopted) } } +func TestAcknowledgePreparationMigratesCommittedVersionFiveRecord( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + expectedHeadOID := record.ExpectedHeadOID + record.Version = removalWorkspaceRecordVersion + record.ExpectedHeadOID = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write committed version 5 preparation record: %v", err) + } + + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("migrate committed version 5 preparation: %v", err) + } + migrated, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read migrated preparation: exists=%v err=%v", + exists, + err, + ) + } + if migrated.Version != workspaceRecordVersion || + !migrated.PreparationCommitted || + migrated.ExpectedHeadOID != expectedHeadOID || + migrated.BranchOperationID != "" || + migrated.RepositoryEvidence == nil { + t.Fatalf("migrated version 5 preparation = %+v", migrated) + } +} + func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( t *testing.T, ) { diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index dd415f4..75f7f9b 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -726,6 +726,47 @@ func (r repositoryCapability) verifyBoundPreparedWorktree( ) } +func (r repositoryCapability) derivePreparedWorktreeExpectedHead( + ctx context.Context, + target Workspace, +) (string, error) { + if err := r.verifyBinding(ctx); err != nil { + return "", err + } + registered, exists, err := r.worktreeRegistration(ctx, target.Path) + if err != nil { + return "", err + } + if !exists { + return "", errors.New( + "workspace: prepared worktree registration disappeared", + ) + } + if err := r.verifyPreparedWorktreeRegistration( + target, + registered, + "", + ); err != nil { + return "", err + } + branchOID, exists, err := r.refOID( + ctx, + "refs/heads/"+target.Branch, + ) + if err != nil { + return "", err + } + if !exists || branchOID != registered.head { + return "", errors.New( + "workspace: prepared branch does not match worktree registration", + ) + } + if err := r.verifyBinding(ctx); err != nil { + return "", err + } + return branchOID, nil +} + func (r repositoryCapability) verifyPreparationRefs( ctx context.Context, target Workspace, From 90e3e511ac1ceacf95c9dc385cc24cf4306385a8 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:20:43 +0800 Subject: [PATCH 092/104] fix: verify BSD working tree bytes with Git --- internal/workspace/prepared_checkout_unix.go | 182 ++++++------------ .../workspace/prepared_checkout_unix_test.go | 44 ++++- 2 files changed, 90 insertions(+), 136 deletions(-) diff --git a/internal/workspace/prepared_checkout_unix.go b/internal/workspace/prepared_checkout_unix.go index c766329..290f12c 100644 --- a/internal/workspace/prepared_checkout_unix.go +++ b/internal/workspace/prepared_checkout_unix.go @@ -5,12 +5,9 @@ package workspace import ( "bytes" "context" - "crypto/sha1" - "crypto/sha256" "encoding/hex" "errors" "fmt" - "hash" "io" "os" "path/filepath" @@ -109,8 +106,10 @@ func checkoutPreparedWorktree( switch entry.mode { case preparedCheckoutRegularMode, preparedCheckoutExecutableMode: if err := installPreparedCheckoutFile( - repository.gitRoot, + ctx, + repository, worktreeRoot, + expectedHeadOID, entry, ); err != nil { return err @@ -449,111 +448,43 @@ func bindPreparedCheckoutTemps( return nil } -func verifyPreparedCheckoutTemp(entry *preparedCheckoutEntry) error { - if entry.temp == nil || entry.temp.file == nil { - return fmt.Errorf( - "workspace: prepared checkout source for %q is not open", - entry.path, - ) - } - oid, err := preparedCheckoutTempBlobOID( - entry.temp, - entry.path, - entry.oid, - ) - if err != nil { - return err - } - if !strings.EqualFold(oid, entry.oid) { - return fmt.Errorf( - "workspace: prepared checkout source for %q does not match the index", - entry.path, - ) - } - entry.temp.verified = true - return nil -} - -func preparedCheckoutTempBlobOID( - temp *preparedCheckoutTemp, +func hashPreparedCheckoutBytes( + ctx context.Context, + repository repositoryCapability, + input io.Reader, + output io.Writer, path string, - expectedOID string, + expectedHeadOID string, ) (string, error) { - before, err := temp.file.Stat() - if err != nil { - return "", fmt.Errorf( - "workspace: inspect prepared checkout source for %q: %w", - path, - err, - ) - } - if !samePreparedCheckoutTempState(temp.info, before) { - return "", fmt.Errorf( - "workspace: prepared checkout source for %q changed before reading", - path, - ) - } - if _, err := temp.file.Seek(0, io.SeekStart); err != nil { - return "", fmt.Errorf( - "workspace: rewind prepared checkout source for %q: %w", - path, - err, - ) - } - digest, err := newPreparedCheckoutBlobHash(expectedOID, before.Size()) + command, cleanup, err := repository.privateGitCommandAt( + ctx, + ".", + "--attr-source="+expectedHeadOID, + "hash-object", + "--stdin", + "--path="+path, + ) if err != nil { return "", err } - read, err := io.Copy(digest, temp.file) - if err != nil { - return "", fmt.Errorf( - "workspace: hash prepared checkout source for %q: %w", - path, - err, - ) - } - after, err := temp.file.Stat() - if err != nil { + command.Stdin = io.TeeReader(input, output) + rawOID, commandErr := runGitCommand(command, "") + if err := errors.Join(commandErr, cleanup()); err != nil { return "", fmt.Errorf( - "workspace: inspect prepared checkout source for %q after reading: %w", + "workspace: hash prepared checkout bytes for %q: %w", path, err, ) } - if read != before.Size() || - !samePreparedCheckoutTempState(before, after) || - !samePreparedCheckoutTempState(temp.info, after) { + oid := strings.TrimSpace(string(rawOID)) + if err := validatePreparedCheckoutOID(oid); err != nil { return "", fmt.Errorf( - "workspace: prepared checkout source for %q changed while reading", + "workspace: hash prepared checkout bytes for %q: %w", path, - ) - } - return hex.EncodeToString(digest.Sum(nil)), nil -} - -func newPreparedCheckoutBlobHash( - expectedOID string, - size int64, -) (hash.Hash, error) { - var digest hash.Hash - switch len(expectedOID) { - case sha1.Size * 2: - digest = sha1.New() - case sha256.Size * 2: - digest = sha256.New() - default: - return nil, errors.New( - "workspace: prepared checkout object ID uses an unsupported hash", - ) - } - header := fmt.Sprintf("blob %d\x00", size) - if _, err := io.WriteString(digest, header); err != nil { - return nil, fmt.Errorf( - "workspace: initialize prepared checkout blob hash: %w", err, ) } - return digest, nil + return oid, nil } func samePreparedCheckoutTempState( @@ -597,8 +528,10 @@ func removePreparedCheckoutTemps( } func installPreparedCheckoutFile( - gitRoot *os.Root, + ctx context.Context, + repository repositoryCapability, worktreeRoot *os.Root, + expectedHeadOID string, entry *preparedCheckoutEntry, ) (result error) { if entry.temp == nil { @@ -607,21 +540,21 @@ func installPreparedCheckoutFile( entry.path, ) } - if err := openPreparedCheckoutTemp(gitRoot, entry.temp); err != nil { + if err := openPreparedCheckoutTemp( + repository.gitRoot, + entry.temp, + ); err != nil { return err } defer func() { result = errors.Join( result, removePreparedCheckoutTemps( - gitRoot, + repository.gitRoot, []*preparedCheckoutTemp{entry.temp}, ), ) }() - if err := verifyPreparedCheckoutTemp(entry); err != nil { - return err - } input := entry.temp.file sourceInfo, err := input.Stat() if err != nil { @@ -694,54 +627,49 @@ func installPreparedCheckoutFile( err, ) } - copiedDigest, err := newPreparedCheckoutBlobHash( - entry.oid, - sourceInfo.Size(), + copiedOID, err := hashPreparedCheckoutBytes( + ctx, + repository, + input, + output, + entry.path, + expectedHeadOID, ) if err != nil { return err } - copied, err := io.Copy(io.MultiWriter(output, copiedDigest), input) + afterCopy, err := input.Stat() if err != nil { - return fmt.Errorf( - "workspace: copy prepared checkout path %q: %w", - entry.path, - err, - ) + return err } - afterCopy, err := input.Stat() + outputInfo, err := output.Stat() if err != nil { return err } - copiedOID := hex.EncodeToString(copiedDigest.Sum(nil)) - if copied != sourceInfo.Size() || - !samePreparedCheckoutTempState(sourceInfo, afterCopy) || - !samePreparedCheckoutTempState(entry.temp.info, afterCopy) || - !strings.EqualFold(copiedOID, entry.oid) { + if !samePreparedCheckoutTempState(sourceInfo, afterCopy) || + !samePreparedCheckoutTempState(entry.temp.info, afterCopy) { return fmt.Errorf( - "workspace: prepared checkout source for %q changed or did not match the index while copying", + "workspace: prepared checkout source for %q changed while copying", entry.path, ) } - verifiedOID, err := preparedCheckoutTempBlobOID( - entry.temp, - entry.path, - entry.oid, - ) - if err != nil { - return err + if outputInfo.Size() != sourceInfo.Size() { + return fmt.Errorf( + "workspace: prepared checkout copy for %q is incomplete", + entry.path, + ) } - if !strings.EqualFold(verifiedOID, entry.oid) || - !strings.EqualFold(verifiedOID, copiedOID) { + if !strings.EqualFold(copiedOID, entry.oid) { return fmt.Errorf( - "workspace: prepared checkout source for %q did not match the index while verifying", + "workspace: prepared checkout source for %q does not match the index", entry.path, ) } + entry.temp.verified = true if err := output.Sync(); err != nil { return err } - outputInfo, err := output.Stat() + outputInfo, err = output.Stat() if err != nil { return err } diff --git a/internal/workspace/prepared_checkout_unix_test.go b/internal/workspace/prepared_checkout_unix_test.go index b361503..71e74f6 100644 --- a/internal/workspace/prepared_checkout_unix_test.go +++ b/internal/workspace/prepared_checkout_unix_test.go @@ -160,6 +160,40 @@ func TestPreparedCheckoutRejectsWorkingTreeFilter(t *testing.T) { } } +func TestPreparedCheckoutPreservesTextConversion(t *testing.T) { + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitattributes"), + []byte("tracked.txt text eol=crlf\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, repository, "add", ".gitattributes") + runGit(t, repository, "commit", "-m", "add checkout text conversion") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare converted worktree: %v", err) + } + content, err := os.ReadFile(filepath.Join(prepared.Path, "tracked.txt")) + if err != nil { + t.Fatalf("read converted tracked file: %v", err) + } + if string(content) != "tracked\r\n" { + t.Fatalf("converted tracked file = %q, want CRLF", content) + } +} + func TestRemovePreparedCheckoutTempsPreservesReplacement(t *testing.T) { path := t.TempDir() const ( @@ -192,15 +226,7 @@ func TestRemovePreparedCheckoutTempsPreservesReplacement(t *testing.T) { } } }() - entry := &preparedCheckoutEntry{ - mode: preparedCheckoutRegularMode, - oid: "3136b9e8f996037b1178065c3d107c5053690d7f", - path: "tracked.txt", - temp: temps[0], - } - if err := verifyPreparedCheckoutTemp(entry); err != nil { - t.Fatalf("verify prepared checkout temporary: %v", err) - } + temp.verified = true if err := os.Rename( filepath.Join(path, name), filepath.Join(path, original), From c672a367d4ef8128355f682fa4ec88a1e744a0ba Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:25:43 +0800 Subject: [PATCH 093/104] fix: bind prepared worktree common directory --- internal/workspace/prepared_worktree.go | 6 + internal/workspace/repository_capability.go | 161 +++++++++++++++++++- internal/workspace/source_swap_test.go | 83 ++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 0ec119c..8aab28c 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -387,6 +387,9 @@ func (m *Manager) initializePreparedWorktree( if err := verifyRealPathRoot(gitDirectory, gitRoot); err != nil { return err } + if err := privateRepository.verifyPrivateGitCommonDirectory(); err != nil { + return err + } target.gitDirectory = gitDirectory record, exists, err := m.readWorkspaceRecord(target.Path) @@ -824,6 +827,9 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( ); err != nil { return err } + if err := privateRepository.verifyPrivateGitCommonDirectory(); err != nil { + return err + } return repository.verifyBinding(ctx) } diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 75f7f9b..bfd651a 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -5,12 +5,15 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strings" ) +const maxPrivateGitCommonDirectorySize = 64 * 1024 + type repositoryCapability struct { manager *Manager path string @@ -719,10 +722,166 @@ func (r repositoryCapability) verifyBoundPreparedWorktree( target Workspace, registered registeredWorktree, ) error { - return r.verifyPreparedWorktreeRegistration( + if err := r.verifyPrivateGitCommonDirectory(); err != nil { + return err + } + if err := r.verifyPreparedWorktreeRegistration( target, registered, target.expectedHeadOID, + ); err != nil { + return err + } + return r.verifyPrivateGitCommonDirectory() +} + +func (r repositoryCapability) verifyPrivateGitCommonDirectory() ( + result error, +) { + if r.gitRoot == nil || + r.commonRoot == nil || + r.gitPath == "" || + r.commonPath == "" { + return errors.New( + "workspace: private Git common directory binding is incomplete", + ) + } + if err := verifyRealPathRoot(r.gitPath, r.gitRoot); err != nil { + return err + } + if err := verifyRealPathRoot(r.commonPath, r.commonRoot); err != nil { + return err + } + + const name = "commondir" + pathInfo, err := r.gitRoot.Lstat(name) + if err != nil { + return fmt.Errorf( + "workspace: inspect private Git common directory pointer: %w", + err, + ) + } + if !pathInfo.Mode().IsRegular() || + pathInfo.Mode()&os.ModeSymlink != 0 { + return errors.New( + "workspace: private Git common directory pointer is not a regular file", + ) + } + file, err := r.gitRoot.Open(name) + if err != nil { + return fmt.Errorf( + "workspace: open private Git common directory pointer: %w", + err, + ) + } + defer func() { + result = errors.Join(result, file.Close()) + }() + before, err := file.Stat() + if err != nil { + return fmt.Errorf( + "workspace: inspect opened private Git common directory pointer: %w", + err, + ) + } + if !before.Mode().IsRegular() || + before.Mode()&os.ModeSymlink != 0 || + !os.SameFile(pathInfo, before) { + return errors.New( + "workspace: private Git common directory pointer changed while opening", + ) + } + raw, err := io.ReadAll( + io.LimitReader(file, maxPrivateGitCommonDirectorySize+1), + ) + if err != nil { + return fmt.Errorf( + "workspace: read private Git common directory pointer: %w", + err, + ) + } + if len(raw) > maxPrivateGitCommonDirectorySize { + return errors.New( + "workspace: private Git common directory pointer is too large", + ) + } + after, err := file.Stat() + if err != nil { + return fmt.Errorf( + "workspace: reinspect private Git common directory pointer: %w", + err, + ) + } + current, err := r.gitRoot.Lstat(name) + if err != nil { + return fmt.Errorf( + "workspace: reinspect private Git common directory pointer path: %w", + err, + ) + } + if !after.Mode().IsRegular() || + after.Mode()&os.ModeSymlink != 0 || + !os.SameFile(before, after) || + before.Size() != after.Size() || + before.Mode() != after.Mode() || + !before.ModTime().Equal(after.ModTime()) || + !current.Mode().IsRegular() || + current.Mode()&os.ModeSymlink != 0 || + !os.SameFile(before, current) { + return errors.New( + "workspace: private Git common directory pointer changed while reading", + ) + } + + value := strings.TrimSuffix(string(raw), "\n") + if value == "" || strings.ContainsAny(value, "\x00\r\n") { + return errors.New( + "workspace: private Git common directory pointer is invalid", + ) + } + commonPath := value + if !filepath.IsAbs(commonPath) { + commonPath = filepath.Join(r.gitPath, commonPath) + } + commonPath, err = resolvePath(commonPath) + if err != nil { + return fmt.Errorf( + "workspace: resolve private Git common directory pointer: %w", + err, + ) + } + if commonPath != r.commonPath { + return errors.New( + "workspace: private Git common directory binding changed", + ) + } + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + return fmt.Errorf( + "workspace: open private Git common directory target: %w", + err, + ) + } + defer func() { + result = errors.Join(result, commonRoot.Close()) + }() + expectedIdentity, err := openedDirectoryIdentity(r.commonRoot) + if err != nil { + return err + } + currentIdentity, err := openedDirectoryIdentity(commonRoot) + if err != nil { + return err + } + if currentIdentity != expectedIdentity { + return errors.New( + "workspace: private Git common directory identity changed", + ) + } + return errors.Join( + verifyRealPathRoot(r.gitPath, r.gitRoot), + verifyRealPathRoot(r.commonPath, r.commonRoot), + verifyRealPathRoot(commonPath, commonRoot), ) } diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 2723fcd..d16d3a1 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -1323,6 +1323,89 @@ func TestAcknowledgeRejectsReplacementCommonGitDirectory( } } +func TestAcknowledgeRejectsRedirectedPreparedCommonGitDirectory( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || + record.GitDirectory == "" || + record.RepositoryEvidence == nil { + t.Fatalf( + "read preparation record: exists=%v err=%v record=%+v", + exists, + err, + record, + ) + } + commonDirectory := record.RepositoryEvidence.CommonGitDirectory + replacementCommon := filepath.Join(parent, "replacement-common") + if err := os.CopyFS( + replacementCommon, + os.DirFS(commonDirectory), + ); err != nil { + t.Fatalf("copy common Git directory: %v", err) + } + commonPointer := filepath.Join(record.GitDirectory, "commondir") + originalPointer, err := os.ReadFile(commonPointer) + if err != nil { + t.Fatalf("read prepared common directory pointer: %v", err) + } + if err := os.WriteFile( + commonPointer, + []byte(replacementCommon+"\n"), + 0o600, + ); err != nil { + t.Fatalf("redirect prepared common directory pointer: %v", err) + } + + err = manager.AcknowledgePreparation(prepared) + if err == nil || !strings.Contains( + err.Error(), + "private Git common directory binding changed", + ) { + t.Fatalf( + "acknowledge redirected prepared common directory error = %v", + err, + ) + } + assertGitRefExists( + t, + commonDirectory, + branchOwnershipRef(prepared.branchOperationID), + ) + failed, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read failed preparation record: exists=%v err=%v", exists, err) + } + if failed.PreparationCommitted || failed.BranchOperationID == "" { + t.Fatalf("failed acknowledgement record = %+v", failed) + } + + if err := os.WriteFile(commonPointer, originalPointer, 0o600); err != nil { + t.Fatalf("restore prepared common directory pointer: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored preparation: %v", err) + } +} + func TestAcknowledgeRejectsReplacementSourceGitPointer(t *testing.T) { parent := t.TempDir() repository := filepath.Join(parent, "repository") From 73aff72d2d542ff7ffa37e56728c4f98dda95397 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:41:02 +0800 Subject: [PATCH 094/104] fix(workspace): recover record deletion namespaces Record recovery previously scanned before settling interrupted deletion transactions, and workspace status could observe the internal namespace as user dirt. Recover at both record-operation boundaries and before Git status so retries converge without special-casing internal paths. --- internal/workspace/record_recovery_unix.go | 26 +++- internal/workspace/record_rename_unix_test.go | 119 ++++++++++++++++++ internal/workspace/workspace.go | 10 ++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/internal/workspace/record_recovery_unix.go b/internal/workspace/record_recovery_unix.go index fccc644..b6b26c5 100644 --- a/internal/workspace/record_recovery_unix.go +++ b/internal/workspace/record_recovery_unix.go @@ -21,6 +21,12 @@ type recordInstallAlias struct { } func recoverRecordRenameDebris(root *os.Root) (result error) { + if err := cleanupRecordDeletionNamespace(root); err != nil { + return fmt.Errorf( + "workspace: recover record deletion transactions: %w", + err, + ) + } entries, err := readRootDirectory(root) if err != nil { return err @@ -56,8 +62,11 @@ func recoverRecordRenameDebris(root *os.Root) (result error) { if err != nil { return err } + directoryOpen := true defer func() { - result = errors.Join(result, directory.Close()) + if directoryOpen { + result = errors.Join(result, directory.Close()) + } }() for _, alias := range aliases { file, err := openRecordPath(directory, alias.name) @@ -87,7 +96,20 @@ func recoverRecordRenameDebris(root *os.Root) (result error) { return err } } - return syncRecordDirectory(directory) + if err := syncRecordDirectory(directory); err != nil { + return err + } + directoryOpen = false + if err := directory.Close(); err != nil { + return err + } + if err := cleanupRecordDeletionNamespace(root); err != nil { + return fmt.Errorf( + "workspace: cleanup record deletion transactions: %w", + err, + ) + } + return nil } func validRecordInstallAliasName(name string) bool { diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 848c980..1f5cb2a 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -3,6 +3,7 @@ package workspace import ( + "context" "errors" "os" "path/filepath" @@ -443,6 +444,118 @@ func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { } } +func TestRecoverRecordRenameDebrisSettlesDeletionNamespace(t *testing.T) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + transaction, err := createRecordDeletionTransaction( + namespace, + "67676767-6767-4767-8767-676767676767", + "interrupted-record", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := errors.Join( + transaction.Close(), + namespace.Close(), + directory.Close(), + ); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("recover deletion namespace: %v", err) + } + if _, err := root.Lstat(recordDeletionNamespace); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("deletion namespace remains: %v", err) + } +} + +func TestRemoveRecoversRecordDeletionNamespaceBeforeStatus(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + root, err := os.OpenRoot(prepared.Path) + if err != nil { + t.Fatalf("open workspace root: %v", err) + } + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open workspace directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open workspace deletion namespace: %v", err) + } + transaction, err := createRecordDeletionTransaction( + namespace, + "89898989-8989-4898-8989-898989898989", + "interrupted-marker", + ) + if err != nil { + t.Fatalf("create marker deletion transaction: %v", err) + } + if err := errors.Join( + transaction.Close(), + namespace.Close(), + directory.Close(), + root.Close(), + ); err != nil { + t.Fatalf("close marker deletion transaction: %v", err) + } + status := runGit( + t, + prepared.Path, + "status", + "--porcelain=v1", + "--untracked-files=all", + ) + if !strings.Contains(status, recordDeletionNamespace) { + t.Fatalf("Git status did not observe deletion namespace: %q", status) + } + + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + false, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove after deletion recovery = %+v, %v", result, err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } +} + func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( t *testing.T, ) { @@ -508,6 +621,12 @@ func TestRecoverRecordInstallAliasUsesSameFileWitness(t *testing.T) { if _, err := os.Lstat(aliasPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("install alias remains: %v", err) } + if _, err := root.Lstat(recordDeletionNamespace); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("record deletion namespace remains: %v", err) + } } func TestRecoverRecordInstallAliasRejectsMissingWitness(t *testing.T) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 1a3bc58..2b9bd6d 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1061,6 +1061,16 @@ func (m *Manager) inspectAtRoot( path, ) } + if err := verifyRealPathRoot(path, root); err != nil { + return Workspace{}, err + } + if err := cleanupRecordDeletionNamespace(root); err != nil { + return Workspace{}, fmt.Errorf( + "workspace: recover internal deletions for %q: %w", + path, + err, + ) + } branchOutput, err := m.runRootedGit( ctx, From 4ffe1b1a68ae7b93cf04f290d0aff6eea89ffbf9 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:54:54 +0800 Subject: [PATCH 095/104] fix(workspace): bind deletion directory removal Record cleanup previously closed the validated directory handle before deleting a fixed pathname, so a replacement could be removed and concurrent recoveries could split across flock domains. Hold a stable parent lock, isolate directories through identity-checked no-replace renames, and fail closed where the platform cannot provide that primitive. --- internal/workspace/record_rename_unix.go | 16 +- internal/workspace/record_rename_unix_test.go | 170 ++++++++++++++++++ ...ecord_unlink_namespace_legacy_unix_test.go | 54 ++++++ ...cord_unlink_namespace_linux_darwin_test.go | 139 ++++++++++++++ .../workspace/record_unlink_namespace_unix.go | 81 ++++++++- .../workspace/record_unlink_recovery_unix.go | 61 ++++++- internal/workspace/record_unlink_unix.go | 74 ++++++-- 7 files changed, 574 insertions(+), 21 deletions(-) create mode 100644 internal/workspace/record_unlink_namespace_legacy_unix_test.go create mode 100644 internal/workspace/record_unlink_namespace_linux_darwin_test.go diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index e4f9aad..4119eee 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -206,7 +206,19 @@ func unlinkLinkedRecordPathAfterValidation( name string, witnessName string, afterValidation func(string), -) error { +) (result error) { + if err := validateRecordDeletionSourceName(name); err != nil { + return err + } + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() if err := verifyRecordPathIdentity( directory, expected, @@ -223,7 +235,7 @@ func unlinkLinkedRecordPathAfterValidation( } else if err != nil { return err } - if err := unlinkRecordPathAfterValidation( + if err := unlinkRecordPathAfterValidationLocked( directory, expected, name, diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 1f5cb2a..cb9d711 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -228,6 +229,7 @@ func TestUnlinkLinkedRecordPreservesReplacementAfterFinalValidation( } func TestRecoverRecordDeletionRestoresReplacement(t *testing.T) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() sourcePath := filepath.Join(rootPath, "source") if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { @@ -324,6 +326,7 @@ func TestRecoverRecordDeletionRestoresReplacement(t *testing.T) { } func TestRecoverRecordDeletionCompletesValidatedCleanup(t *testing.T) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() sourcePath := filepath.Join(rootPath, "source") if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { @@ -406,6 +409,7 @@ func TestRecoverRecordDeletionCompletesValidatedCleanup(t *testing.T) { } func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() directory, err := os.Open(rootPath) if err != nil { @@ -444,7 +448,163 @@ func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { } } +func TestRemoveSettledRecordDeletionTransactionRejectsReplacement( + t *testing.T, +) { + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "45454545-4545-4454-8454-454545454545" + if err := unix.Mkdirat( + int(namespace.Fd()), + transactionName, + 0o700, + ); err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + transactionPath := filepath.Join( + rootPath, + recordDeletionNamespace, + transactionName, + ) + originalInfo, err := os.Stat(transactionPath) + if err != nil { + t.Fatalf("inspect deletion transaction: %v", err) + } + originalPath := transactionPath + "-original" + var replacementInfo os.FileInfo + + err = removeSettledRecordDeletionTransactionAfterValidation( + namespace, + transactionName, + originalInfo, + func() { + if err := os.Rename(transactionPath, originalPath); err != nil { + t.Fatalf("move validated deletion transaction: %v", err) + } + if err := os.Mkdir(transactionPath, 0o700); err != nil { + t.Fatalf("create replacement deletion transaction: %v", err) + } + replacementInfo, err = os.Stat(transactionPath) + if err != nil { + t.Fatalf("inspect replacement deletion transaction: %v", err) + } + }, + ) + if err == nil { + t.Fatal("removed a replacement deletion transaction") + } + currentInfo, err := os.Stat(transactionPath) + if err != nil { + t.Fatalf("replacement deletion transaction was removed: %v", err) + } + if !os.SameFile(replacementInfo, currentInfo) { + t.Fatal("replacement deletion transaction changed identity") + } + displacedInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("validated deletion transaction was removed: %v", err) + } + if !os.SameFile(originalInfo, displacedInfo) { + t.Fatal("validated deletion transaction changed identity") + } +} + +func TestCleanupRecordDeletionNamespaceRejectsReplacementAfterValidation( + t *testing.T, +) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + if err := errors.Join(namespace.Close(), directory.Close()); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + namespacePath := filepath.Join(rootPath, recordDeletionNamespace) + originalInfo, err := os.Stat(namespacePath) + if err != nil { + t.Fatalf("inspect deletion namespace: %v", err) + } + originalPath := namespacePath + "-original" + var replacementInfo os.FileInfo + + err = cleanupRecordDeletionNamespaceAfterValidation(root, func() { + if err := os.Rename(namespacePath, originalPath); err != nil { + t.Fatalf("move validated deletion namespace: %v", err) + } + if err := os.Mkdir(namespacePath, 0o700); err != nil { + t.Fatalf("create replacement deletion namespace: %v", err) + } + replacementInfo, err = os.Stat(namespacePath) + if err != nil { + t.Fatalf("inspect replacement deletion namespace: %v", err) + } + }) + if err == nil { + t.Fatal("removed a replacement deletion namespace") + } + currentInfo, err := os.Stat(namespacePath) + if err != nil { + t.Fatalf("replacement deletion namespace was removed: %v", err) + } + if !os.SameFile(replacementInfo, currentInfo) { + t.Fatal("replacement deletion namespace changed identity") + } + displacedInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("validated deletion namespace was removed: %v", err) + } + if !os.SameFile(originalInfo, displacedInfo) { + t.Fatal("validated deletion namespace changed identity") + } +} + +func TestCleanupRecordDeletionNamespaceRejectsIsolationDebris(t *testing.T) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + name := recordDeletionNamespaceIsolationPrefix + + "23232323-2323-4232-8232-232323232323" + if err := root.Mkdir(name, 0o700); err != nil { + t.Fatalf("create deletion namespace debris: %v", err) + } + + err = cleanupRecordDeletionNamespace(root) + if err == nil || !strings.Contains(err.Error(), "no recoverable owner") { + t.Fatalf("cleanup deletion namespace debris error = %v", err) + } + info, err := root.Stat(name) + if err != nil { + t.Fatalf("deletion namespace debris was removed: %v", err) + } + if !info.IsDir() { + t.Fatal("deletion namespace debris changed type") + } +} + func TestRecoverRecordRenameDebrisSettlesDeletionNamespace(t *testing.T) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() root, err := os.OpenRoot(rootPath) if err != nil { @@ -487,6 +647,7 @@ func TestRecoverRecordRenameDebrisSettlesDeletionNamespace(t *testing.T) { } func TestRemoveRecoversRecordDeletionNamespaceBeforeStatus(t *testing.T) { + requireAtomicRecordDeletion(t) repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -559,6 +720,7 @@ func TestRemoveRecoversRecordDeletionNamespaceBeforeStatus(t *testing.T) { func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( t *testing.T, ) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() sourcePath := filepath.Join(rootPath, "source") if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { @@ -594,6 +756,7 @@ func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( } func TestRecoverRecordInstallAliasUsesSameFileWitness(t *testing.T) { + requireAtomicRecordDeletion(t) rootPath := t.TempDir() sourcePath := filepath.Join(rootPath, "source") if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { @@ -681,3 +844,10 @@ func TestRecoverRecordRenameDebrisRejectsLegacyRemovalBeforeMutation( assertFileContents(t, aliasPath, "record\n") assertFileContents(t, legacyPath, "legacy\n") } + +func requireAtomicRecordDeletion(t *testing.T) { + t.Helper() + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("atomic no-replace directory rename is unavailable") + } +} diff --git a/internal/workspace/record_unlink_namespace_legacy_unix_test.go b/internal/workspace/record_unlink_namespace_legacy_unix_test.go new file mode 100644 index 0000000..dfc2e6a --- /dev/null +++ b/internal/workspace/record_unlink_namespace_legacy_unix_test.go @@ -0,0 +1,54 @@ +//go:build dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "os" + "strings" + "testing" +) + +func TestCleanupRecordDeletionNamespaceFailsWithoutNoReplaceRename( + t *testing.T, +) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + expected, err := namespace.Stat() + if err != nil { + t.Fatalf("inspect deletion namespace: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + if err := directory.Close(); err != nil { + t.Fatalf("close record directory: %v", err) + } + + err = cleanupRecordDeletionNamespace(root) + if err == nil || + !strings.Contains( + err.Error(), + "atomic no-replace directory rename is unsupported", + ) { + t.Fatalf("cleanup without no-replace rename error = %v", err) + } + current, err := root.Stat(recordDeletionNamespace) + if err != nil { + t.Fatalf("deletion namespace was removed: %v", err) + } + if !os.SameFile(expected, current) { + t.Fatal("deletion namespace changed identity") + } +} diff --git a/internal/workspace/record_unlink_namespace_linux_darwin_test.go b/internal/workspace/record_unlink_namespace_linux_darwin_test.go new file mode 100644 index 0000000..698fc73 --- /dev/null +++ b/internal/workspace/record_unlink_namespace_linux_darwin_test.go @@ -0,0 +1,139 @@ +//go:build darwin || linux + +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestCleanupRecordDeletionNamespaceWaitsForParentLock(t *testing.T) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + if err := directory.Close(); err != nil { + t.Fatalf("close record directory: %v", err) + } + + lockHolder, err := os.Open(filepath.Clean(rootPath)) + if err != nil { + t.Fatalf("open parent lock holder: %v", err) + } + defer lockHolder.Close() + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_EX); err != nil { + t.Fatalf("lock record directory: %v", err) + } + locked := true + defer func() { + if locked { + _ = unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN) + } + }() + + done := make(chan error, 1) + go func() { + done <- cleanupRecordDeletionNamespace(root) + }() + select { + case err := <-done: + t.Fatalf("cleanup bypassed parent lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN); err != nil { + t.Fatalf("unlock record directory: %v", err) + } + locked = false + select { + case err := <-done: + if err != nil { + t.Fatalf("cleanup after parent unlock: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("cleanup remained blocked after parent unlock") + } +} + +func TestUnlinkLinkedRecordPathLocksParentBeforeSourceCheck(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + defer directory.Close() + lockHolder, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent lock holder: %v", err) + } + defer lockHolder.Close() + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_EX); err != nil { + t.Fatalf("lock record directory: %v", err) + } + locked := true + defer func() { + if locked { + _ = unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN) + } + }() + + done := make(chan error, 1) + go func() { + done <- unlinkLinkedRecordPath( + directory, + expected, + "source", + "target", + ) + }() + select { + case err := <-done: + t.Fatalf("linked unlink bypassed parent lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := os.Remove(sourcePath); err != nil { + t.Fatalf("remove source while unlink waits: %v", err) + } + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN); err != nil { + t.Fatalf("unlock record directory: %v", err) + } + locked = false + select { + case err := <-done: + if err != nil { + t.Fatalf("accept missing linked source after lock: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("linked unlink remained blocked after parent unlock") + } + assertFileContents(t, targetPath, "record\n") +} diff --git a/internal/workspace/record_unlink_namespace_unix.go b/internal/workspace/record_unlink_namespace_unix.go index 8656b7d..5d7d799 100644 --- a/internal/workspace/record_unlink_namespace_unix.go +++ b/internal/workspace/record_unlink_namespace_unix.go @@ -6,11 +6,20 @@ import ( "errors" "fmt" "os" + "strings" + "github.com/google/uuid" "golang.org/x/sys/unix" ) func cleanupRecordDeletionNamespace(root *os.Root) (result error) { + return cleanupRecordDeletionNamespaceAfterValidation(root, nil) +} + +func cleanupRecordDeletionNamespaceAfterValidation( + root *os.Root, + afterValidation func(), +) (result error) { directory, err := openRecordDirectory(root) if err != nil { return err @@ -18,6 +27,18 @@ func cleanupRecordDeletionNamespace(root *os.Root) (result error) { defer func() { result = errors.Join(result, directory.Close()) }() + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() + if err := rejectRecordDeletionNamespaceIsolationDebris(directory); err != nil { + return err + } namespace, err := openRecordDeletionDirectoryAt( directory, recordDeletionNamespace, @@ -31,8 +52,11 @@ func cleanupRecordDeletionNamespace(root *os.Root) (result error) { err, ) } + namespaceOpen := true defer func() { - result = errors.Join(result, namespace.Close()) + if namespaceOpen { + result = errors.Join(result, namespace.Close()) + } }() if err := validateRecordDeletionDirectory(namespace); err != nil { return err @@ -73,9 +97,38 @@ func cleanupRecordDeletionNamespace(root *os.Root) (result error) { "workspace: private record deletion namespace changed identity", ) } + if afterValidation != nil { + afterValidation() + } + isolatedName := recordDeletionNamespaceIsolationPrefix + uuid.NewString() + moved, err := renameDirectoryNoReplace( + directory, + expectedInfo, + recordDeletionNamespace, + isolatedName+".rename", + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "isolate private record deletion namespace: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: private record deletion namespace was not isolated", + ) + } + namespaceOpen = false + if err := namespace.Close(); err != nil { + return fmt.Errorf( + "close isolated private record deletion namespace: %w", + err, + ) + } if err := unix.Unlinkat( int(directory.Fd()), - recordDeletionNamespace, + isolatedName, unix.AT_REMOVEDIR, ); err != nil { return fmt.Errorf( @@ -91,3 +144,27 @@ func cleanupRecordDeletionNamespace(root *os.Root) (result error) { } return nil } + +func rejectRecordDeletionNamespaceIsolationDebris( + directory *os.File, +) error { + entries, err := readRecordDeletionDirectory(directory) + if err != nil { + return fmt.Errorf( + "inspect private record deletion namespace debris: %w", + err, + ) + } + for _, entry := range entries { + if strings.HasPrefix( + entry.Name(), + recordDeletionNamespaceIsolationPrefix, + ) { + return fmt.Errorf( + "workspace: private record deletion namespace debris %q has no recoverable owner", + entry.Name(), + ) + } + } + return nil +} diff --git a/internal/workspace/record_unlink_recovery_unix.go b/internal/workspace/record_unlink_recovery_unix.go index 3de1080..b14f082 100644 --- a/internal/workspace/record_unlink_recovery_unix.go +++ b/internal/workspace/record_unlink_recovery_unix.go @@ -8,6 +8,7 @@ import ( "io" "os" + "github.com/google/uuid" "golang.org/x/sys/unix" ) @@ -64,6 +65,10 @@ func settleRecordDeletionTransaction( return err } if len(entries) == 0 { + transactionInfo, err := transaction.Stat() + if err != nil { + return err + } if err := transaction.Close(); err != nil { return err } @@ -71,6 +76,7 @@ func settleRecordDeletionTransaction( return removeSettledRecordDeletionTransaction( namespace, name, + transactionInfo, ) } sourceName, validated, witness, candidate, err := @@ -173,20 +179,71 @@ func settleRecordDeletionTransaction( ); err != nil { return err } + transactionInfo, err := transaction.Stat() + if err != nil { + return err + } if err := transaction.Close(); err != nil { return err } transactionOpen = false - return removeSettledRecordDeletionTransaction(namespace, name) + return removeSettledRecordDeletionTransaction( + namespace, + name, + transactionInfo, + ) } func removeSettledRecordDeletionTransaction( namespace *os.File, name string, + expected os.FileInfo, +) error { + return removeSettledRecordDeletionTransactionAfterValidation( + namespace, + name, + expected, + nil, + ) +} + +func removeSettledRecordDeletionTransactionAfterValidation( + namespace *os.File, + name string, + expected os.FileInfo, + afterValidation func(), ) error { + if expected == nil || !expected.IsDir() { + return errors.New( + "workspace: settled record deletion transaction identity is invalid", + ) + } + if afterValidation != nil { + afterValidation() + } + isolatedName := recordDeletionTransactionIsolationPrefix + + uuid.NewString() + moved, err := renameDirectoryNoReplace( + namespace, + expected, + name, + isolatedName+".rename", + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "isolate settled record deletion transaction: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: settled record deletion transaction was not isolated", + ) + } if err := unix.Unlinkat( int(namespace.Fd()), - name, + isolatedName, unix.AT_REMOVEDIR, ); err != nil { return fmt.Errorf("remove settled record deletion transaction: %w", err) diff --git a/internal/workspace/record_unlink_unix.go b/internal/workspace/record_unlink_unix.go index a117a67..3bc5401 100644 --- a/internal/workspace/record_unlink_unix.go +++ b/internal/workspace/record_unlink_unix.go @@ -16,10 +16,12 @@ import ( const ( recordDeletionNamespace = ".drove-delete-v1" - recordDeletionSource = "source" - recordDeletionWitness = "witness" - recordDeletionValidated = "validated" - recordDeletionCandidate = "candidate" + recordDeletionNamespaceIsolationPrefix = ".drove-delete-v1-isolated-" + recordDeletionTransactionIsolationPrefix = ".drove-transaction-isolated-" + recordDeletionSource = "source" + recordDeletionWitness = "witness" + recordDeletionValidated = "validated" + recordDeletionCandidate = "candidate" ) func unlinkRecordPath( @@ -44,6 +46,29 @@ func unlinkRecordPathAfterValidation( if err := validateRecordDeletionSourceName(name); err != nil { return err } + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() + return unlinkRecordPathAfterValidationLocked( + directory, + expected, + name, + afterValidation, + ) +} + +func unlinkRecordPathAfterValidationLocked( + directory *os.File, + expected *os.File, + name string, + afterValidation func(string), +) (result error) { namespace, err := openRecordDeletionNamespace(directory) if err != nil { return err @@ -208,6 +233,26 @@ func openRecordDeletionNamespace( return namespace, nil } +func lockRecordDeletionDirectory(directory *os.File) error { + if err := unix.Flock(int(directory.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf( + "lock record directory for private deletion: %w", + err, + ) + } + return nil +} + +func unlockRecordDeletionDirectory(directory *os.File) error { + if err := unix.Flock(int(directory.Fd()), unix.LOCK_UN); err != nil { + return fmt.Errorf( + "unlock record directory after private deletion: %w", + err, + ) + } + return nil +} + func openRecordDeletionDirectoryAt( parent *os.File, name string, @@ -263,14 +308,8 @@ func createRecordDeletionTransaction( } transaction, err := openRecordDeletionDirectoryAt(namespace, name) if err != nil { - removeErr := unix.Unlinkat( - int(namespace.Fd()), - name, - unix.AT_REMOVEDIR, - ) return nil, errors.Join( fmt.Errorf("open record deletion transaction: %w", err), - removeErr, namespace.Sync(), ) } @@ -283,15 +322,20 @@ func createRecordDeletionTransaction( if errors.Is(removeSourceErr, unix.ENOENT) { removeSourceErr = nil } + transactionInfo, statErr := transaction.Stat() closeErr := transaction.Close() - removeTransactionErr := unix.Unlinkat( - int(namespace.Fd()), - name, - unix.AT_REMOVEDIR, - ) + var removeTransactionErr error + if removeSourceErr == nil && statErr == nil && closeErr == nil { + removeTransactionErr = removeSettledRecordDeletionTransaction( + namespace, + name, + transactionInfo, + ) + } return nil, errors.Join( cause, removeSourceErr, + statErr, closeErr, removeTransactionErr, namespace.Sync(), From 809532da5b76e4b8bcf9c437bcfb1772c2d8598d Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 22:58:13 +0800 Subject: [PATCH 096/104] fix(workspace): require immediate Windows record unlink --- internal/workspace/record_rename_windows.go | 35 ++++++++++++++++--- .../workspace/record_rename_windows_test.go | 9 +++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index e0cdf3e..da72f98 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -94,8 +94,11 @@ func unlinkRecordPathAfterValidation( if err != nil { return err } + deletingOpen := true defer func() { - result = errors.Join(result, deleting.Close()) + if deletingOpen { + result = errors.Join(result, deleting.Close()) + } }() expectedInfo, err := expected.Stat() if err != nil { @@ -127,17 +130,39 @@ func unlinkRecordPathAfterValidation( return fmt.Errorf("record path %q changed identity", name) } - deleteFile := byte(1) + disposition := uint32( + windows.FILE_DISPOSITION_DELETE | + windows.FILE_DISPOSITION_POSIX_SEMANTICS | + windows.FILE_DISPOSITION_FORCE_IMAGE_SECTION_CHECK, + ) var status windows.IO_STATUS_BLOCK if err := windows.NtSetInformationFile( windows.Handle(deleting.Fd()), &status, - &deleteFile, - 1, - windows.FileDispositionInformation, + (*byte)(unsafe.Pointer(&disposition)), + uint32(unsafe.Sizeof(disposition)), + windows.FileDispositionInformationEx, ); err != nil { return fmt.Errorf("remove record path %q: %w", name, err) } + if err := deleting.Close(); err != nil { + deletingOpen = false + return fmt.Errorf("close removed record path %q: %w", name, err) + } + deletingOpen = false + + current, err = openRecordForIdentity(directory, name) + if err == nil { + closeErr := current.Close() + return errors.Join( + fmt.Errorf("record path %q remains after removal", name), + closeErr, + ) + } + if err != windows.STATUS_OBJECT_NAME_NOT_FOUND && + err != windows.STATUS_OBJECT_PATH_NOT_FOUND { + return fmt.Errorf("verify removed record path %q: %w", name, err) + } return nil } diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 213b3c7..180a9d5 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -99,12 +99,15 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if err := unlinkRecordPath(directory, expected, "source"); err != nil { t.Fatalf("unlink source while mutation handle is held: %v", err) } + if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { + t.Fatalf("source remains while identity handle is held: %v", err) + } + if err := syncRecordDirectory(directory); err != nil { + t.Fatalf("sync parent after unlink: %v", err) + } if err := expected.Close(); err != nil { t.Fatalf("close identity handle: %v", err) } - if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { - t.Fatalf("source remains after unlink: %v", err) - } } func TestInstallAndReplaceWorkspaceRecordOnWindows(t *testing.T) { From 3584d6d43d3d02a605147640a4263e33e3fb6e90 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 23:05:18 +0800 Subject: [PATCH 097/104] fix(workspace): bind deletion debris recovery --- .../workspace/prepared_registration_bsd.go | 7 +- internal/workspace/recovery_security_test.go | 96 +++++++++++++- internal/workspace/secure_remove.go | 119 ++++++++++++++++-- 3 files changed, 204 insertions(+), 18 deletions(-) diff --git a/internal/workspace/prepared_registration_bsd.go b/internal/workspace/prepared_registration_bsd.go index 93502a6..0c833b2 100644 --- a/internal/workspace/prepared_registration_bsd.go +++ b/internal/workspace/prepared_registration_bsd.go @@ -335,12 +335,6 @@ func finalizePreparedWorktreeAdd( if err := validateBSDStageContents(stageRoot); err != nil { return err } - if err := removeRootDeletionDebris( - repository.commonRoot, - stageName, - ); err != nil { - return err - } ownedStageRoot := stageRoot stageRoot = nil if err := removeOpenedDirectoryFromRoot( @@ -485,6 +479,7 @@ func cleanupBSDPreparedWorktreeStage( if err := removeRootDeletionDebris( repository.commonRoot, stageName, + expectedIdentity, ); err != nil { return err } diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 58521c8..45ed767 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -242,7 +242,7 @@ func TestRemoveOpenedDirectoryPreservesCanonicalReplacement(t *testing.T) { } } -func TestRemoveAllRecoversIsolatedDirectoryDeletion(t *testing.T) { +func TestRemoveAllRejectsUnownedIsolatedDirectoryDeletion(t *testing.T) { parentPath := t.TempDir() debrisName := rootDeletionPrefix("workspace") + "49494949-4949-4949-8949-494949494949" @@ -263,14 +263,102 @@ func TestRemoveAllRecoversIsolatedDirectoryDeletion(t *testing.T) { } defer parent.Close() - if err := removeAllFromRoot(parent, "workspace"); err != nil { - t.Fatalf("recover isolated deletion: %v", err) + if err := removeAllFromRoot(parent, "workspace"); err == nil { + t.Fatal("remove accepted deletion debris without persisted identity") + } + assertFileContents(t, filepath.Join(debrisPath, "old"), "old\n") +} + +func TestRemoveRootDeletionDebrisRecoversPersistedIdentity(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + original, err := openRealPathRoot(originalPath) + if err != nil { + t.Fatalf("open original directory: %v", err) + } + identity, identityErr := openedDirectoryIdentity(original) + closeErr := original.Close() + if err := errors.Join(identityErr, closeErr); err != nil { + t.Fatalf("inspect original directory identity: %v", err) + } + debrisName := rootDeletionName("workspace", identity) + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Rename(originalPath, debrisPath); err != nil { + t.Fatalf("isolate original directory: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeAllFromRoot(parent, "workspace"); err == nil { + t.Fatal("remove inferred deletion ownership from a missing original name") + } + if _, err := os.Lstat(debrisPath); err != nil { + t.Fatalf("untrusted identity-bound debris changed: %v", err) + } + if err := removeRootDeletionDebris( + parent, + "workspace", + identity, + ); err != nil { + t.Fatalf("recover identity-bound deletion: %v", err) } if _, err := os.Lstat(debrisPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("deletion debris remains: %v", err) + t.Fatalf("identity-bound deletion debris remains: %v", err) } } +func TestRemoveRootDeletionDebrisRejectsReplacedIdentity(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + original, err := openRealPathRoot(originalPath) + if err != nil { + t.Fatalf("open original directory: %v", err) + } + identity, identityErr := openedDirectoryIdentity(original) + closeErr := original.Close() + if err := errors.Join(identityErr, closeErr); err != nil { + t.Fatalf("inspect original directory identity: %v", err) + } + if err := os.Rename( + originalPath, + filepath.Join(parentPath, "original-held"), + ); err != nil { + t.Fatalf("retain original directory identity: %v", err) + } + debrisName := rootDeletionName("workspace", identity) + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Mkdir(debrisPath, 0o700); err != nil { + t.Fatalf("create replacement debris: %v", err) + } + replacement := filepath.Join(debrisPath, "replacement") + if err := os.WriteFile(replacement, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement debris: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeRootDeletionDebris( + parent, + "workspace", + identity, + ); err == nil { + t.Fatal("remove accepted replaced identity-bound deletion debris") + } + assertFileContents(t, replacement, "replacement\n") +} + func TestRenameRecordRejectsReplacedTemporaryPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test requires renaming an open file") diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index b836ba8..7ae2116 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -2,6 +2,7 @@ package workspace import ( "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -1200,7 +1201,18 @@ func readRootDirectory(root *os.Root) ([]os.DirEntry, error) { func removeAllFromRoot(root *os.Root, name string) (result error) { info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { - return removeRootDeletionDebris(root, name) + debris, debrisErr := findRootDeletionDebris(root, name, "") + if debrisErr != nil { + return debrisErr + } + if debris != "" { + return fmt.Errorf( + "directory %q has deletion debris %q without persisted identity", + name, + debris, + ) + } + return nil } if err != nil { return err @@ -1279,7 +1291,11 @@ func removeOpenedDirectoryFromRootAfterIsolation( if err != nil { return err } - deleteName := rootDeletionPrefix(recoveryName) + uuid.NewString() + directoryIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf("inspect removed directory identity: %w", err) + } + deleteName := rootDeletionName(recoveryName, directoryIdentity) directory, err = openRecordDirectory(parent) if err != nil { return err @@ -1304,6 +1320,13 @@ func removeOpenedDirectoryFromRootAfterIsolation( if err := verifyRootEntryUnchanged(parent, name, root); err != nil { return err } + isolatedIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf("reinspect removed directory identity: %w", err) + } + if isolatedIdentity != directoryIdentity { + return errors.New("removed directory changed identity during isolation") + } if afterIsolation != nil { afterIsolation(name) } @@ -1323,14 +1346,38 @@ func rootDeletionPrefix(name string) string { return fmt.Sprintf(".drove-delete-%x-", digest[:8]) } +func rootDeletionName(name string, directoryIdentity string) string { + digest := sha256.Sum256([]byte(directoryIdentity)) + return rootDeletionPrefix(name) + + hex.EncodeToString(digest[:]) + "-" + uuid.NewString() +} + func isRootDeletionName(name string, recoveryName string) bool { + _, valid := rootDeletionIdentityDigest(name, recoveryName) + return valid +} + +func rootDeletionIdentityDigest( + name string, + recoveryName string, +) (string, bool) { suffix, found := strings.CutPrefix(name, rootDeletionPrefix(recoveryName)) if !found { - return false + return "", false } suffix = strings.TrimSuffix(suffix, ".rename") - _, err := uuid.Parse(suffix) - return err == nil + identityDigest, operationID, found := strings.Cut(suffix, "-") + if !found || len(identityDigest) != sha256.Size*2 { + return "", false + } + if _, err := hex.DecodeString(identityDigest); err != nil { + return "", false + } + parsed, err := uuid.Parse(operationID) + if err != nil || parsed.String() != operationID { + return "", false + } + return identityDigest, true } func findRootDeletionDebris( @@ -1344,10 +1391,21 @@ func findRootDeletionDebris( } found := "" for _, entry := range entries { - if entry.Name() == ignoredName || - !isRootDeletionName(entry.Name(), recoveryName) { + if entry.Name() == ignoredName { + continue + } + if !strings.HasPrefix( + entry.Name(), + rootDeletionPrefix(recoveryName), + ) { continue } + if !isRootDeletionName(entry.Name(), recoveryName) { + return "", fmt.Errorf( + "deletion debris %q has no persisted directory identity", + entry.Name(), + ) + } info, err := parent.Lstat(entry.Name()) if err != nil { return "", err @@ -1372,15 +1430,60 @@ func findRootDeletionDebris( func removeRootDeletionDebris( parent *os.Root, recoveryName string, -) error { + expectedIdentity string, +) (result error) { name, err := findRootDeletionDebris(parent, recoveryName, "") if err != nil || name == "" { return err } + if !validDirectoryIdentity(expectedIdentity) { + return fmt.Errorf( + "deletion debris %q has no trusted directory identity", + name, + ) + } + expectedIdentityDigest, valid := rootDeletionIdentityDigest( + name, + recoveryName, + ) + if !valid { + return fmt.Errorf( + "deletion debris %q has no persisted directory identity", + name, + ) + } + digest := sha256.Sum256([]byte(expectedIdentity)) + if hex.EncodeToString(digest[:]) != expectedIdentityDigest { + return fmt.Errorf( + "deletion debris %q does not match the trusted directory identity", + name, + ) + } root, err := openRealRootFromRoot(parent, name) if err != nil { return err } + rootOwned := true + defer func() { + if rootOwned { + result = errors.Join(result, root.Close()) + } + }() + directoryIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf( + "inspect deletion debris %q identity: %w", + name, + err, + ) + } + if directoryIdentity != expectedIdentity { + return fmt.Errorf( + "deletion debris %q changed directory identity", + name, + ) + } + rootOwned = false return removeOpenedDirectoryFromRootAfterIsolation( parent, name, From f54655d961150eaef5d5a8872cb08747a3494b3a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 23:21:20 +0800 Subject: [PATCH 098/104] fix(workspace): adopt advanced committed worktrees --- internal/workspace/preparation_test.go | 50 +++++++++++++++++++++++++ internal/workspace/prepared_worktree.go | 11 ++++++ 2 files changed, 61 insertions(+) diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 7ae078c..468f2df 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -282,6 +282,56 @@ func TestAcknowledgePreparationMigratesCommittedVersionFiveRecord( } } +func TestReconcilePreparationsAcceptsCommittedBranchAdvance(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + if err := os.WriteFile( + filepath.Join(prepared.Path, "advanced.txt"), + []byte("advanced\n"), + 0o600, + ); err != nil { + t.Fatalf("write committed workspace change: %v", err) + } + runGit(t, prepared.Path, "add", "advanced.txt") + runGit(t, prepared.Path, "commit", "-m", "advance managed branch") + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + []Workspace{prepared}, + ); err != nil { + t.Fatalf("adopt advanced committed preparation: %v", err) + } + record, exists, err := restarted.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || !record.PreparationCommitted { + t.Fatalf( + "advanced committed record = %+v, exists=%v err=%v", + record, + exists, + err, + ) + } +} + func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( t *testing.T, ) { diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index 8aab28c..9b40874 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -748,6 +748,17 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( "workspace: prepared worktree expected HEAD changed", ) } + if record.PreparationCommitted { + currentHeadOID, err := + repository.derivePreparedWorktreeExpectedHead(ctx, target) + if err != nil { + return fmt.Errorf( + "workspace: inspect committed worktree HEAD: %w", + err, + ) + } + target.expectedHeadOID = currentHeadOID + } if _, err := repository.boundGitDirectory( record.GitDirectory, target.AgentID, From a05587dc0162f81df17248b6b687ca95aee826d9 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 23:21:38 +0800 Subject: [PATCH 099/104] fix(workspace): recover isolated deletion transactions --- internal/workspace/record_rename_unix_test.go | 88 +++++++++++++++++++ .../workspace/record_unlink_recovery_unix.go | 72 +++++++++++++-- 2 files changed, 153 insertions(+), 7 deletions(-) diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index cb9d711..cbea4be 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -448,6 +448,94 @@ func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { } } +func TestRecoverRecordDeletionRemovesIsolatedEmptyTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) + for _, suffix := range []string{"", ".rename"} { + t.Run(suffix, func(t *testing.T) { + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + name := recordDeletionTransactionIsolationPrefix + + "67676767-6767-4767-8767-676767676767" + suffix + if err := unix.Mkdirat( + int(namespace.Fd()), + name, + 0o700, + ); err != nil { + t.Fatalf("create isolated deletion transaction: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync isolated deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover isolated deletion transaction: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf( + "isolated deletion transaction remains: %v", + entries, + ) + } + }) + } +} + +func TestRecoverRecordDeletionRejectsNonemptyIsolatedTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + name := recordDeletionTransactionIsolationPrefix + + "78787878-7878-4787-8787-787878787878" + if err := unix.Mkdirat(int(namespace.Fd()), name, 0o700); err != nil { + t.Fatalf("create isolated deletion transaction: %v", err) + } + sentinel := filepath.Join( + rootPath, + recordDeletionNamespace, + name, + "must-remain", + ) + if err := os.WriteFile(sentinel, []byte("sentinel\n"), 0o600); err != nil { + t.Fatalf("write isolated transaction sentinel: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync isolated deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err == nil { + t.Fatal("recovery accepted a nonempty isolated deletion transaction") + } + assertFileContents(t, sentinel, "sentinel\n") +} + func TestRemoveSettledRecordDeletionTransactionRejectsReplacement( t *testing.T, ) { diff --git a/internal/workspace/record_unlink_recovery_unix.go b/internal/workspace/record_unlink_recovery_unix.go index b14f082..68d167e 100644 --- a/internal/workspace/record_unlink_recovery_unix.go +++ b/internal/workspace/record_unlink_recovery_unix.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/google/uuid" "golang.org/x/sys/unix" @@ -21,27 +22,84 @@ func recoverRecordDeletionTransactions( return fmt.Errorf("read private record deletion namespace: %w", err) } for _, entry := range entries { - if !canonicalUUID(entry.Name()) { + var recoverErr error + switch { + case canonicalUUID(entry.Name()): + recoverErr = settleRecordDeletionTransaction( + directory, + namespace, + entry.Name(), + ) + case isRecordDeletionTransactionIsolationName(entry.Name()): + recoverErr = removeIsolatedRecordDeletionTransaction( + namespace, + entry.Name(), + ) + default: return fmt.Errorf( "workspace: private record deletion artifact %q is invalid", entry.Name(), ) } - if err := settleRecordDeletionTransaction( - directory, - namespace, - entry.Name(), - ); err != nil { + if recoverErr != nil { return fmt.Errorf( "workspace: recover record deletion transaction %q: %w", entry.Name(), - err, + recoverErr, ) } } return nil } +func isRecordDeletionTransactionIsolationName(name string) bool { + suffix, found := strings.CutPrefix( + name, + recordDeletionTransactionIsolationPrefix, + ) + if !found { + return false + } + return canonicalUUID(strings.TrimSuffix(suffix, ".rename")) +} + +func removeIsolatedRecordDeletionTransaction( + namespace *os.File, + name string, +) (result error) { + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + return err + } + transactionOpen := true + defer func() { + if transactionOpen { + result = errors.Join(result, transaction.Close()) + } + }() + if err := validateRecordDeletionDirectory(transaction); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return err + } + if len(entries) != 0 { + return errors.New( + "workspace: isolated record deletion transaction is not empty", + ) + } + info, err := transaction.Stat() + if err != nil { + return err + } + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction(namespace, name, info) +} + func settleRecordDeletionTransaction( directory *os.File, namespace *os.File, From f40b9831fd8c33864ba745a6452e4419e32d73c5 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 23:28:49 +0800 Subject: [PATCH 100/104] fix(workspace): fold Windows namespace lock aliases --- .../workspace/manager_lock_key_windows.go | 29 +++++++++++- .../manager_lock_key_windows_test.go | 46 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/internal/workspace/manager_lock_key_windows.go b/internal/workspace/manager_lock_key_windows.go index f9a9e9a..b30732e 100644 --- a/internal/workspace/manager_lock_key_windows.go +++ b/internal/workspace/manager_lock_key_windows.go @@ -8,5 +8,32 @@ import ( ) func workspaceManagerLockKey(path string) string { - return strings.ToUpper(filepath.Clean(path)) + return strings.ToUpper(filepath.Clean(normalizeWindowsManagerPath(path))) +} + +func normalizeWindowsManagerPath(path string) string { + path = filepath.Clean(path) + for _, prefix := range []string{`\\?\`, `\??\`} { + remainder, found := cutWindowsPrefix(path, prefix) + if !found { + continue + } + if unc, found := cutWindowsPrefix(remainder, `UNC\`); found { + return `\\` + unc + } + if len(remainder) >= 3 && + remainder[1] == ':' && + (remainder[2] == '\\' || remainder[2] == '/') { + return remainder + } + } + return path +} + +func cutWindowsPrefix(path string, prefix string) (string, bool) { + if len(path) < len(prefix) || + !strings.EqualFold(path[:len(prefix)], prefix) { + return "", false + } + return path[len(prefix):], true } diff --git a/internal/workspace/manager_lock_key_windows_test.go b/internal/workspace/manager_lock_key_windows_test.go index 0b4c358..1fb0a58 100644 --- a/internal/workspace/manager_lock_key_windows_test.go +++ b/internal/workspace/manager_lock_key_windows_test.go @@ -15,3 +15,49 @@ func TestWorkspaceManagerLockKeyFoldsPathCase(t *testing.T) { t.Fatal("case aliases received different manager locks") } } + +func TestWorkspaceManagerLockKeyFoldsNamespaceAliases(t *testing.T) { + for _, test := range []struct { + name string + regular string + aliases []string + }{ + { + name: "drive", + regular: `C:\drove\data\worktrees`, + aliases: []string{ + `\\?\C:\drove\data\worktrees`, + `\??\C:\drove\data\worktrees`, + }, + }, + { + name: "unc", + regular: `\\server\share\drove\data\worktrees`, + aliases: []string{ + `\\?\UNC\server\share\drove\data\worktrees`, + `\??\UNC\server\share\drove\data\worktrees`, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + wantKey := workspaceManagerLockKey(test.regular) + wantLock := workspaceManagerLock(test.regular) + for _, alias := range test.aliases { + if got := workspaceManagerLockKey(alias); got != wantKey { + t.Errorf( + "manager lock key for %q = %q, want %q", + alias, + got, + wantKey, + ) + } + if got := workspaceManagerLock(alias); got != wantLock { + t.Errorf( + "manager lock for %q differs from canonical path", + alias, + ) + } + } + }) + } +} From 88c7e4732179ea46fe8fb01fb7ccedabea71be79 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Wed, 7 Oct 2026 23:36:05 +0800 Subject: [PATCH 101/104] test(workspace): exercise committed restart adoption --- internal/workspace/preparation_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index 468f2df..447f44b 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -315,9 +315,15 @@ func TestReconcilePreparationsAcceptsCommittedBranchAdvance(t *testing.T) { if err != nil { t.Fatalf("restart manager: %v", err) } + recovered := Workspace{ + AgentID: prepared.AgentID, + Repository: prepared.Repository, + Path: prepared.Path, + Branch: prepared.Branch, + } if err := restarted.ReconcilePreparations( context.Background(), - []Workspace{prepared}, + []Workspace{recovered}, ); err != nil { t.Fatalf("adopt advanced committed preparation: %v", err) } From 36a15e22ce61022b6cd88008970945e4679e4af2 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Thu, 8 Oct 2026 00:51:15 +0800 Subject: [PATCH 102/104] fix: unblock Windows workspace cleanup --- internal/workspace/record_owned.go | 2 +- internal/workspace/record_owned_other.go | 4 +++ internal/workspace/record_owned_unix.go | 4 +++ internal/workspace/record_owned_windows.go | 15 +++++++++- .../workspace/record_rename_windows_test.go | 9 ++++-- internal/workspace/recovery_security_test.go | 3 ++ internal/workspace/workspace_test.go | 30 +++++++++++++++++++ 7 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/workspace/record_owned.go b/internal/workspace/record_owned.go index ca28e33..71b17cd 100644 --- a/internal/workspace/record_owned.go +++ b/internal/workspace/record_owned.go @@ -96,7 +96,7 @@ func removeOwnedRecordPath( ); err != nil { return err } - if err := syncRecordDirectory(directory); err != nil { + if err := syncOwnedRecordDirectoryAfterUnlink(directory); err != nil { return fmt.Errorf( "workspace: sync removed owned record path %q: %w", name, diff --git a/internal/workspace/record_owned_other.go b/internal/workspace/record_owned_other.go index 66602fc..42cf6a6 100644 --- a/internal/workspace/record_owned_other.go +++ b/internal/workspace/record_owned_other.go @@ -16,3 +16,7 @@ func unlinkOwnedRecordPath( "workspace: owned record removal is unsupported on this platform", ) } + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + return syncRecordDirectory(directory) +} diff --git a/internal/workspace/record_owned_unix.go b/internal/workspace/record_owned_unix.go index 29f0b83..d4a3aa6 100644 --- a/internal/workspace/record_owned_unix.go +++ b/internal/workspace/record_owned_unix.go @@ -13,3 +13,7 @@ func unlinkOwnedRecordPath( ) error { return unlinkRecordPath(directory, expected, name) } + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + return syncRecordDirectory(directory) +} diff --git a/internal/workspace/record_owned_windows.go b/internal/workspace/record_owned_windows.go index 014498e..98e4f91 100644 --- a/internal/workspace/record_owned_windows.go +++ b/internal/workspace/record_owned_windows.go @@ -2,7 +2,12 @@ package workspace -import "os" +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) func unlinkOwnedRecordPath( directory *os.File, @@ -11,3 +16,11 @@ func unlinkOwnedRecordPath( ) error { return unlinkRecordPath(directory, expected, name) } + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + err := syncRecordDirectory(directory) + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil + } + return err +} diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 180a9d5..61f56cf 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -90,11 +90,14 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if err != nil { t.Fatalf("open source: %v", err) } - directory, err := os.Open(rootPath) + directory, err := openRecordDirectory(root) if err != nil { - t.Fatalf("open parent directory: %v", err) + t.Fatalf("open syncable parent directory: %v", err) } defer directory.Close() + if err := syncRecordDirectory(directory); err != nil { + t.Fatalf("sync parent before unlink: %v", err) + } if err := unlinkRecordPath(directory, expected, "source"); err != nil { t.Fatalf("unlink source while mutation handle is held: %v", err) @@ -102,7 +105,7 @@ func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { t.Fatalf("source remains while identity handle is held: %v", err) } - if err := syncRecordDirectory(directory); err != nil { + if err := syncOwnedRecordDirectoryAfterUnlink(directory); err != nil { t.Fatalf("sync parent after unlink: %v", err) } if err := expected.Close(); err != nil { diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 45ed767..409a596 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -2330,6 +2330,9 @@ func TestAcknowledgeRemovalRejectsWrongTokenForQuarantinedRecord( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 3a7398d..3ee2cb7 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -493,6 +493,9 @@ func TestCleanupRepairsMissingWorktreeRegistration(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } if err := os.RemoveAll(prepared.Path); err != nil { t.Fatalf("remove worktree directory externally: %v", err) } @@ -853,6 +856,9 @@ func TestVersionOneRecordRequiresForceEvenWhenPathIsMissing(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } legacy, err := json.Marshal(struct { Version int `json:"version"` AgentID string `json:"agent_id"` @@ -1083,6 +1089,9 @@ func TestRemoveRevalidatesExistingNonForceIntent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1203,6 +1212,9 @@ func TestReconcileRemovalDeletesPresentUnregisteredPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1265,6 +1277,9 @@ func TestReconcileNonForceRemovalPreservesPresentUnregisteredPath(t *testing.T) if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1317,6 +1332,9 @@ func TestReconcileNonForceRemovalPreservesMissingDetachedRegistration(t *testing if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, prepared.Path, "checkout", "--detach") if err := os.WriteFile( filepath.Join(prepared.Path, "detached-only.txt"), @@ -1378,6 +1396,9 @@ func TestReconcilePendingRemovalRequiresGit(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1461,6 +1482,9 @@ func TestRemoveRecordsInitiallyAbsentPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, repository, "worktree", "remove", "--force", prepared.Path) result, err := manager.Remove( @@ -1503,6 +1527,9 @@ func TestReconcileClearsUnsafeNonForceIntent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -2052,6 +2079,9 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 1f60f7ebda9e56757aa3a2573123002624ea00ae Mon Sep 17 00:00:00 2001 From: Duang777 Date: Thu, 8 Oct 2026 01:00:52 +0800 Subject: [PATCH 103/104] test: release remaining preparation leases --- internal/workspace/workspace_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 3ee2cb7..3ef6c00 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -437,6 +437,9 @@ func TestListAndCleanupDetachedWorktree(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, prepared.Path, "checkout", "--detach") if err := os.WriteFile( filepath.Join(prepared.Path, "detached.txt"), @@ -1441,6 +1444,9 @@ func TestReconcileRemovalReturnsAlreadyAbsentWorkspace(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) From 89d819647815ba5df62bb747de34f8df8fdbb32a Mon Sep 17 00:00:00 2001 From: Duang777 Date: Thu, 8 Oct 2026 01:12:35 +0800 Subject: [PATCH 104/104] test: close Windows preparation leases --- internal/workspace/recovery_security_test.go | 12 ++++++++++++ internal/workspace/removal_reconcile_test.go | 3 +++ internal/workspace/workspace_test.go | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 409a596..a8feb35 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -1994,6 +1994,9 @@ func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -2291,6 +2294,9 @@ func TestAcknowledgeRemovalRecoversQuarantinedRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -2376,6 +2382,9 @@ func TestReconcileRemovalCoalescesAcknowledgementHardLinks(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -2486,6 +2495,9 @@ func TestRemoveRecoversQuarantinedAcknowledgementRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) diff --git a/internal/workspace/removal_reconcile_test.go b/internal/workspace/removal_reconcile_test.go index 1e9af49..36bff97 100644 --- a/internal/workspace/removal_reconcile_test.go +++ b/internal/workspace/removal_reconcile_test.go @@ -283,6 +283,9 @@ func TestReconcileDiscoversAcknowledgementQuarantineAfterRestart(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 3ef6c00..ac964bf 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -374,6 +374,9 @@ func TestPrepareIsolatesConcurrentAgentChanges(t *testing.T) { if err != nil { t.Fatalf("prepare first worktree: %v", err) } + if err := manager.AcknowledgePreparation(first); err != nil { + t.Fatalf("acknowledge first preparation: %v", err) + } second, err := manager.Prepare( context.Background(), repository, @@ -383,6 +386,9 @@ func TestPrepareIsolatesConcurrentAgentChanges(t *testing.T) { if err != nil { t.Fatalf("prepare second worktree: %v", err) } + if err := manager.AcknowledgePreparation(second); err != nil { + t.Fatalf("acknowledge second preparation: %v", err) + } if err := os.WriteFile( filepath.Join(first.Path, "tracked.txt"), @@ -1159,6 +1165,9 @@ func TestRemoveUpgradesExistingIntentToForce(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1586,6 +1595,9 @@ func TestAcknowledgeRemovalValidatesTokenAndIsIdempotent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err)