fix(skills): make publication rollback race-safe

This commit is contained in:
玉澜 committed 2026-08-17 20:20:13 +08:00
1 parent a46958c788
commit 7a97354d93
20 files changed
+934 -135

No files matched your search

+1 -1
View File
@@ -2,4 +2,4 @@
category: Fixed category: Fixed
--- ---
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. - **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor.
+143 -12
View File
@@ -698,10 +698,133 @@ function samePhysicalDir(left, right) {
} }
} }
function writeSkillFingerprintField(hash, value) {
const bytes = Buffer.from(String(value), "utf8");
hash.update(String(bytes.length), "utf8");
hash.update(":", "utf8");
hash.update(bytes);
}
function skillPathFingerprint(target) {
const hash = crypto.createHash("sha256");
const visit = (current) => {
const stat = fs.lstatSync(current, { bigint: true });
writeSkillFingerprintField(hash, Number(stat.mode & 0o170000n));
writeSkillFingerprintField(hash, Number(stat.mode & 0o777n));
if (stat.isDirectory()) {
const names = fs.readdirSync(current).sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
writeSkillFingerprintField(hash, names.length);
for (const name of names) {
writeSkillFingerprintField(hash, name);
visit(path.join(current, name));
}
return;
}
if (stat.isSymbolicLink()) {
writeSkillFingerprintField(hash, fs.readlinkSync(current));
return;
}
if (stat.isFile()) {
writeSkillFingerprintField(hash, stat.size.toString());
hash.update(fs.readFileSync(current));
return;
}
throw new Error(`unsupported Skill path type: ${current}`);
};
visit(target);
return hash.digest("hex");
}
function captureSkillPublication(source, destination) {
const stat = fs.lstatSync(source, { bigint: true });
return {
destination,
device: stat.dev.toString(),
inode: stat.ino.toString(),
fingerprint: skillPathFingerprint(source),
};
}
function skillPublicationMatches(target, publication) {
const stat = fs.lstatSync(target, { bigint: true });
return stat.dev.toString() === publication.device
&& stat.ino.toString() === publication.inode
&& skillPathFingerprint(target) === publication.fingerprint;
}
function removeSkillPathLexically(target) {
const stat = fs.lstatSync(target);
if (stat.isDirectory() && !stat.isSymbolicLink()) {
fs.rmSync(target, { recursive: true, force: true });
} else {
fs.unlinkSync(target);
}
}
function skillPathExistsLexically(target) {
try {
fs.lstatSync(target);
return true;
} catch (err) {
if (err && err.code === "ENOENT") return false;
throw err;
}
}
function rollbackPublishedSkillPath(publication, options = {}) {
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || removeSkillPathLexically;
const destination = publication.destination;
const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`));
const quarantine = path.join(quarantineRoot, "payload");
try {
renameFn(destination, quarantine);
} catch (err) {
fs.rmSync(quarantineRoot, { recursive: true, force: true });
if (err && err.code === "ENOENT") return;
throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`);
}
let owned = false;
try {
owned = skillPublicationMatches(quarantine, publication);
} catch (err) {
throw new Error(`refusing to delete unverifiable Skill ${destination}; object retained at ${quarantine}: ${err.message}`);
}
if (!owned) {
throw new Error(`refusing to delete non-transaction Skill ${destination}; concurrent object retained at ${quarantine}`);
}
removeFn(quarantine);
fs.rmSync(quarantineRoot, { recursive: true, force: true });
}
function publishCanonicalLinkNoReplace(staged, destination, options = {}) {
const publication = captureSkillPublication(staged, destination);
if (process.platform === "win32") {
const renameFn = options.publishRenameFn || fs.renameSync;
renameFn(staged, destination);
} else {
const linkFn = options.publishLinkFn || ((source, target) => {
const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" });
if (result.error) throw result.error;
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || "ln -P failed").trim();
const error = new Error(detail);
if (skillPathExistsLexically(target)) error.code = "EEXIST";
throw error;
}
});
linkFn(staged, destination);
}
if (!skillPublicationMatches(destination, publication)) {
throw new Error(`published Skill identity changed before confirmation: ${destination}`);
}
return publication;
}
function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) {
const moveOptions = skillMoveOptions(options); const moveOptions = skillMoveOptions(options);
const publishRenameFn = options.publishRenameFn || fs.renameSync; const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically;
const publishRemoveFn = options.publishRemoveFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync;
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
const realBaseDir = fs.realpathSync(baseDir); const realBaseDir = fs.realpathSync(baseDir);
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-"));
@@ -713,9 +836,9 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names,
const restoreErrors = []; const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) { for (let i = published.length - 1; i >= 0; i -= 1) {
try { try {
publishRemoveFn(published[i]); rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn: publishRemoveFn });
} catch (err) { } catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`); restoreErrors.push(`remove ${published[i].destination}: ${err.message}`);
} }
} }
for (let i = backups.length - 1; i >= 0; i -= 1) { for (let i = backups.length - 1; i >= 0; i -= 1) {
@@ -752,8 +875,8 @@ function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names,
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
} }
for (const item of staged) { for (const item of staged) {
publishRenameFn(item.staged, item.dest); const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options);
published.push(item.dest); published.push(publication);
} }
} catch (err) { } catch (err) {
try { try {
@@ -807,6 +930,7 @@ function publishManagedMultiSkillSetAtomically(
const copyFn = options.copyFn || copyChildren; const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync; const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync;
const moveOptions = skillMoveOptions(options); const moveOptions = skillMoveOptions(options);
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
@@ -818,9 +942,9 @@ function publishManagedMultiSkillSetAtomically(
const restoreErrors = []; const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) { for (let i = published.length - 1; i >= 0; i -= 1) {
try { try {
removeFn(published[i]); rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn });
} catch (err) { } catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`); restoreErrors.push(`remove ${published[i].destination}: ${err.message}`);
} }
} }
for (let i = backups.length - 1; i >= 0; i -= 1) { for (let i = backups.length - 1; i >= 0; i -= 1) {
@@ -855,8 +979,11 @@ function publishManagedMultiSkillSetAtomically(
} }
for (const item of staged) { for (const item of staged) {
const publication = captureSkillPublication(item.staged, item.dest);
if (skillPathExistsLexically(item.dest)) throw new Error(`Skill publish destination already exists: ${item.dest}`);
renameFn(item.staged, item.dest); renameFn(item.staged, item.dest);
published.push(item.dest); if (!skillPublicationMatches(item.dest, publication)) throw new Error(`published Skill identity changed before confirmation: ${item.dest}`);
published.push(publication);
} }
} catch (err) { } catch (err) {
try { try {
@@ -883,6 +1010,7 @@ function publishManagedMonoSkillSetAtomically(
const copyFn = options.copyFn || copyChildren; const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync; const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
const rollbackRenameFn = options.rollbackRenameFn || fs.renameSync;
const moveOptions = skillMoveOptions(options); const moveOptions = skillMoveOptions(options);
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
@@ -895,9 +1023,9 @@ function publishManagedMonoSkillSetAtomically(
const restoreErrors = []; const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) { for (let i = published.length - 1; i >= 0; i -= 1) {
try { try {
removeFn(published[i]); rollbackPublishedSkillPath(published[i], { renameFn: rollbackRenameFn, removeFn });
} catch (err) { } catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`); restoreErrors.push(`remove ${published[i].destination}: ${err.message}`);
} }
} }
for (let i = backups.length - 1; i >= 0; i -= 1) { for (let i = backups.length - 1; i >= 0; i -= 1) {
@@ -927,8 +1055,11 @@ function publishManagedMonoSkillSetAtomically(
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
} }
published.push(destDir); const publication = captureSkillPublication(stagedDir, destDir);
if (skillPathExistsLexically(destDir)) throw new Error(`Skill publish destination already exists: ${destDir}`);
renameFn(stagedDir, destDir); renameFn(stagedDir, destDir);
if (!skillPublicationMatches(destDir, publication)) throw new Error(`published Skill identity changed before confirmation: ${destDir}`);
published.push(publication);
} catch (err) { } catch (err) {
try { try {
restore(); restore();
+8 -12
View File
@@ -143,6 +143,8 @@ var (
skillSetupReadState = skillstate.Read skillSetupReadState = skillstate.Read
skillSetupWriteState = skillstate.Write skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove skillSetupRemoveState = skillstate.Remove
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
skillSetupNow = time.Now skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows" skillSetupFoldPathCase = runtime.GOOS == "windows"
) )
@@ -2035,13 +2037,8 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
// restoreSkillSetupTarget removes a partially published replacement and // restoreSkillSetupTarget removes a partially published replacement and
// restores every original directory from its exact backup path. // restores every original directory from its exact backup path.
func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error { func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
var restoreErr error restoreErr := skillSetupRollbackPaths(published)
for i := len(published) - 1; i >= 0; i-- {
if err := skillSetupRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- { for i := len(backups) - 1; i >= 0; i-- {
item := backups[i] item := backups[i]
if _, err := skillSetupLstat(item.original); err == nil { if _, err := skillSetupLstat(item.original); err == nil {
@@ -2088,18 +2085,17 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
} }
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error { func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
published := make([]string, 0, len(staged)) published := make([]upgrade.SkillPathPublication, 0, len(staged))
for _, item := range staged { for _, item := range staged {
// Record before rename so rollback also removes a destination created by publication, err := skillSetupPublishPath(item.staged, item.dest)
// a platform-specific partial failure. if err != nil {
published = append(published, item.dest)
if err := skillSetupPublishRename(item.staged, item.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
} }
return publishErr return publishErr
} }
published = append(published, publication)
} }
return nil return nil
} }
@@ -9,6 +9,7 @@ import (
"testing" "testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
) )
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
@@ -62,7 +63,9 @@ func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t
t.Run("publish", func(t *testing.T) { t.Run("publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t) home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return errors.New("publish denied") }) testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, errors.New("publish denied")
})
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
t.Fatalf("publish failure = %v", err) t.Fatalf("publish failure = %v", err)
} }
@@ -15,6 +15,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
) )
func useManagedSkillNames(t *testing.T, names ...string) { func useManagedSkillNames(t *testing.T, names ...string) {
@@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
return originalBackup(homeDir, dir) return originalBackup(homeDir, dir)
}) })
} else { } else {
originalRename := skillSetupPublishRename originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure return upgrade.SkillPathPublication{}, failure
} }
return originalRename(oldPath, newPath) return originalPublish(oldPath, newPath)
}) })
} }
@@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("restore failure aggregation", func(t *testing.T) { t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) { t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure }) testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) { if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err) t.Fatalf("remove published error = %v", err)
} }
}) })
@@ -490,7 +491,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}) })
t.Run("publish rollback failure", func(t *testing.T) { t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, failure
})
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
@@ -531,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("after publish failure", func(t *testing.T) { t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t) plan := newPlan(t)
originalRename := skillSetupPublishRename originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure return upgrade.SkillPathPublication{}, failure
} }
return originalRename(oldPath, newPath) return originalPublish(oldPath, newPath)
}) })
originalRemoveAll := skillSetupRemoveAll originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure") cleanupErr := errors.New("cleanup after publish failure")
+6 -6
View File
@@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
backupCalls, copyCalls := []string{}, 0 backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) { testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path) backupCalls = append(backupCalls, path)
if err := os.RemoveAll(path); err != nil {
return "", err
}
return "backup", nil return "backup", nil
}) })
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil }) testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil }) testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false) dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut) dryRunCmd.SetOut(&dryRunOut)
@@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
// A filtered multi plan replaces only selected same-name skills and leaves // A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set. // unselected siblings out of the backup set.
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true) filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -0,0 +1,85 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
writeSkillSetupFile(t, first, "old first")
writeSkillSetupFile(t, second, "old second")
backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard)
if err != nil {
t.Fatal(err)
}
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
writeSkillSetupFile(t, stagedFirst, "new first")
writeSkillSetupFile(t, stagedSecond, "new second")
failure := errors.New("injected second setup publication failure")
originalPublish := skillSetupPublishPath
calls := 0
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
writeSkillSetupFile(t, first, "concurrent")
return upgrade.SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err = publishSkillSetupTarget([]skillSetupStagedDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, backups)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("setup transaction error = %v", err)
}
assertSkillSetupFile(t, first, "concurrent")
assertSkillSetupFile(t, second, "old second")
var firstBackup string
for _, item := range backups {
if item.original == first {
firstBackup = item.backup
break
}
}
if firstBackup == "" {
t.Fatal("first backup was not recorded")
}
assertSkillSetupFile(t, firstBackup, "old first")
}
func writeSkillSetupFile(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func assertSkillSetupFile(t *testing.T, dir, want string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil || string(content) != want {
t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want)
}
}
+2 -2
View File
@@ -416,8 +416,8 @@ func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
if mode == "multi" { if mode == "multi" {
useUpgradeManagedNames(t, "dingtalk-chat") useUpgradeManagedNames(t, "dingtalk-chat")
} }
origRename := upgradeRename origRename := skillPathRenameNoReplace
testseam.Swap(t, &upgradeRename, func(src, dst string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if src == victim { if src == victim {
return errors.New("retire denied") return errors.New("retire denied")
} }
+29 -31
View File
@@ -140,26 +140,28 @@ var universalSkillDirs = map[string]bool{
} }
var ( var (
upgradeUserHomeDir = os.UserHomeDir upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir upgradeReadDir = os.ReadDir
upgradeStat = os.Stat upgradeStat = os.Stat
upgradeLstat = os.Lstat upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } upgradePublishSkillPath = PublishSkillPathNoReplace
upgradeWriteSkillState = skillstate.Write upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
upgradeNow = time.Now upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeFoldPathCase = runtime.GOOS == "windows" upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeFoldPathCase = runtime.GOOS == "windows"
) )
// skillBackupSubdir is the user-level directory where skill directories are // skillBackupSubdir is the user-level directory where skill directories are
@@ -492,13 +494,8 @@ func uniqueSkillDirs(paths []string) []string {
// restoreSkillSet removes any newly published directories, then restores all // restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order. // original directories in reverse backup order.
func restoreSkillSet(published []string, backups []backedUpSkillDir) error { func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
var restoreErr error restoreErr := upgradeRollbackPublishedSkillPaths(published)
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- { for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i] backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil { if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
@@ -538,16 +535,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st
if err != nil { if err != nil {
return err return err
} }
published := make([]string, 0, len(staged)) published := make([]SkillPathPublication, 0, len(staged))
for _, skill := range staged { for _, skill := range staged {
if err := upgradeRename(skill.staged, skill.dest); err != nil { publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err) if publishErr != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil { if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr)) return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
} }
return publishErr return publishErr
} }
published = append(published, skill.dest) published = append(published, publication)
} }
return nil return nil
} }
+11 -11
View File
@@ -625,8 +625,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
} }
useUpgradeManagedNames(t, filepath.Base(staleDir)) useUpgradeManagedNames(t, filepath.Base(staleDir))
origRename := upgradeRename origRename := skillPathRenameNoReplace
testseam.Swap(t, &upgradeRename, func(src, dst string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") { if strings.Contains(src, "dingtalk-stale") {
return errors.New("injected backup failure") return errors.New("injected backup failure")
} }
@@ -814,14 +814,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
if err := os.MkdirAll(victim4, 0o755); err != nil { if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err) t.Fatalf("rename error = %v", err)
} }
if _, err := os.Stat(victim4); err != nil { if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err) t.Fatalf("victim4 must survive rename failure: %v", err)
} }
testseam.Swap(t, &upgradeRename, os.Rename) testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
} }
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
@@ -949,11 +949,11 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755) os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, ".agents") { if strings.Contains(src, ".agents") {
return errors.New("backup denied") return errors.New("backup denied")
} }
return os.Rename(src, dst) return renameSkillPathNoReplace(src, dst)
}) })
result, err := UpgradeSkillLocations(mono) result, err := UpgradeSkillLocations(mono)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
@@ -1014,7 +1014,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
// Backup failure of a multi leftover aborts cleanupMultiLeftovers. // Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale") useUpgradeManagedNames(t, "dingtalk-stale")
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return errors.New("backup denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err) t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
} }
@@ -1024,19 +1024,19 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err) t.Fatalf("opposite cleanup mono backup error = %v", err)
} }
testseam.Swap(t, &upgradeRename, os.Rename) testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Backup failure of a stale (non-mono) skill aborts with its own message. // Backup failure of a stale (non-mono) skill aborts with its own message.
testseam.Swap(t, &upgradeRename, func(src, dst string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) error {
if strings.Contains(src, "dingtalk-stale") { if strings.Contains(src, "dingtalk-stale") {
return errors.New("backup denied") return errors.New("backup denied")
} }
return os.Rename(src, dst) return renameSkillPathNoReplace(src, dst)
}) })
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err) t.Fatalf("opposite cleanup stale backup error = %v", err)
} }
testseam.Swap(t, &upgradeRename, os.Rename) testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Success matrix: mono leftover + stale skill removed into backups, bundle // Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved. // skill and regular file preserved.
+20 -19
View File
@@ -11,23 +11,24 @@ import (
) )
var ( var (
skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
skillPathCopy = copySkillPathLexically skillPathRenameNoReplace = renameSkillPathNoReplace
skillPathVerify = verifySkillPathCopy skillPathCopy = copySkillPathLexically
skillPathRemoveAll = os.RemoveAll skillPathVerify = verifySkillPathCopy
skillPathMkdirAll = os.MkdirAll skillPathRemoveAll = os.RemoveAll
skillPathMkdirTemp = os.MkdirTemp skillPathMkdirAll = os.MkdirAll
skillPathChmod = os.Chmod skillPathMkdirTemp = os.MkdirTemp
skillPathLstat = os.Lstat skillPathChmod = os.Chmod
skillPathMkdir = os.Mkdir skillPathLstat = os.Lstat
skillPathReadDir = os.ReadDir skillPathMkdir = os.Mkdir
skillPathReadlink = os.Readlink skillPathReadDir = os.ReadDir
skillPathSymlink = os.Symlink skillPathReadlink = os.Readlink
skillPathOpen = os.Open skillPathSymlink = os.Symlink
skillPathOpenFile = os.OpenFile skillPathOpen = os.Open
skillPathCopyBytes = io.Copy skillPathOpenFile = os.OpenFile
skillPathSync = func(file *os.File) error { return file.Sync() } skillPathCopyBytes = io.Copy
skillPathWalkDir = filepath.WalkDir skillPathSync = func(file *os.File) error { return file.Sync() }
skillPathWalkDir = filepath.WalkDir
) )
// moveSkillPathRecoverably moves one managed Skill path without weakening the // moveSkillPathRecoverably moves one managed Skill path without weakening the
@@ -44,7 +45,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) {
if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
} }
if renameErr := skillPathRename(src, dst); renameErr == nil { if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
return nil return nil
} else if !isCrossDeviceError(renameErr) { } else if !isCrossDeviceError(renameErr) {
return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
@@ -85,7 +86,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) {
return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
} }
} }
if err := skillPathRename(stage, dst); err != nil { if err := skillPathRenameNoReplace(stage, dst); err != nil {
return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
} }
if stageInfo.IsDir() { if stageInfo.IsDir() {
+5 -5
View File
@@ -140,7 +140,7 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
t.Run("non cross-device rename never copies", func(t *testing.T) { t.Run("non cross-device rename never copies", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t) src, dst := makeSkillMoveFixture(t)
copied := false copied := false
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrPermission }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return os.ErrPermission })
testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err) t.Fatalf("error = %v", err)
@@ -181,8 +181,8 @@ func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
t.Run("staging publish failure", func(t *testing.T) { t.Run("staging publish failure", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t) src, dst := makeSkillMoveFixture(t)
originalRename := skillPathRename originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRename, func(from, to string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error {
if from == src && to == dst { if from == src && to == dst {
return testCrossDeviceError() return testCrossDeviceError()
} }
@@ -402,8 +402,8 @@ func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) {
func forceCrossDeviceRename(t *testing.T, src, dst string) { func forceCrossDeviceRename(t *testing.T, src, dst string) {
t.Helper() t.Helper()
originalRename := skillPathRename originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRename, func(from, to string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) error {
if from == src && to == dst { if from == src && to == dst {
return testCrossDeviceError() return testCrossDeviceError()
} }
+153
View File
@@ -0,0 +1,153 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"os"
"path/filepath"
"sort"
)
// SkillPathPublication records enough immutable identity to prove that a
// destination still belongs to the transaction that published it. The
// fingerprint is intentionally private so callers cannot forge records.
type SkillPathPublication struct {
Destination string
fingerprint [sha256.Size]byte
identity os.FileInfo
}
// PublishSkillPathNoReplace atomically publishes a staged path without ever
// replacing a destination created after the backup phase.
func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
identity, err := skillPathLstat(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
}
fingerprint, err := fingerprintSkillPath(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
}
if err := skillPathRenameNoReplace(staged, destination); err != nil {
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
}
return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil
}
// RollbackSkillPathPublications removes only objects that can still be proven
// to have been published by this transaction. Each live path is first claimed
// into a private sibling quarantine. A concurrent replacement is restored when
// possible, otherwise retained in quarantine and reported explicitly.
func RollbackSkillPathPublications(publications []SkillPathPublication) error {
var rollbackErr error
for i := len(publications) - 1; i >= 0; i-- {
if err := rollbackSkillPathPublication(publications[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
destination := publication.Destination
quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
if err != nil {
return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
}
quarantine := filepath.Join(quarantineRoot, "payload")
cleanupRoot := func() error {
if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
}
return nil
}
if err := skillPathRename(destination, quarantine); err != nil {
cleanupErr := cleanupRoot()
if os.IsNotExist(err) {
return cleanupErr
}
return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
}
actualIdentity, identityErr := skillPathLstat(quarantine)
actual, fingerprintErr := fingerprintSkillPath(quarantine)
if identityErr == nil && fingerprintErr == nil && publication.identity != nil &&
os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint {
if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
}
return cleanupRoot()
}
verificationErr := errors.Join(identityErr, fingerprintErr)
if verificationErr == nil {
verificationErr = errors.New("发布对象身份已变化")
}
if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
}
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
cleanupRoot(),
)
}
func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
h := sha256.New()
if err := fingerprintSkillPathInto(h, path); err != nil {
return [sha256.Size]byte{}, err
}
var result [sha256.Size]byte
copy(result[:], h.Sum(nil))
return result, nil
}
func fingerprintSkillPathInto(h hash.Hash, path string) error {
info, err := skillPathLstat(path)
if err != nil {
return err
}
writeFingerprintField(h, info.Mode().Type().String())
writeFingerprintField(h, info.Mode().Perm().String())
switch {
case info.IsDir():
entries, err := skillPathReadDir(path)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
for _, entry := range entries {
writeFingerprintField(h, entry.Name())
if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(path)
if err != nil {
return err
}
writeFingerprintField(h, target)
return nil
case info.Mode().IsRegular():
digest, err := skillPathFileDigest(path)
if err != nil {
return err
}
writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
_, err = h.Write(digest[:])
return err
default:
return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
}
}
func writeFingerprintField(h hash.Hash, value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
}
+314
View File
@@ -0,0 +1,314 @@
package upgrade
import (
"crypto/sha256"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
t.Run("existing destination is never replaced", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
seedUpgradeSkill(t, destination, "concurrent", false)
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
t.Fatal("expected no-replace publication to fail")
}
assertUpgradeSkillContent(t, destination, "concurrent")
assertUpgradeSkillContent(t, staged, "new")
})
t.Run("owned publication is removed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("owned publication remains: %v", err)
}
})
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
// Even a byte-for-byte identical replacement is not owned by this
// transaction: inode identity, not content alone, is authoritative.
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
}
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
failure := errors.New("injected publication failure")
t.Run("publish identity", func(t *testing.T) {
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected identity failure")
}
})
t.Run("publish fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
seedUpgradeSkill(t, staged, "value", false)
originalLstat := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
calls++
if calls == 2 {
return nil, failure
}
return originalLstat(path)
})
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
t.Fatal("expected fingerprint failure")
}
})
t.Run("rollback temp", func(t *testing.T) {
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
t.Fatalf("temp error = %v", err)
}
})
t.Run("missing destination cleanup", func(t *testing.T) {
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
t.Fatal(err)
}
})
t.Run("missing destination cleanup failure", func(t *testing.T) {
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v", err)
}
})
t.Run("quarantine rename", func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "destination")
seedUpgradeSkill(t, destination, "value", false)
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: destination}}); !errors.Is(err, failure) {
t.Fatalf("quarantine error = %v", err)
}
})
t.Run("owned remove", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if filepath.Base(path) == "payload" {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("remove error = %v", err)
}
})
t.Run("quarantined fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "payload" {
return nil, failure
}
return originalLstat(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("fingerprint error = %v", err)
}
})
t.Run("mismatch restore and cleanup", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "replacement", false)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("mismatch cleanup error = %v", err)
}
assertUpgradeSkillContent(t, destination, "replacement")
})
t.Run("mismatch restore failure", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "replacement", false)
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
t.Fatalf("mismatch restore error = %v", err)
}
})
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
t.Run("directory read", func(t *testing.T) {
dir := t.TempDir()
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("read dir error = %v", err)
}
})
t.Run("recursive child", func(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "child")
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == child {
return nil, failure
}
return originalLstat(path)
})
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("child error = %v", err)
}
})
t.Run("symlink read", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
t.Fatalf("readlink error = %v", err)
}
})
t.Run("file digest", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
t.Fatalf("digest error = %v", err)
}
})
t.Run("special", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
t.Fatal("expected special path error")
}
})
})
}
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
seedUpgradeSkill(t, first, "old first", false)
seedUpgradeSkill(t, second, "old second", false)
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
seedUpgradeSkill(t, stagedFirst, "new first", false)
seedUpgradeSkill(t, stagedSecond, "new second", false)
failure := errors.New("injected second publication failure")
originalPublish := upgradePublishSkillPath
calls := 0
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, first, "concurrent", false)
return SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err := publishStagedSkillSet(home, []stagedSkillDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, []string{first, second})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("transaction error = %v", err)
}
assertUpgradeSkillContent(t, first, "concurrent")
assertUpgradeSkillContent(t, second, "old second")
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("retained old backup = %v, %v", matches, globErr)
}
content, readErr := os.ReadFile(matches[0])
if readErr != nil || string(content) != "old first" {
t.Fatalf("retained backup content = %q, %v", content, readErr)
}
}
@@ -0,0 +1,9 @@
//go:build darwin
package upgrade
import "golang.org/x/sys/unix"
func renameSkillPathNoReplace(source, destination string) error {
return unix.RenamexNp(source, destination, unix.RENAME_EXCL)
}
@@ -0,0 +1,9 @@
//go:build linux
package upgrade
import "golang.org/x/sys/unix"
func renameSkillPathNoReplace(source, destination string) error {
return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE)
}
@@ -0,0 +1,12 @@
//go:build !linux && !darwin && !windows
package upgrade
import (
"fmt"
"runtime"
)
func renameSkillPathNoReplace(_, _ string) error {
return fmt.Errorf("当前平台 %s 不支持安全的原子 no-replace rename", runtime.GOOS)
}
@@ -0,0 +1,17 @@
//go:build windows
package upgrade
import "golang.org/x/sys/windows"
func renameSkillPathNoReplace(source, destination string) error {
sourcePtr, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPtr, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
return windows.MoveFile(sourcePtr, destinationPtr)
}
@@ -92,8 +92,8 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("backup failure restores earlier victims", func(t *testing.T) { t.Run("backup failure restores earlier victims", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename originalRename := skillPathRenameNoReplace
testseam.Swap(t, &upgradeRename, func(src, dest string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
if src == filepath.Join(base, "dingtalk-b") { if src == filepath.Join(base, "dingtalk-b") {
return failure return failure
} }
@@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("publish failure rolls back complete set", func(t *testing.T) { t.Run("publish failure rolls back complete set", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename originalPublish := upgradePublishSkillPath
failed := false failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error { testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" { if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
failed = true failed = true
return failure return SkillPathPublication{}, failure
} }
return originalRename(src, dest) return originalPublish(src, dest)
}) })
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) { if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err) t.Fatalf("publish failure = %v, want injected failure", err)
@@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T
t.Run("publish failure rolls back mono and multi", func(t *testing.T) { t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
home, base, monoRoot := seed(t) home, base, monoRoot := seed(t)
originalRename := upgradeRename originalPublish := upgradePublishSkillPath
failed := false failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error { testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" { if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
failed = true failed = true
return failure return SkillPathPublication{}, failure
} }
return originalRename(src, dest) return originalPublish(src, dest)
}) })
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) { if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err) t.Fatalf("publish failure = %v, want injected failure", err)
@@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
}) })
t.Run("restore reports removal and directory failures", func(t *testing.T) { t.Run("restore reports removal and directory failures", func(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure }) testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure })
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure }) testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
err := restoreSkillSet( err := restoreSkillSet(
[]string{filepath.Join(t.TempDir(), "published")}, []SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}},
[]backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}},
) )
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
@@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
}) })
t.Run("restore reports rename failure", func(t *testing.T) { t.Run("restore reports rename failure", func(t *testing.T) {
testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure }) testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return restoreFailure })
err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}})
if !errors.Is(err, restoreFailure) { if !errors.Is(err, restoreFailure) {
t.Fatalf("restore rename error = %v", err) t.Fatalf("restore rename error = %v", err)
@@ -236,8 +236,8 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
second := filepath.Join(home, "second") second := filepath.Join(home, "second")
seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false) seedUpgradeSkill(t, second, "second", false)
originalRename := upgradeRename originalRename := skillPathRenameNoReplace
testseam.Swap(t, &upgradeRename, func(src, dest string) error { testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
switch { switch {
case src == second: case src == second:
return failure return failure
@@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
staged := filepath.Join(home, "skills", ".stage", "dws") staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false) seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false) seedUpgradeSkill(t, staged, "new", false)
originalRename := upgradeRename testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
testseam.Swap(t, &upgradeRename, func(src, dest string) error { return SkillPathPublication{}, failure
switch { })
case src == staged: originalRename := skillPathRenameNoReplace
return failure testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) {
return restoreFailure return restoreFailure
default:
return originalRename(src, dest)
} }
return originalRename(src, dest)
}) })
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
+70 -1
View File
@@ -287,7 +287,7 @@ function runCrossDeviceMoveContract() {
} }
fs.renameSync(source, target); fs.renameSync(source, target);
}, },
publishRenameFn() { throw new Error("link publish failed"); }, publishLinkFn() { throw new Error("link publish failed"); },
}), }),
/link publish failed/, /link publish failed/,
); );
@@ -687,6 +687,75 @@ scenario("cache copy failure preserves the previous complete cache", () => {
} }
}); });
scenario("canonical link publication never clobbers or deletes concurrent user data", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-"));
try {
{
const home = path.join(tmp, "no-clobber-home");
const canonical = path.join(home, ".agents", "skills");
const base = path.join(home, ".cursor", "skills");
const destination = path.join(base, "dingtalk-chat");
writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n");
assert.throws(
() => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], {
publishLinkFn(_source, target) {
writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n");
throw Object.assign(new Error("concurrent destination exists"), { code: "EEXIST" });
},
}),
/concurrent destination exists/,
);
assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n");
}
{
const home = path.join(tmp, "rollback-home");
const canonical = path.join(home, ".agents", "skills");
const base = path.join(home, ".cursor", "skills");
const first = path.join(base, "dingtalk-first");
const second = path.join(base, "dingtalk-second");
for (const name of ["dingtalk-first", "dingtalk-second"]) {
writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`);
writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`);
}
let publishCalls = 0;
assert.throws(
() => publishCanonicalLinksAtomically(
home,
canonical,
base,
["dingtalk-first", "dingtalk-second"],
[first, second],
{
publishLinkFn(source, target) {
publishCalls += 1;
if (publishCalls === 1) {
const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr || "ln -P failed");
return;
}
fs.unlinkSync(first);
writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n");
throw new Error("injected second canonical publish failure");
},
},
),
/concurrent object retained|rollback failed/,
);
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n");
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n");
const retained = fs.readdirSync(base)
.filter((name) => name.startsWith(".dingtalk-first.rollback-"))
.map((name) => path.join(base, name, "payload", "concurrent-user-data.txt"))
.filter((candidate) => fs.existsSync(candidate));
assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine");
assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n");
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi set publish failure restores the complete previous set", () => { scenario("multi set publish failure restores the complete previous set", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-"));
const home = path.join(tmp, "home"); const home = path.join(tmp, "home");