- add success and failure outcomes for three attachment commands
- define business data schemas and mark downloadUri as sensitive
- migrate attachment commands to unified result output
- verify compact and full Schema result projections
- cover success, malformed response, and tool error paths
to #666
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.
On filesystems without atomic no-replace rename, the degraded
publication moves the source children into a fresh claim and leaves an
emptied source shell for the caller to remove once the move is
confirmed. If that removal failed, moveSkillPathRecoverably reported a
plain failure claiming both locations were preserved while the data
existed only at the destination, so backupAndRemoveSkillDir never
recorded the backup and the original path was left empty. Move the
children back into the shell and withdraw the destination instead; a
failed retraction reports the data location explicitly. Restores the
contract that a failed move keeps the source intact.
The no-replace file fallback links the destination and then removes the
source. If the removal fails, the caller treats the publish as failed,
but no publication record exists to roll the new destination back, and
a backup restore would refuse the occupied path. Remove the destination
behind an identity check — only the proven linked object may be deleted
— and report when the retraction itself fails or the destination was
concurrently replaced.
The previous wrapper returned the first observed ID for both paths, so
expected == actual still held on Windows and the proof accepted the
swap. Return a distinct ID for the second probe.
The constant file-ID stub made both IDs equal, so the Windows proof
(expected == actual) accepted the publication and the subtest failed
there; Unix stayed green because its proof ignores the ID strings and
the swapped os.SameFile seam already forced the failure. Return the
first observed ID for both paths so staged and published identities
differ on every platform while real IDs still flow through the wrapper.
The physical same-content swap relied on the recreated destination
getting a fresh inode, but CI runners' ext4/overlayfs recycle inodes
eagerly, so the swap was undetectable on Linux and the subtest failed
there (while passing on macOS). Swap the same-file identity seam instead
so the confirmation's fast-path rejection contract is pinned on every
platform.
The degraded directory publication claimed the destination with mkdir, then
— on platforms whose rename refuses to replace a directory (macOS refuses
even an empty target, verified empirically) — removed the claim and retried
a plain rename. Between the unlink and the retry a foreign directory could
appear at the destination and be silently overwritten, breaking the
no-replace contract the fallback exists to provide.
Hold the claim for the whole transaction instead: rename over the claim
where the platform permits it (Linux), otherwise move the source children
into the claim one by one. The destination is never unlinked, so a
concurrent creator can only ever lose the mkdir race; every child rename
targets a nonexistent path inside the empty claim, and a failed move
restores the children and removes only the claim.
The child move legitimately changes the publication's identity, which the
confirmation now handles: a rename that consumed the staged path is still
proven by identity, while a child move is proven by the pre-rename content
fingerprint. The emptied source shell doubles as the signal distinguishing
the two shapes; moveSkillPathRecoverably removes it to keep move semantics.
The backup stamp has second precision and pruning kept only the newest 5
stamps, so a canonical migration that retires copies across many Agent
roots deleted its own earlier backups mid-run. That silently voided the
reversibility guarantee the transaction depends on for rollback: a probe
retiring 8 paths lost 3 of them permanently.
Record every stamp directory this process creates, keyed by normalized
absolute path, and prune only the oldest foreign stamps.
Creating the staged symlink usually succeeds, so the link strategy really
fails at publish time: renameSkillPathNoReplace has no atomic no-clobber
primitive for a symlink source and refuses it whenever the kernel flag is
unavailable (NFS, FUSE, overlayfs). Gating the copy fallback on staging
alone therefore left every non-universal Agent unconfigured on exactly the
filesystems the fallback exists to support.
Retry the whole target transaction as a direct copy after a failure in any
phase, but only when the failed attempt fully restored the originals. The
converter also re-adds the replacement backups the link plan deliberately
skips for destinations already pointing at canonical, which a copy must
replace and no-replace publication would otherwise reject with EEXIST.
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.
Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:
- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
GetFileInformationByHandle, which uniquely identifies the file on the
volume for its lifetime and is unaffected by tunneling
When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.
Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
EEXIST if occupied). On Linux rename(2) replaces the empty dir
directly; on Darwin/Windows rename refuses existing dirs so the empty
dir is removed and the rename retried — any concurrent creation
between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
os.Remove completes the move.
Add concurrent-creation test covering the mkdir→rename race window.
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).
Also:
- Add a checked-rename fallback for filesystems that reject the atomic
no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.
Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.