fix(skills): create shell-published links at the destination atomically

The POSIX shell installers staged shared Skill links and published them
with mv after an existence check; a file or symlink another process
created at the destination between the check and the move was silently
replaced, and the inode confirmation could not detect the loss. Publish
by creating each link directly at its destination instead — symlink(2)
refuses an occupied path with EEXIST, so the creation itself is the
atomic no-replace check. A directory that appears at the destination
turns ln -s into a container; the nested link is removed after an
identity check and the transaction rolls back, leaving the foreign
directory untouched. Applied to install.sh, install-skills.sh,
install-event.sh, and install-devapp.sh. Also covers the remaining
retraction branches of the Go shell-removal fallback so changed-code
coverage is complete. Regression tests inject a concurrent occupant at
the publish instant for regular-file and directory cases and assert the
foreign object and its contents stay completely unchanged.
This commit is contained in:
玉澜
2026-08-18 10:20:15 +08:00
parent e84743615f
commit fcbddb0904
8 changed files with 306 additions and 49 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
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. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. 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; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. Regular-file publication over those filesystems links and then removes the source, and a failed source removal retracts the proven link so a failed publish never leaves an untracked object the rollback cannot account for. A degraded child-move publication whose emptied source shell cannot be removed is retracted the same way — the children move back into the shell and the destination is withdrawn — so a failed move keeps the original path intact instead of stranding the data at an unrecorded backup location. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone shell installers publish shared Skill links the same way — creating each link directly at its destination — so a concurrent writer's file, symlink, or directory is refused atomically instead of being replaced by a staged move. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor.
@@ -159,6 +159,22 @@ func TestCrossPlatformCoverageSkillMoveShellCleanup(t *testing.T) {
}
})
t.Run("shell vanished concurrently after publication completes the move", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceSlowClaimMove(t, src, dst)
originalRemove := skillPathRemove
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == src {
return &os.PathError{Op: "remove", Path: path, Err: os.ErrNotExist}
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatalf("concurrent shell removal must complete the move, got %v", err)
}
assertUpgradeSkillContent(t, dst, "old")
})
t.Run("shell removal failure retracts the publication and restores the source", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceSlowClaimMove(t, src, dst)
+43
View File
@@ -542,4 +542,47 @@ func TestCrossPlatformCoverageSkillPathMoveShellRetraction(t *testing.T) {
t.Fatalf("source shell must still exist, stat err=%v", statErr)
}
})
t.Run("retraction read failure reports the data location", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
forceFastClaimRenameFailure(t, src, dst)
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == src {
return os.ErrPermission
}
return os.Remove(path)
})
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(dir string) ([]os.DirEntry, error) {
if dir == dst {
return nil, os.ErrPermission
}
return originalReadDir(dir)
})
err := moveSkillPathRecoverably(src, dst)
if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) {
t.Fatalf("unreadable retraction target must be reported with the data location, got %v", err)
}
assertUpgradeSkillContent(t, dst, "old")
})
t.Run("retraction withdraw failure reports the data location", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
forceFastClaimRenameFailure(t, src, dst)
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == src || path == dst {
return os.ErrPermission
}
return os.Remove(path)
})
err := moveSkillPathRecoverably(src, dst)
if err == nil || !strings.Contains(err.Error(), "状态不确定") || !strings.Contains(err.Error(), dst) {
t.Fatalf("withdraw failure must be reported with the data location, got %v", err)
}
assertUpgradeSkillContent(t, src, "old")
})
}
+19 -3
View File
@@ -244,10 +244,26 @@ link_or_copy_skill() {
return 1
fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
# Create the link directly at the destination: symlink(2) refuses an
# occupied path with EEXIST, so publication itself is the atomic no-replace
# check (`mv` after the backup could still replace a file or symlink a
# concurrent writer created in between). A directory that slipped in turns
# `ln -s` into a container: remove exactly our nested link and roll back.
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
rm -rf "$stage"
return 0
fi
nested="$dest/${relative##*/}"
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
rm -f "$nested" || true
fi
rm -rf "$stage"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
if [ -n "$backup" ]; then
if [ -e "$dest" ] || [ -L "$dest" ]; then
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
elif ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
fi
return 1
}
+19 -3
View File
@@ -313,10 +313,26 @@ link_or_copy_skill() {
return 1
fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
# Create the link directly at the destination: symlink(2) refuses an
# occupied path with EEXIST, so publication itself is the atomic no-replace
# check (`mv` after the backup could still replace a file or symlink a
# concurrent writer created in between). A directory that slipped in turns
# `ln -s` into a container: remove exactly our nested link and roll back.
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
rm -rf "$stage"
return 0
fi
nested="$dest/${relative##*/}"
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
rm -f "$nested" || true
fi
rm -rf "$stage"
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
if [ -n "$backup" ]; then
if [ -e "$dest" ] || [ -L "$dest" ]; then
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
elif ! mv "$backup" "$dest"; then
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
fi
return 1
}
+21 -17
View File
@@ -720,25 +720,29 @@ link_canonical_skills_to_base() {
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
# The staged symlink keeps its inode across the rename below (the staging
# directory is a sibling inside the same Agent root), so the identity
# recorded in the publication manifest stays exact.
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
# Anything still present here appeared after the pre-flight scan, i.e. a
# concurrent writer. Never let `mv` clobber it.
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
# Publish by creating the link directly at the destination: symlink(2)
# refuses an occupied path with EEXIST, so the creation itself is the
# atomic no-replace check. `mv` after any pre-check could still replace a
# file or symlink a concurrent writer created in between, and `ln -P`
# stays unusable (absent from BusyBox `ln`, which silently degraded every
# non-universal Agent to the copy layout on Alpine and most containers).
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
# absent from BusyBox `ln`, which silently degraded every non-universal
# Agent to the copy layout on Alpine and most containers). install-event.sh
# and install-devapp.sh already publish this way. The staged basename stays
# unique, so a destination a concurrent process turned into a directory can
# only receive our link and post-validation removes exactly that.
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
# A directory that appeared at the destination turns `ln -s` into a
# container: the link lands inside it under the target basename. Remove
# exactly our link after an identity check and roll back; the foreign
# directory stays untouched.
if [ ! -L "$_lcs_dest" ]; then
_lcs_nested_name="${_lcs_link_target##*/}"
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
+25 -19
View File
@@ -961,29 +961,35 @@ link_canonical_skills_to_base() {
rm -rf "$_lcs_stage"
return 1
}
# The staged symlink keeps its inode across the rename below (the staging
# directory is a sibling inside the same Agent root), so the identity
# recorded in the publication manifest stays exact.
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
# Anything still present here appeared after the pre-flight scan, i.e. a
# concurrent writer. Never let `mv` clobber it.
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
# Publish by creating the link directly at the destination: symlink(2)
# refuses an occupied path with EEXIST, so the creation itself is the
# atomic no-replace check. `mv` after any pre-check could still replace a
# file or symlink a concurrent writer created in between, and `ln -P`
# stays unusable (absent from BusyBox `ln`, which silently degraded every
# non-universal Agent to the copy layout on Alpine and most containers).
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
# absent from BusyBox `ln`, which silently degraded every non-universal
# Agent to the copy layout on Alpine and most containers). install-event.sh
# and install-devapp.sh already publish this way. The staged basename stays
# unique, so a destination a concurrent process turned into a directory can
# only receive our link and post-validation removes exactly that.
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
# A directory that appeared at the destination turns `ln -s` into a
# container: the link lands inside it under the target basename. Remove
# exactly our link after an identity check and roll back; the foreign
# directory stays untouched.
if [ ! -L "$_lcs_dest" ]; then
_lcs_nested_name="${_lcs_link_target##*/}"
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
fi
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
}
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
rm -rf "$_lcs_stage"
return 1
+162 -6
View File
@@ -575,6 +575,160 @@ exit 0
}
}
func TestInstallScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell semantics are unavailable")
}
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "# ── Main")
if cut < 0 {
t.Fatal("install.sh main section not found")
}
harness := string(data[:cut]) + `
ROOT="$DWS_TEST_HOME/root"
BASE="$ROOT/agent/skills"
CANONICAL="$ROOT/.agents/skills"
BUNDLE="$DWS_TEST_HOME/bundle"
mkdir -p "$CANONICAL/dingtalk-chat" "$BASE" "$BUNDLE/dingtalk-chat" || exit 9
printf 'canonical\n' > "$CANONICAL/dingtalk-chat/SKILL.md" || exit 9
printf 'bundled\n' > "$BUNDLE/dingtalk-chat/SKILL.md" || exit 9
race_case="$1"
dest="$BASE/dingtalk-chat"
# Inject the concurrent writer at the exact instant before publication: the
# publish loop reads every staged link first, so the override occupies the
# destination right before the real creation attempt. The override runs in a
# command-substitution subshell, so only its on-disk effects are asserted.
readlink() {
case "$1" in
*.dws-link-set.*dingtalk-chat)
case "$race_case" in
file) printf 'must survive\n' > "$dest" ;;
dir)
mkdir -p "$dest" || exit 9
printf 'must survive\n' > "$dest/concurrent-user-data.txt" || exit 9
;;
esac
;;
esac
command readlink "$1"
}
if link_canonical_skills_to_base "$ROOT" "$BASE" multi "$BUNDLE"; then
printf 'UNEXPECTED-SUCCESS\n'
exit 1
fi
case "$race_case" in
file)
[ -f "$dest" ] || { printf 'MISSING-FILE\n'; exit 3; }
[ "$(cat "$dest")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; }
;;
dir)
[ -d "$dest" ] || { printf 'MISSING-DIR\n'; exit 3; }
[ "$(cat "$dest/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; }
if [ -n "$(ls -A "$dest" | grep -v '^concurrent-user-data\.txt$')" ]; then
printf 'NESTED-LEFTOVER\n'
exit 5
fi
;;
esac
case "$(ls -A "$BASE")" in
*".dws-link-set."*) printf 'STAGE-LEAK\n'; exit 6 ;;
esac
exit 0
`
for _, raceCase := range []string{"file", "dir"} {
harnessPath := filepath.Join(t.TempDir(), "link-publication-race.sh")
mustWriteFile(t, harnessPath, []byte(harness), 0o755)
cmd := exec.Command("sh", harnessPath, raceCase)
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir())
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("install.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output))
}
}
}
func TestInstallEventScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell semantics are unavailable")
}
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh"))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "\nmain\n")
if cut < 0 {
t.Fatal("install-event.sh main call not found")
}
harness := string(data[:cut]) + `
ROOT="$DWS_TEST_HOME"
CANONICAL="$ROOT/.agents/skills/dingtalk-chat"
SRC="$ROOT/src/dingtalk-chat"
DEST="$ROOT/agent/skills/dingtalk-chat"
mkdir -p "$CANONICAL" "$SRC" || exit 9
printf 'canonical\n' > "$CANONICAL/SKILL.md" || exit 9
printf 'src\n' > "$SRC/SKILL.md" || exit 9
# Inject the concurrent writer at the exact instant before publication:
# backup_skill_dir is the last call before the link is created at DEST. The
# override runs in a command-substitution subshell, so only its on-disk
# effects are asserted.
backup_skill_dir() {
case "$RACE_CASE" in
file) printf 'must survive\n' > "$DEST" || exit 9 ;;
dir)
mkdir -p "$DEST" || exit 9
printf 'must survive\n' > "$DEST/concurrent-user-data.txt" || exit 9
;;
esac
printf '\n'
}
if link_or_copy_skill "$CANONICAL" "$SRC" "$DEST"; then
printf 'UNEXPECTED-SUCCESS\n'
exit 1
fi
case "$RACE_CASE" in
file)
[ -f "$DEST" ] || { printf 'MISSING-FILE\n'; exit 3; }
[ "$(cat "$DEST")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; }
;;
dir)
[ -d "$DEST" ] || { printf 'MISSING-DIR\n'; exit 3; }
[ "$(cat "$DEST/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; }
if [ -n "$(ls -A "$DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then
printf 'NESTED-LEFTOVER\n'
exit 5
fi
;;
esac
exit 0
`
for _, raceCase := range []string{"file", "dir"} {
harnessPath := filepath.Join(t.TempDir(), "event-link-publication-race.sh")
mustWriteFile(t, harnessPath, []byte(harness), 0o755)
cmd := exec.Command("sh", harnessPath)
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir(), "EVENT_VERSION=1.0.0-test", "RACE_CASE="+raceCase)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("install-event.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output))
}
}
}
func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) {
t.Parallel()
@@ -1866,18 +2020,20 @@ func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *test
mustWriteFile(t, filepath.Join(bundle, "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644)
mustWriteFile(t, filepath.Join(bundle, "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644)
// Publication now moves a staged symlink instead of using `ln -P`
// (BusyBox has no -P), and multi mode requires the bundle source so
// that only bundle skills are linked. Inject the race on that move.
// Publication creates the link directly at the destination with
// `ln -s` (symlink(2) refuses an occupied path atomically; BusyBox
// has no `ln -P`), and multi mode requires the bundle source so
// that only bundle skills are linked. Inject the race on that
// creation.
harness := `. "$DWS_TEST_LIBRARY"
mv() {
if [ "$2" = "$DWS_TEST_SECOND" ]; then
ln() {
if [ "$#" -eq 3 ] && [ "$1" = "-s" ] && [ "$3" = "$DWS_TEST_SECOND" ]; then
rm -f "$DWS_TEST_FIRST"
mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND"
printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt"
printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt"
fi
command mv "$@"
command ln "$@"
}
if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then
exit 2