diff --git a/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md new file mode 100644 index 00000000..e6b650d2 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md @@ -0,0 +1,14 @@ +--- +category: Changed +--- + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. diff --git a/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md new file mode 100644 index 00000000..b84f9ebc --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. diff --git a/.changes/released/1.0.59-beta.4/976-recruit-job.md b/.changes/released/1.0.59-beta.4/976-recruit-job.md new file mode 100644 index 00000000..a1be13c3 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/976-recruit-job.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 diff --git a/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md new file mode 100644 index 00000000..c206decc --- /dev/null +++ b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md @@ -0,0 +1,5 @@ +--- +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 directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); 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. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. 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 event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. 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 — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. 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; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md new file mode 100644 index 00000000..166f6708 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md @@ -0,0 +1,6 @@ +--- +category: Fixed +--- + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. diff --git a/.changes/released/1.0.59-beta.4/command-path-migration-governance.md b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md new file mode 100644 index 00000000..61f89635 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. diff --git a/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md new file mode 100644 index 00000000..da6f2c33 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b5384b3..08fb7c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,12 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js + - name: Test npm installer smoke (prune, backup, publish) + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + env: + XDG_CONFIG_HOME: "" + run: node test/scripts/install_js_smoke.mjs + test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d8f7b..4377f4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.4] - 2026-08-20 + +### Added + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. + +### Changed + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. + +### 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 directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); 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. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. 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 event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. 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 — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. 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; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. + + ## [1.0.59-beta.3] - 2026-08-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce9d6372..4c334984 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code. `make authoritative-interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF=`. The Make target delegates to the authoritative wrapper; CI does not invoke a second comparator or the legacy fixture checker. See - [CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md) + [CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md) for the reviewed two-stage `pending` → `consumed` lifecycle. - Agent-visible flag migrations must also run + Agent-visible flag or command-path migrations must also run `make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF=`; it consumes the same base-owned ledger rather than a second exception list. 5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 90ab9635..285d2254 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.59-beta.3" + version "1.0.59-beta.4" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz" - sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz" + sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz" - sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz" + sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz" - sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz" + sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz" - sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz" + sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip" - sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip" + sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435" end def install diff --git a/README.md b/README.md index 0c6af0bb..58775c3f 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). diff --git a/build/npm/install.js b/build/npm/install.js index 49e9fd7f..58e2a7f1 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -8,27 +8,122 @@ const os = require("os"); const path = require("path"); const childProcess = require("child_process"); -// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. -const AGENT_DIRS = [ - ".agents/skills", - ".claude/skills", - ".cursor/skills", - ".qoder/skills", - ".qoderwork/skills", - ".gemini/skills", - ".codex/skills", - ".zcode/skills", - ".github/skills", - ".windsurf/skills", - ".augment/skills", - ".cline/skills", - ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs, +// including canonical-direct and no-global-directory entries. The resolver +// below intentionally deduplicates shared effective roots only after this +// authoritative enumeration has been classified. +const UPSTREAM_AGENTS = [ + ["aider-desk", false, ".aider-desk/skills"], + ["amp", true, ".config/agents/skills"], + ["antigravity", true, ".gemini/antigravity/skills"], + ["antigravity-cli", true, ".gemini/antigravity-cli/skills"], + ["astrbot", false, ".astrbot/data/skills"], + ["autohand-code", false, ".autohand/skills"], + ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"], + ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"], + ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"], + ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"], + ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"], + ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"], + ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"], + ["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"], + ["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"], + ["droid", false, ".factory/skills"], ["eve", false, null], + ["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"], + ["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"], + ["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"], + ["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"], + ["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"], + ["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"], + ["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"], + ["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"], + ["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"], + ["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"], + ["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"], + ["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"], + ["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"], + ["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"], + ["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"], + ["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"], + ["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"], + ["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"], + ["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"], + ["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"], + ["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"], + ["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"], + ["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"], + ["pochi", false, ".pochi/skills"], ["promptscript", true, null], + ["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"], +].map(([id, universal, agentDir]) => ({ id, universal, agentDir })); + +// Paths emitted by beta.6 or older DWS installers that do not correspond to +// the upstream registry. They are cleanup-only migration targets. +const LEGACY_UNIVERSAL_AGENT_DIRS = [ + ".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills", ]; +function openClawSkillsDir(homeDir) { + for (const name of [".openclaw", ".clawdbot", ".moltbot"]) { + const root = path.join(homeDir, name); + if (fs.existsSync(root)) return path.join(root, "skills"); + } + return path.join(homeDir, ".openclaw", "skills"); +} + +function resolvedAgentTargets(homeDir) { + const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config"); + const targets = [ + ...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"), + // DWS compatibility target; qoderwork is not in upstream's registry. + { id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true }, + ...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })), + ].map((target) => { + const { agentDir } = target; + let baseDir = path.join(homeDir, agentDir); + if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) { + baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills"); + } else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) { + baseDir = path.join(process.env.CODEX_HOME.trim(), "skills"); + } else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) { + baseDir = path.join(process.env.HERMES_HOME.trim(), "skills"); + } else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) { + baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills"); + } else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) { + baseDir = path.join(process.env.GROK_HOME.trim(), "skills"); + } else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) { + baseDir = path.join(process.env.VIBE_HOME.trim(), "skills"); + } else if (agentDir === ".openclaw/skills") { + baseDir = openClawSkillsDir(homeDir); + } else if (agentDir.startsWith(".config/")) { + baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length)); + } + return { ...target, baseDir }; + }); + const seen = new Set(); + return targets.filter((target) => { + let key = path.resolve(target.baseDir); + if (process.platform === "win32") key = key.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function agentTargetDetected(target) { + const parent = path.dirname(target.baseDir); + switch (target.id) { + case "kimchi": + case "tabnine-cli": + return fs.existsSync(path.dirname(parent)); + case "zcode": + return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app"); + case "minimax-code": + return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app"); + default: + return fs.existsSync(parent); + } +} + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -58,45 +153,656 @@ function backupStamp() { ); } +// SKILL_BACKUP_KEEP bounds ~/.dws/skill-backups/ growth: only the newest +// stamped backup directories are kept. Mirrors skillBackupKeep and +// pruneSkillBackups in internal/upgrade/paths.go so every installer surface +// applies the same retention. +const SKILL_BACKUP_KEEP = 5; + +// Backup roots this process created are never pruned: an in-flight +// transaction still needs them to roll back. +const currentRunBackupRoots = new Set(); + +// Ownership marker: every stamp root DWS creates carries .dws-skill-backup +// with exactly these bytes — the same bytes internal/upgrade/paths.go, +// install.sh, and the PowerShell installers write. A stamp-shaped name alone +// is not ownership proof, so pruning only deletes directories whose marker +// content matches byte-for-byte (cross-surface exact-LF contract; a +// PowerShell writer's hypothetical CRLF is normalized away on that surface, +// while every writer here emits LF). +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +// writeSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +function writeSkillBackupMarker(root) { + fs.writeFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + +// skillBackupRootUsable reports whether a stamp root may be written into: +// either it does not exist yet (this transaction creates it fresh) or its +// ownership marker already verifies. A stamp-shaped directory created by +// anyone else must never be adopted — writing our marker into it would turn +// its foreign contents into prunable DWS backups. +function skillBackupRootUsable(root) { + if (!pathExistsLexicallySync(root)) return true; + // A root this very process created is ours by construction and stays + // reusable even when its marker cannot be re-verified mid-run (for + // example a permission failure after the first payload moved in). + if (currentRunBackupRoots.has(root)) return true; + return isProvenSkillBackupRoot(root); +} + +// isProvenSkillBackupRoot reports whether a stamp root carries the ownership +// marker with the exact expected bytes; anything else is foreign data. +function isProvenSkillBackupRoot(root) { + try { + return fs.readFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), "utf8") === SKILL_BACKUP_MARKER_BODY; + } catch { + return false; + } +} + +// skillBackupStampPattern matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/; + +function isSkillBackupStamp(name) { + return skillBackupStampPattern.test(name); +} + +// pruneSkillBackups removes the oldest stamped backup directories once more +// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS +// backup stamp format AND whose ownership marker verifies are candidates; +// unknown or unmarked directories are foreign data — preserved and never +// counted against SKILL_BACKUP_KEEP. Stamps sort lexicographically in +// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +function pruneSkillBackups(homeDir) { + const root = path.join(homeDir, ".dws", "skill-backups"); + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (err) { + if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return; + throw err; + } + const names = entries + .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) + .filter((entry) => isProvenSkillBackupRoot(path.join(root, entry.name))) + .map((entry) => entry.name) + .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + let excess = names.length - SKILL_BACKUP_KEEP; + for (const name of names) { + if (excess <= 0) break; + const candidate = path.join(root, name); + if (currentRunBackupRoots.has(candidate)) continue; + fs.rmSync(candidate, { recursive: true, force: true }); + excess -= 1; + } +} + +function pathExistsLexicallySync(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function copyPathLexicallySync(src, dest) { + const info = fs.lstatSync(src); + if (info.isDirectory()) { + fs.mkdirSync(dest, { mode: 0o700 }); + for (const name of fs.readdirSync(src)) { + copyPathLexicallySync(path.join(src, name), path.join(dest, name)); + } + fs.chmodSync(dest, info.mode & 0o777); + return; + } + if (info.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + return; + } + if (!info.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, info.mode & 0o777); +} + +function restoreDirectoryModesSync(modes, chmodFn) { + const failures = []; + for (let i = modes.length - 1; i >= 0; i -= 1) { + const item = modes[i]; + try { + if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode); + } catch (err) { + failures.push(`restore source directory mode ${item.path}: ${err.message}`); + } + } + if (failures.length > 0) throw new Error(failures.join("; ")); +} + +function prepareTreeRemovalSync(root, chmodFn) { + const modes = []; + const visit = (target) => { + const info = fs.lstatSync(target); + if (!info.isDirectory()) return; + const mode = info.mode & 0o777; + if ((mode & 0o700) !== 0o700) { + chmodFn(target, mode | 0o700); + modes.push({ path: target, mode }); + } + for (const name of fs.readdirSync(target)) visit(path.join(target, name)); + }; + try { + visit(root); + return modes; + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function makeTreeWritableBestEffortSync(root) { + try { + const info = fs.lstatSync(root); + if (!info.isDirectory()) return; + try { fs.chmodSync(root, 0o700); } catch {} + let names = []; + try { names = fs.readdirSync(root); } catch {} + for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name)); + } catch {} +} + +function removePublishedSourceSync(src, removeFn, chmodFn) { + const modes = prepareTreeRemovalSync(src, chmodFn); + try { + removeFn(src); + if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal"); + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function fileDigestSync(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function verifyPathCopySync(src, dest) { + const srcInfo = fs.lstatSync(src); + const destInfo = fs.lstatSync(dest); + const srcType = srcInfo.mode & fs.constants.S_IFMT; + const destType = destInfo.mode & fs.constants.S_IFMT; + if (srcType !== destType) { + throw new Error(`Skill path type mismatch: ${src} != ${dest}`); + } + if (srcInfo.isDirectory()) { + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`); + } + const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right)); + const srcNames = fs.readdirSync(src).sort(bytewise); + const destNames = fs.readdirSync(dest).sort(bytewise); + if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) { + throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`); + } + for (const name of srcNames) { + verifyPathCopySync(path.join(src, name), path.join(dest, name)); + } + return; + } + if (srcInfo.isSymbolicLink()) { + if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) { + throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`); + } + return; + } + if (!srcInfo.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`); + } + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill file mode mismatch: ${src} != ${dest}`); + } + if (srcInfo.size !== destInfo.size) { + throw new Error(`Skill file size mismatch: ${src} != ${dest}`); + } + if (fileDigestSync(src) !== fileDigestSync(dest)) { + throw new Error(`Skill file digest mismatch: ${src} != ${dest}`); + } +} + +// removeClaimIfEmptySync deletes a claimed destination only while it holds no +// entries. A foreign entry that appeared after the claim must never be +// removed: the rollback has no ownership proof for it. +function removeClaimIfEmptySync(destination) { + try { + fs.rmdirSync(destination); + } catch (err) { + if (err && (err.code === "ENOTEMPTY" || err.code === "EEXIST" || err.code === "EPERM")) { + return; + } + throw err; + } +} + +function destinationExistsError(destination) { + return Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); +} + +// moveChildIntoClaimNoClobberSync moves one staged child into the claimed +// destination through an atomic no-clobber primitive: directories recursively +// claim with mkdir, regular files publish via fs.linkSync, and symlinks are +// recreated with fs.symlinkSync. Every primitive fails with EEXIST when the +// destination entry was taken concurrently, so no step can replace a foreign +// object the way a plain rename would. +function moveChildIntoClaimNoClobberSync(source, destination, fns) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + // The recursive move leaves an emptied shell at the source. rmdirSync + // only deletes an empty directory, so clearing the shell keeps the + // single-move outcome the parent's rollback expects; a shell that gained + // a concurrent entry fails the removal and retracts the publication + // instead of silently discarding it. + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + removeClaimIfEmptySync(destination); + throw new Error( + `Skill move state uncertain; source shell ${source} removal failed: ${err.message}` + + (rollbackErr ? `; rollback failed: ${rollbackErr.message}` : ""), + ); + } + return; + } + if (sourceStat.isSymbolicLink()) { + const target = fs.readlinkSync(source); + try { + fns.symlinkFn(target, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + let current = null; + try { + current = fs.readlinkSync(destination); + } catch (_) { + // Dest no longer carries our link; leave whatever replaced it. + } + if (current === target) fs.unlinkSync(destination); + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fns.linkFn(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination, { bigint: true }); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (_) { + // Best effort; the error below names both retained paths. + } + throw err; + } + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +// moveChildrenBackOutOfClaimSync returns the already relocated children to +// the source with plain renames — every one of them is an object this +// transaction published, so a replacing rename is safe — and removes the +// claim only while it is empty. +function moveChildrenBackOutOfClaimSync(source, destination, moved, fns) { + let firstErr = null; + for (let i = moved.length - 1; i >= 0; i -= 1) { + try { + fns.renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); + } catch (err) { + firstErr = firstErr || err; + } + } + try { + removeClaimIfEmptySync(destination); + } catch (err) { + firstErr = firstErr || err; + } + return firstErr; +} + +// moveChildrenIntoClaimSync populates an already-claimed destination from the +// staged source. The claim must still be empty when it runs (a concurrent +// writer landing between the claim and here aborts the publication with the +// destination retained), every child moves through an atomic no-clobber +// primitive, and a live re-read catches a different-named foreign entry that +// landed mid-move. Any failure returns the moved children to the source and +// removes the claim only while it is empty, so foreign data always survives. +function moveChildrenIntoClaimSync(source, destination, sourceMode, fns) { + const claimEntries = fs.readdirSync(destination); + if (claimEntries.length > 0) { + throw destinationExistsError(destination); + } + const entries = fs.readdirSync(source); + const moved = []; + for (const name of entries) { + try { + moveChildIntoClaimNoClobberSync(path.join(source, name), path.join(destination, name), fns); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${err.message}; rollback failed: ${rollbackErr.message}`); + } + throw err; + } + moved.push(name); + } + const live = fs.readdirSync(destination); + if (live.length > entries.length) { + const abortErr = destinationExistsError(destination); + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${abortErr.message}; rollback failed: ${rollbackErr.message}`); + } + throw abortErr; + } + fns.chmodFn(destination, sourceMode); +} + +function defaultClaimFns(renameFn) { + return { + mkdirFn: (target) => fs.mkdirSync(target, { mode: 0o700 }), + renameFn: renameFn || fs.renameSync, + linkFn: fs.linkSync, + symlinkFn: fs.symlinkSync, + chmodFn: fs.chmodSync, + }; +} + +function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + const fns = defaultClaimFns(renameFn); + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + if (rollbackErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${rollbackErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isSymbolicLink()) { + try { + fs.symlinkSync(fs.readlinkSync(source), destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + fs.unlinkSync(destination); + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fs.linkSync(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +function recordSkillPathPublicationSync(destination) { + const stat = fs.lstatSync(destination, { bigint: true }); + return { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(destination), + }; +} + +function retractUnconfirmedSkillPublicationSync(src, dest, publication, cause, options = {}) { + try { + rollbackPublishedSkillPath(publication, { + quarantineRenameFn: options.quarantineRenameFn, + restoreRenameFn: options.restoreRenameFn || options.renameFn, + removeFn: options.removeFn, + }); + } catch (retractErr) { + throw new Error(`Skill move state uncertain: ${cause.message}; failed to retract ${dest}: ${retractErr.message}; source ${src} and dest ${dest} retained`); + } + throw new Error(`Skill move failed, dest retracted, source retained (${src}): ${cause.message}`); +} + +function movePathRecoverablySync(src, dest, options = {}) { + const renameFn = options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const copyFn = options.copyFn || copyPathLexicallySync; + const verifyFn = options.verifyFn || verifyPathCopySync; + const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync; + const chmodFn = options.chmodFn || fs.chmodSync; + if (pathExistsLexicallySync(dest)) { + throw new Error(`move destination already exists: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + try { + renameFn(src, dest); + return; + } catch (err) { + if (!err || err.code !== "EXDEV") throw err; + } + + const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); + const stage = path.join(stageRoot, "payload"); + let stageCleaned = false; + let destOccupied = false; + try { + copyFn(src, stage); + verifyFn(src, stage); + const stageInfo = fs.lstatSync(stage); + const stageMode = stageInfo.mode & 0o777; + if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); + // Dest publication is always no-replace. The injected renameFn is the + // same-volume EXDEV probe; using it here would re-open a replacing + // rename on Windows (MOVEFILE_REPLACE_EXISTING). + renamePathNoReplaceSync(stage, dest, renameFn); + destOccupied = true; + let publication; + try { + publication = recordSkillPathPublicationSync(dest); + } catch (recErr) { + if (!pathExistsLexicallySync(dest)) throw recErr; + throw new Error(`Skill move state uncertain: published dest ${dest} could not be recorded: ${recErr.message}; source ${src} and dest ${dest} retained`); + } + try { + if (stageInfo.isDirectory()) { + chmodFn(dest, stageMode); + publication = recordSkillPathPublicationSync(dest); + } + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + } catch (postErr) { + retractUnconfirmedSkillPublicationSync(src, dest, publication, postErr, { + removeFn, + }); + } + try { + removePublishedSourceSync(src, removeFn, chmodFn); + } catch (err) { + throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`); + } + if (pathExistsLexicallySync(src)) { + throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`); + } + } catch (err) { + if (!stageCleaned) { + try { + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + } catch (cleanupErr) { + if (!destOccupied) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } + } + } + throw err; + } +} + +function skillMoveOptions(options = {}) { + return { + renameFn: options.renameFn, + copyFn: options.backupCopyFn, + verifyFn: options.backupVerifyFn, + removeFn: options.backupRemoveFn, + mkdirTempFn: options.backupMkdirTempFn, + }; +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it (non-interactive // installs cannot confirm, so removals must stay reversible). Missing paths // are a no-op success. On any backup failure the directory is left in place -// and false is returned so callers skip that target rather than silently -// deleting data. -function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { - return true; +// and an error is returned so callers cannot silently delete data. +function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { + try { + fs.lstatSync(dir); + } catch (err) { + if (err && err.code === "ENOENT") return ""; + throw err; } const rel = path.relative(homeDir, dir); const name = rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("-") : path.basename(dir); - const stamp = backupStamp(); + const stamp = (options.backupStampFn || backupStamp)(); const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; fs.existsSync(target); i++) { + // Bump not only when the payload path is taken but also when the stamp + // root exists without a verified ownership marker: a same-second foreign + // directory must never be stamped DWS-owned and made prunable. A + // marker-verified root from this run's same second stays reusable. + for (let i = 1; pathExistsLexicallySync(target) || !skillBackupRootUsable(targetRoot); i++) { if (i > 1000) { - console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`); - return false; + throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } targetRoot = path.join(backupRoot, `${stamp}-${i}`); target = path.join(targetRoot, name); } try { fs.mkdirSync(targetRoot, { recursive: true }); - renameFn(dir, target); + // Stamp ownership immediately after creating the stamp root and before + // the skill directory moves into it, so an interrupted backup can never + // leave an unmarked (never-prunable) stamp behind. + writeSkillBackupMarker(targetRoot); + } catch (err) { + // Never leave an empty unowned stamp root behind. rmdirSync removes an + // empty root but deliberately fails on a non-empty one, so a pre-existing + // root holding foreign data is never destroyed. + try { + fs.rmdirSync(targetRoot); + } catch { + // Non-empty pre-existing root: foreign data stays. + } + console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); + } + try { + movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); - return false; + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); } if (backups) { backups.push({ original: dir, backup: target }); } + currentRunBackupRoots.add(targetRoot); + // Keep the backup root bounded. The backup itself already succeeded, so a + // prune failure is only a warning (matches Go's best-effort prune call). + try { + pruneSkillBackups(homeDir); + } catch (err) { + console.warn(`⚠️ 旧备份清理失败(备份本身已成功): ${err.message}`); + } console.log(` × 已备份并移除 ${dir} → ${target}`); - return true; + return target; } function findBinary(root) { @@ -234,15 +940,11 @@ function installSkillsToHomes(skillRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -256,37 +958,48 @@ function installSkillsToHomes(skillRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); - - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) { + installed += 1; + } else { + failed += 1; } - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (universal) { + // Cleanup-only migration: universal Agents read the canonical store + // directly, so nothing is installed here. A retire failure leaves an + // obsolete copy in place — worth a warning, never a reason to fail an + // install whose canonical store and links all succeeded. + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + victims.push(path.join(baseDir, entry.name)); + } + } + } + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); + installed += 1; + } catch (linkErr) { + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; + } } } if (installed === 0) { @@ -296,6 +1009,10 @@ function installSkillsToHomes(skillRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); } fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true }); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and @@ -346,12 +1063,12 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireGenericSkillRoot(homeDir, managedNames) { - const baseDir = path.join(homeDir, ".agents", "skills"); +function retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) { + const moveOptions = skillMoveOptions(options); const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -359,27 +1076,345 @@ function retireGenericSkillRoot(homeDir, managedNames) { const backups = []; try { for (const victim of victims) { - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } } catch (err) { const restoreErrors = []; for (let i = backups.length - 1; i >= 0; i -= 1) { try { fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); } catch (restoreErr) { restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`); } } if (restoreErrors.length > 0) { - throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`); + throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`); } throw err; } } +function samePhysicalDir(left, right) { + try { + const leftReal = fs.realpathSync(left); + const rightReal = fs.realpathSync(right); + return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal; + } catch (_) { + return false; + } +} + +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 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 = {}) { + // Prove ownership at dest first. Quarantining before that check would move + // a concurrent replacement off its original path; if the later match fails + // and restore cannot republish, user data stays hidden under .rollback-*. + // Quarantine itself is a same-inode rename — a no-replace mkdir-claim + // would mint a new identity and make this transaction's own dest look + // foreign. A post-quarantine mismatch is restored with no-replace. + const quarantineRenameFn = options.quarantineRenameFn || fs.renameSync; + const restoreRenameFn = options.restoreRenameFn || options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + 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"); + const cleanupRoot = () => { + try { + fs.rmSync(quarantineRoot, { recursive: true, force: true }); + } catch (cleanupErr) { + throw new Error(`failed to clean Skill rollback quarantine ${quarantineRoot}: ${cleanupErr.message}`); + } + }; + try { + if (!skillPublicationMatches(destination, publication)) { + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); + } + } catch (err) { + if (err && err.code === "ENOENT") { + // ENOENT from the fingerprint walk means the destination itself is + // gone only when it no longer exists lexically; a concurrently removed + // CHILD produces the same error and must fall through to the refusal + // below instead of skipping rollback. + if (!pathExistsLexicallySync(destination)) { + cleanupRoot(); + return; + } + } + if (String(err.message || "").startsWith("refusing to delete non-transaction Skill") + || String(err.message || "").startsWith("failed to clean Skill rollback quarantine")) { + throw err; + } + cleanupRoot(); + throw new Error(`refusing to delete unverifiable Skill ${destination}: ${err.message}`); + } + + try { + quarantineRenameFn(destination, quarantine); + } catch (err) { + cleanupRoot(); + 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) { + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}`); + } + if (!owned) { + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); + } + removeFn(quarantine); + cleanupRoot(); +} + +// Publish a staged Skill directory with a truly atomic no-replace claim. +// fs.mkdirSync fails with EEXIST when anything occupies the destination, so +// the claim itself is the existence check — there is no check-then-rename +// window a concurrent file, symlink, or empty directory could slip through +// (fs.renameSync replaces the destination outright on every platform, +// including Windows where libuv passes MOVEFILE_REPLACE_EXISTING). Children +// then move into the claimed directory one by one; each child rename targets +// a path inside a directory this transaction owns, so no step can replace a +// foreign object. The publication identity is the claim's: it is captured +// after the claim succeeds and re-verified by the caller once the move +// completes. On a failed child move the relocated children are moved back and +// only the (now empty) claim is removed, so the destination returns to the +// foreign object or to nothing — never to a partial publication. +function publishSkillDirNoReplace(staged, destination, options = {}) { + const fns = { + mkdirFn: options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })), + renameFn: options.publishRenameFn || fs.renameSync, + linkFn: options.publishLinkFn || fs.linkSync, + symlinkFn: options.publishSymlinkFn || fs.symlinkSync, + chmodFn: options.publishChmodFn || fs.chmodSync, + }; + const sourceStat = fs.lstatSync(staged); + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + const claimStat = fs.lstatSync(destination, { bigint: true }); + const publication = { + destination, + device: claimStat.dev.toString(), + inode: claimStat.ino.toString(), + fingerprint: skillPathFingerprint(staged), + }; + moveChildrenIntoClaimSync(staged, destination, sourceStat.mode & 0o7777, fns); + return publication; +} + +// Publish a staged canonical link by creating the link directly at the +// destination. Link creation is the atomic no-replace primitive on every +// platform: the kernel refuses with EEXIST when anything — file, symlink, or +// directory — already occupies the path, and unlike a rename (which Windows +// performs with MOVEFILE_REPLACE_EXISTING) or `ln -P source target` (which +// links INTO a directory that appeared at the target) it has no +// check-then-publish window and never treats the destination as a container. +// Staging still proves beforehand that links can be created on this +// filesystem, before any victim moves. The publication identity is captured +// from the destination after creation — no primitive carries the staged +// inode across this publish — and confirmation re-reads the live path, so a +// concurrent replacement surfaces before the publication enters the rollback +// list instead of being recorded as owned. +function publishCanonicalLinkNoReplace(staged, destination, options = {}) { + const linkTarget = fs.readlinkSync(staged); + const linkType = process.platform === "win32" ? "junction" : "dir"; + const symlinkFn = options.publishSymlinkFn || fs.symlinkSync; + try { + symlinkFn(linkTarget, destination, linkType); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + let publication; + try { + publication = recordSkillPathPublicationSync(destination); + } catch (recErr) { + if (!skillPathExistsLexically(destination)) throw recErr; + throw new Error(`Skill publish state uncertain: dest ${destination} occupied but unrecorded: ${recErr.message}`); + } + let destStat; + try { + destStat = fs.lstatSync(destination, { bigint: true }); + } catch (err) { + throw new Error(`published Skill identity changed before confirmation: ${destination}: ${err.message}`); + } + if (!destStat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) { + // Dest is not the link this transaction created. Leave the occupant. + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + const sameInode = publication + && destStat.dev.toString() === publication.device + && destStat.ino.toString() === publication.inode; + if (!publication || !sameInode || !skillPublicationMatches(destination, publication)) { + if (sameInode) { + try { + rollbackPublishedSkillPath(publication); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: published dest ${destination} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + return publication; +} + +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { + const moveOptions = skillMoveOptions(options); + const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + // Injectable so the Windows junction type and absolute link target can be + // asserted from a non-Windows host. + const symlinkFn = options.symlinkFn || fs.symlinkSync; + fs.mkdirSync(baseDir, { recursive: true }); + const realBaseDir = fs.realpathSync(baseDir); + const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); + const staged = []; + const backups = []; + const published = []; + const correctLinks = new Set(); + const restore = () => { + const restoreErrors = []; + for (let i = published.length - 1; i >= 0; i -= 1) { + try { + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn: publishRemoveFn }); + } catch (err) { + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); + } + } + for (let i = backups.length - 1; i >= 0; i -= 1) { + try { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); + } catch (err) { + restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`); + } + } + if (restoreErrors.length > 0) { + throw new Error(restoreErrors.join("; ")); + } + }; + try { + for (const name of names) { + const target = path.join(canonicalBase, name); + const dest = path.join(baseDir, name); + if (samePhysicalDir(dest, target)) { + correctLinks.add(path.resolve(dest)); + continue; + } + const stagedPath = path.join(stageRoot, name); + const realTarget = fs.realpathSync(target); + const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); + symlinkFn(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); + } + const seen = new Set(); + for (const victim of victims) { + const normalized = path.resolve(victim); + if (seen.has(normalized) || correctLinks.has(normalized)) continue; + seen.add(normalized); + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); + } + for (const item of staged) { + const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options); + published.push(publication); + } + } catch (err) { + try { + restore(); + } catch (restoreErr) { + throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`); + } + throw err; + } finally { + fs.rmSync(stageRoot, { recursive: true, force: true }); + } +} + function skillDirectoryDigest(dir) { const files = []; const visit = (current, prefix) => { @@ -418,8 +1453,9 @@ function publishManagedMultiSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const staged = []; @@ -430,16 +1466,16 @@ function publishManagedMultiSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } 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) { const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -463,14 +1499,28 @@ function publishManagedMultiSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - renameFn(item.staged, item.dest); - published.push(item.dest); + const publication = publishSkillDirNoReplace(item.staged, item.dest, options); + if (!skillPublicationMatches(item.dest, publication)) { + let live = null; + try { + live = fs.lstatSync(item.dest, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${item.dest} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + } + published.push(publication); } } catch (err) { try { @@ -495,8 +1545,9 @@ function publishManagedMonoSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stagedDir = path.join(stageRoot, "dws"); @@ -508,16 +1559,16 @@ function publishManagedMonoSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } 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) { const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -537,13 +1588,27 @@ function publishManagedMonoSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } - published.push(destDir); - renameFn(stagedDir, destDir); + const publication = publishSkillDirNoReplace(stagedDir, destDir, options); + if (!skillPublicationMatches(destDir, publication)) { + let live = null; + try { + live = fs.lstatSync(destDir, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${destDir} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + } + published.push(publication); } catch (err) { try { restore(); @@ -628,10 +1693,6 @@ function installMultiSkillsToHomes(multiRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { fs.mkdirSync(baseDir, { recursive: true }); const victims = [path.join(baseDir, "dws")]; @@ -639,7 +1700,7 @@ function installMultiSkillsToHomes(multiRoot) { // the same transaction as every replaced bundled skill. for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { if ( - entry.isDirectory() && + (entry.isDirectory() || entry.isSymbolicLink()) && (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && !skillSet.has(entry.name) ) { @@ -658,37 +1719,48 @@ function installMultiSkillsToHomes(multiRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) installed += 1; + else failed += 1; - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } - } - - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (universal) { + // Cleanup-only migration (see installSkillsToHomes): nothing is + // installed in a universal root, so a retire failure must not fail an + // otherwise complete install. Go records the same case as a failed + // directory yet still returns success for the run. + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ( + (entry.isDirectory() || entry.isSymbolicLink()) && + (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && + !skillSet.has(entry.name) + ) victims.push(path.join(baseDir, entry.name)); + } + } + for (const name of skills) victims.push(path.join(baseDir, name)); + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); + installed += 1; + } catch (linkErr) { + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; + } } } if (installed === 0) { @@ -698,6 +1770,10 @@ function installMultiSkillsToHomes(multiRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); } writeSkillsState(homeDir, multiRoot, skills); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi) @@ -812,7 +1888,19 @@ if (require.main === module) { } module.exports = { + UPSTREAM_AGENTS, + resolvedAgentTargets, + agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, + backupAndRemoveSkillDir, + copyPathLexicallySync, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, + verifyPathCopySync, }; diff --git a/build/npm/package.json.tmpl b/build/npm/package.json.tmpl index f1d70d18..b7d34154 100644 --- a/build/npm/package.json.tmpl +++ b/build/npm/package.json.tmpl @@ -32,6 +32,6 @@ "README.md" ], "engines": { - "node": ">=16" + "node": ">=16.7.0" } } diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go index bfaab482..f00252c9 100644 --- a/cmd/interface-snapshot/main.go +++ b/cmd/interface-snapshot/main.go @@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "", "candidate flag migration manifest", ) + approvedCommandMigrationsPath := flags.String( + "approved-command-migrations", + "", + "merge-base-owned approved command migration manifest", + ) + candidateCommandMigrationsPath := flags.String( + "candidate-command-migrations", + "", + "candidate command migration manifest", + ) if err := flags.Parse(args); err != nil { return false, err } @@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "--approved-flag-migrations and --candidate-flag-migrations must be provided together", ) } - if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") { - return false, fmt.Errorf("flag migration compare requires both --base and --stable") + if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") { + return false, fmt.Errorf( + "--approved-command-migrations and --candidate-command-migrations must be provided together", + ) + } + if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") { + return false, fmt.Errorf("migration compare requires both --base and --stable") } current, err := readSnapshot(*currentPath) @@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { } report := interfacesnapshot.CompareAll(current, references) - if *approvedMigrationsPath != "" { + if *approvedCommandMigrationsPath != "" { + flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}} + flagCandidate := flagApproved + if *approvedMigrationsPath != "" { + flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath) + if err != nil { + return false, fmt.Errorf("read approved flag migrations: %w", err) + } + flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath) + if err != nil { + return false, fmt.Errorf("read candidate flag migrations: %w", err) + } + } + commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read approved command migrations: %w", readErr) + } + commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read candidate command migrations: %w", readErr) + } + report, err = interfacesnapshot.CompareAllWithInterfaceMigrations( + current, + references, + flagApproved, + flagCandidate, + commandApproved, + commandCandidate, + ) + if err != nil { + return false, fmt.Errorf("validate interface migration lifecycle: %w", err) + } + } else if *approvedMigrationsPath != "" { approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath) if readErr != nil { return false, fmt.Errorf("read approved flag migrations: %w", readErr) @@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani return interfacesnapshot.ReadFlagMigrationManifest(file) } +func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + defer file.Close() + return interfacesnapshot.ReadCommandMigrationManifest(file) +} + func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error { for _, command := range snapshot.Commands { path := strings.TrimPrefix(command.Path, "dws") @@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") - fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]") } diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go index 62bb91cb..7bb44fb7 100644 --- a/cmd/interface-snapshot/main_test.go +++ b/cmd/interface-snapshot/main_test.go @@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi } } +func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`) + emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`) + invalid := writeManifest(t, dir, "invalid-commands.json", `{`) + + var stdout, stderr bytes.Buffer + args := []string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + "--stable", snapshotPath, + "--approved-flag-migrations", emptyFlag, + "--candidate-flag-migrations", emptyFlag, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(args, &stdout, &stderr); exitCode != 0 { + t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String()) + } + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved flag", invalid, emptyFlag, "read approved flag migrations"}, + {"candidate flag", emptyFlag, invalid, "read candidate flag migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-flag-migrations", test.approved, + "--candidate-flag-migrations", test.candidate, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved", invalid, emptyCommand, "read approved command migrations"}, + {"candidate", emptyCommand, invalid, "read candidate command migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", test.approved, + "--candidate-command-migrations", test.candidate, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, + "--approved-command-migrations", emptyCommand, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") { + t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String()) + } + + if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil { + t.Fatal("missing command migration manifest unexpectedly read") + } + if _, err := readCommandMigrationManifest(invalid); err == nil { + t.Fatal("invalid command migration manifest unexpectedly read") + } + + pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending")) + consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed")) + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", pending, + "--candidate-command-migrations", consumed, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") { + t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String()) + } +} + func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) { dir := t.TempDir() currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws")) @@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string { }`, "STATE", state, 1) } +func commandMigrationManifestJSON(state string) string { + return strings.Replace(`{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.move", + "replacement_tool_id": "chat.move", + "parameters": [] + }, + "state": "STATE", + "reason": "reviewed command migration" + }] +}`, "STATE", state, 1) +} + func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { for _, flag := range flags { if flag.Name == name && flag.Type == flagType { diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index d34f3679..8016833d 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -184,11 +184,11 @@ candidate SHA。 `check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准 flag 迁移。 -Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag -migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整 -Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface -lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会 -放宽其他 Schema 历史字段。 +Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag +与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与 +stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 +Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史 +副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。 For a release-seal branch that archives rendered fragments: diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index d4a50124..60c5b684 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -1,7 +1,9 @@ -# CLI flag 兼容迁移治理 +# CLI Help / Schema 兼容迁移治理 本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。 +同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。 + 同名 flag 的精确类型迁移属于另一类评审机制,只能进入 `internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename 只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。 @@ -31,7 +33,7 @@ Smoke fixture,不参与迁移审批。 同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免 调用方因漏传历史参考而提前清理 consumed receipt。 -PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 +PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。 @@ -39,10 +41,55 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁 ```text scripts/policy/interface-migrations/approved-flag-migrations-v1.json +scripts/policy/interface-migrations/approved-command-migrations-v1.json ``` 清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 +## 跨命令迁移原语 + +`approved-command-migrations-v1.json` 只接受两种 `kind`: + +| kind | CLI after 状态 | Schema 允许的精确投影 | +|---|---|---| +| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | +| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 | + +`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement +不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 +稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 +`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史 +Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受 +optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool +的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好 +一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有 +`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于 +常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此 +`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即 +`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。 + +`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot +必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、 +值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create` +必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入 +`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份 +同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加 +bool 常量证据属于 bootstrap;一旦任一历史快照已记录该 +证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后 +删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则 +`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与 +`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用 +独立 PR 合入,不能和产品消费混在一起。 + +replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值; +历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。 + +两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 + +跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 + +当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。 + ## 两阶段迁移与回执清理 每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a33bc95f..01db6595 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约 | 模式 | Agent 目录布局 | 选择方式 | |---|---|---| -| multi(默认) | `/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | -| mono(兼容) | `/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | +| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` | +| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in | 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 @@ -140,10 +140,26 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器对每个 Agent 目标执行: +升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 +universal 的 Agent 不再保留 Agent 私有副本;检测到的 +非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical +的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` / +`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的 +直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。 +自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 +`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 +`.moltbot` 必须按 Agent 实际优先级解析。 -- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback; -- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交 +`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 +non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent +解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 +Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 +`.amp/skills`、`.cline/skills` 与 +`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 +对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过 +Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native +路径,不作为 universal symlink 模式的发布目标。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; @@ -151,6 +167,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen 4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录; 5. 仅在没有目标失败且至少一个目标成功时更新状态快照。 +旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入 +`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子 +rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并 +保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、 +文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标 +再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除 +失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。 + Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前; 备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同 Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与 @@ -159,11 +183,31 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 ## 9. 备份合同 - 路径:`~/.dws/skill-backups//...`; -- 主要操作:同一文件系统内使用 rename 移动; +- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的 + copy → verify → publish → remove 回退; - 失败语义:备份失败时原目录保持不变,目标安装失败; +- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在, + 但恢复必须失败并明确提示两份均被保留; - 可见性:计划和执行日志显示原路径与备份路径; - 保留策略:自动修剪,仅保留最近 5 批。 +跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统 +原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell +入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 +上述验证和失败合同。 + +原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件 +系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与 +overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级 +为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务 +持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入); +普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会 +回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会 +被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受 +边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标, +无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在 +目标路径创建(symlink(2) 原子拒绝已占用路径)。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 @@ -192,6 +236,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。 | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | +| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 | Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 @@ -209,6 +254,12 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; +- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` / + `dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复; +- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; +- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 + 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; +- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md new file mode 100644 index 00000000..064abb6b --- /dev/null +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -0,0 +1,310 @@ +# Attendance Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:Lark CLI 1.0.87 +> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 + +## 1. 执行摘要 + +- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 +- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 +- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 +- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | +| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | +| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | +| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | +| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | +| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | +| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | +| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | +| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | +| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | +| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | +| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | +| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | +| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | +| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | +| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | +| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | + +## 3. 下游需求明细 + +### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 + +#### A. 用户任务与现状 + +- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 +- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 +- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 +- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 +- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 +- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 +- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 +- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 +- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 +- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 +- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 + +#### C. 验收标准 + +1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 +2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 +3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 +4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 + +#### D. 临时处置 + +`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 + +### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 + +#### A. 用户任务与现状 + +- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 +- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 +- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 +- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 +- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 +- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 +- 安全证据句柄:`ATT-REPORT-NULL-01`。 + +#### B. 需要下游提供的合同 + +- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 +- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 +- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 +- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 +- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 +- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 + +#### C. 验收标准 + +1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 +2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 +3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 +4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 +5. 分页/partial 分支和远端零残留通过。 + +#### D. 临时处置 + +三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 + +### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 + +#### A. 用户任务与现状 + +- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 +- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 +- 安全证据句柄:`ATT-CHECK-PAGE-01`。 + +#### B. 需要下游提供的合同 + +- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 +- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 +- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 +- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 + +#### C. 验收标准与临时处置 + +- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 +- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 + +### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 + +#### A. 用户任务与现状 + +- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 +- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 +- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 +- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 +- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 + +#### B. 需要的测试基础设施与合同 + +- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 +- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 +- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 +- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 +- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 + +#### C. 验收标准与临时处置 + +1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 +2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 +3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 +4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 +5. 测试结束远端和本地均零残留。 + +在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 + +### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture + +#### A. 用户任务与现状 + +- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 +- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 +- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 +- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 +- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 + +#### B. 需要下游提供的合同与 fixture + +- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 +- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 +- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 +- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 + +#### C. 验收标准与临时处置 + +1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 +2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 +3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 + +在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 + +### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 + +#### A. 用户任务与现状 + +- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 +- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 +- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 +- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 +- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 +- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 +- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 +- 改动需 additive/versioned;现有详情业务字段保持兼容。 + +#### C. 验收标准与临时处置 + +1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 +2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 +3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 + +下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 + +### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture + +#### A. 用户任务与现状 + +- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 +- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 +- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 +- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 + +#### B. 需要下游提供的合同与 fixture + +- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 +- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 +- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 +- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 + +#### C. 验收标准与临时处置 + +1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 +2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 +3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 +4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 + +能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 + +### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 + +#### A. 用户任务与现状 + +- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 +- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 +- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 +- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 +- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 + +#### B. 需要下游提供的合同 + +- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 +- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 +- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 +- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 + +#### C. 验收标准与临时处置 + +1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 +2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 +3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 +4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 + +下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---|---| +| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | + +无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---|---| +| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | +| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | +| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | +| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | +| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | +| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | +| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | +| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | + +### 6.1 clean-HEAD live 发布门状态 + +| 叶子 | clean executable HEAD 双层证据 | 发布状态 | +|---|---|---| +| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | +| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | +| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | + +pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 + +上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 + +## 7. 安全与脱敏声明 + +- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 +- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 +- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 +- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md new file mode 100644 index 00000000..a9fede56 --- /dev/null +++ b/docs/shortcut-downstream-requirements/mail.md @@ -0,0 +1,198 @@ +# Mail Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 +> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 + +## 1. 执行摘要 + +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 + +上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 + +仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | +| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | +| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | +| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | +| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | +| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | +| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | +| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | +| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | +| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | +| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | +| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | +| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | +| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | +| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | +| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | +| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | +| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | +| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | +| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | +| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | +| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | + +## 3. 下游需求明细 + +### `DS-Mail-001` — 可验证的发送生命周期 + +- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 +- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 +- 所需接口合同: + - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 + - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 + - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 + - 支持幂等键,或明确 unknown commit 不可自动重试。 + - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 +- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 + +### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 + +- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 +- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 +- 所需接口合同: + - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 + - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 + - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 +- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 + +### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 + +- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 +- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 +- 所需接口合同: + - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 + - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 + - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 + - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 +- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 + +### `DS-Mail-004` — 已读回执与签名 + +- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 +- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 +- 所需接口合同: + - 消息详情公开稳定回执请求状态和请求者身份类型。 + - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 + - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 +- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 + +### `DS-Mail-005` — 新邮件监听的订阅生命周期 + +- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 +- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 +- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 +- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 + +### `DS-Mail-006` — 模板附件与内联图片事务 + +- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 +- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 +- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 +- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 + +### `DS-Mail-007` — 联系人写操作的稳定身份 + +- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 +- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 +- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 +- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 + +### `DS-Mail-008` — 统一成功、空结果与分页协议 + +- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 +- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 +- 所需接口合同: + - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 + - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 + - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 + - 保留兼容期,但提供 capability/version 让上游安全切换。 +- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 + +### `DS-Mail-009` — 安全租户与真实 E2E fixture + +- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 +- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 +- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 +- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 + +### `DS-Mail-010` — 草稿/模板可证明的清理终态 + +- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 +- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 +- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 +- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 +- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---| +| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | +| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | +| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | +| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | +| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | +| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | +| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | +| `+signature` | no | 缺签名读取资源 | platform unavailable | +| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | +| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | +| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---| +| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | +| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | +| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | +| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | +| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | +| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | +| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | +| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | + +### 6.1 clean executable HEAD 双层证据 + +| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | +|---|---|---| +| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | +| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | + +8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 + +## 7. 安全与脱敏声明 + +- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 +- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 +- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 +- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 +- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 +- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 30bfa1d6..a13ab19a 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T17:38:50.904696", - "count": 436, + "generated_at": "2026-08-19T10:35:58.304269", + "count": 423, "results": [ { "suite": "semantic", @@ -933,137 +933,84 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-result", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-adjustment-rule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-approve-template", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-checkin-record", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-leave-records", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-overtime-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-schedule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-self-setting", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-summary", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+list-approve", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+list-leave-types", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+my-attendance", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+query-report-data", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-adjustment-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-class", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+search-group", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-overtime-rule", "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+this-month", - "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { "suite": "semantic", @@ -3305,74 +3252,84 @@ "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+contact-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+find-mail-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+folder-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+recent-mail", + "command": "+message", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+messages", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+search-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+tag-list", + "command": "+thread", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+template-list", + "command": "+triage", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。", + "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+thread-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "mail", - "command": "+unread-mail", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+user-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index a3b8906f..6458979e 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -274,6 +274,7 @@ type agentExampleFiles struct { markdown string json string batch string + job string binary string image string } @@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown := filepath.Join(root, "content.md") jsonFile := filepath.Join(root, "report.json") batch := filepath.Join(root, "styles.json") + job := filepath.Join(root, "job.json") binary := filepath.Join(root, "report.pdf") image := filepath.Join(root, "chart.png") for path, content := range map[string][]byte{ markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"), jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`), batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), + job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`), binary: []byte("%PDF-1.4\n%%EOF\n"), image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, } { @@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown: "./" + filepath.Base(markdown), json: "./" + filepath.Base(jsonFile), batch: "./" + filepath.Base(batch), + job: "./" + filepath.Base(job), binary: "./" + filepath.Base(binary), image: "./" + filepath.Base(image), } @@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin replacement = files.markdown case "contents-file": replacement = files.json + case "from": + if strings.HasSuffix(strings.ToLower(value), "job.json") { + replacement = files.job + } case "batch": if strings.HasSuffix(strings.ToLower(value), "styles.json") { replacement = files.batch diff --git a/internal/app/schema_recruit_contract_test.go b/internal/app/schema_recruit_contract_test.go new file mode 100644 index 00000000..ece8ceb9 --- /dev/null +++ b/internal/app/schema_recruit_contract_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any { + t.Helper() + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + args := []string{"schema", canonical, "--format", "json"} + if compact { + args = append(args, "--compact") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err) + } + return payload +} + +func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) { + for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} { + t.Run(canonical, func(t *testing.T) { + full := recruitSchemaLeaf(t, canonical, false) + compact := recruitSchemaLeaf(t, canonical, true) + if full["result"] == nil || compact["result"] == nil { + t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"]) + } + if !reflect.DeepEqual(full["result"], compact["result"]) { + t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"]) + } + if canonical == "recruit.list_jobs" { + if full["pagination"] == nil || compact["pagination"] == nil { + t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + if !reflect.DeepEqual(full["pagination"], compact["pagination"]) { + t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"]) + } + parameters, _ := full["parameters"].(map[string]any) + cursor, _ := parameters["cursor"].(map[string]any) + if cursor["type"] != "string" || cursor["interface_type"] != "number" { + t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor) + } + size, _ := parameters["size"].(map[string]any) + if required, _ := size["required"].(bool); required { + t.Fatalf("size required = true, want false: %#v", size) + } + if size["default"] != "20" { + t.Fatalf("size default = %#v, want 20", size["default"]) + } + compactParameters, _ := compact["parameters"].(map[string]any) + compactSize, _ := compactParameters["size"].(map[string]any) + if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" { + t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize) + } + } else if full["pagination"] != nil || compact["pagination"] != nil { + t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + }) + } +} diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index a4c5d83e..bb9127c4 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 435 + publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 438 + schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 435 + publiclyDeliveredShortcutCount = 422 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8db71d07..e0087209 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -117,38 +117,114 @@ type CliSkillDTO struct { // (skill_setup.go) MUST have a matching path value here — enforced by // TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ - // `agents` is the generic-agent sentinel: install scripts and `setup` - // special-case ~/.agents/skills as a no-checks-required fallback so a - // fresh machine without any IDE/agent registry still gets skills. - "agents": ".agents/skills", - "qoder": ".qoder/skills", - "qoderwork": ".qoderwork/skills", + // Universal agents. Those without an independent global directory map + // directly to the canonical ~/.agents/skills store. + "agents": ".agents/skills", + "amp": filepath.Join(".config", "agents", "skills"), + "antigravity": ".gemini/antigravity/skills", + "antigravity-cli": ".gemini/antigravity-cli/skills", + "codex": ".codex/skills", + "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", + "firebender": ".firebender/skills", + "gemini-cli": ".gemini/skills", + "github-copilot": ".copilot/skills", + "opencode": filepath.Join(".config", "opencode", "skills"), + "replit": filepath.Join(".config", "agents", "skills"), + "universal": filepath.Join(".config", "agents", "skills"), + "cline": ".agents/skills", + "dexto": ".agents/skills", + "kimi-code-cli": ".agents/skills", + "loaf": ".agents/skills", + "warp": ".agents/skills", + "zed": ".agents/skills", + + // Non-universal agents with global Skill directories. + "aider-desk": ".aider-desk/skills", + "astrbot": ".astrbot/data/skills", + "autohand-code": ".autohand/skills", + "augment": ".augment/skills", + "bob": ".bob/skills", + "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", + "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", + "codemaker": ".codemaker/skills", + "codestudio": ".codestudio/skills", + "command-code": ".commandcode/skills", + "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", + "crush": filepath.Join(".config", "crush", "skills"), + "devin": filepath.Join(".config", "devin", "skills"), + "droid": ".factory/skills", + "forgecode": ".forge/skills", + "goose": filepath.Join(".config", "goose", "skills"), + "grok": ".grok/skills", + "hermes-agent": ".hermes/skills", + "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", + "junie": ".junie/skills", + "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", + "kimchi": filepath.Join(".config", "kimchi", "harness", "skills"), + "kiro-cli": ".kiro/skills", + "kode": ".kode/skills", + "lingma": ".lingma/skills", + "mcpjam": ".mcpjam/skills", + "minimax-code": ".minimax/skills", + "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", + "mux": ".mux/skills", + "openhands": ".openhands/skills", + "ona": ".ona/skills", + "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", + "qoder-cn": ".qoder-cn/skills", + "qwen-code": ".qwen/skills", + "reasonix": ".reasonix/skills", + "rovodev": ".rovodev/skills", + "roo": ".roo/skills", + "tabnine-cli": ".tabnine/agent/skills", + "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", + "trae": ".trae/skills", + "trae-cn": ".trae-cn/skills", + "windsurf": ".codeium/windsurf/skills", + "zcode": ".zcode/skills", + "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", + "neovate": ".neovate/skills", + "pochi": ".pochi/skills", + "adal": ".adal/skills", + + // DWS compatibility aliases and DWS-only integrations. "claude": ".claude/skills", - "cursor": ".cursor/skills", - "codex": ".codex/skills", - "zcode": ".zcode/skills", - "opencode": filepath.Join(".config", "opencode", "skills"), - // IDE / agent registries also probed by `dws skill setup --target all`. - "gemini": ".gemini/skills", - "github": ".github/skills", - "windsurf": ".windsurf/skills", - "augment": ".augment/skills", - "cline": ".cline/skills", - "amp": ".amp/skills", - "kiro": ".kiro/skills", - "trae": ".trae/skills", - "openclaw": ".openclaw/skills", - "hermes": ".hermes/skills", + "gemini": ".gemini/skills", + "github": ".copilot/skills", + "hermes": ".hermes/skills", + "kiro": ".kiro/skills", + "qoderwork": ".qoderwork/skills", +} + +// Eve has project-scoped Skill directories but no upstream globalSkillsDir. +// Keep it in the advertised enumeration while failing explicitly instead of +// pretending that a global install configured Eve. +var unsupportedGlobalAgentTargets = map[string]string{ + "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills", + "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills", } // supportedTargets returns a sorted, comma-separated list of supported // targets. Sorted so help text and error messages stay stable across runs // (Go map iteration order is intentionally randomized). func supportedTargets() string { - targets := make([]string, 0, len(agentSkillPaths)+1) + targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1) for target := range agentSkillPaths { targets = append(targets, target) } + for target := range unsupportedGlobalAgentTargets { + targets = append(targets, target) + } sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") @@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string { sort.Strings(names) var b strings.Builder for _, n := range names { - fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + installPath := agentSkillPaths[n] + if isUniversalSkillInstallTarget(n) { + installPath = ".agents/skills" + } + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath) } return b.String() } +// Universal Agents discover the shared ~/.agents/skills store directly. A +// marketplace install addressed to one of those Agent IDs must therefore +// publish to canonical instead of recreating an Agent-private duplicate. +func isUniversalSkillInstallTarget(target string) bool { + rel, ok := agentSkillPaths[target] + if !ok { + return false + } + base := filepath.Join("__home__", rel) + canonical := filepath.Join("__home__", ".agents", "skills") + return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base) +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -485,8 +578,13 @@ func resolveSkillTargetPath(target string) (string, error) { return os.Getwd() } - // Look up predefined agent paths - relPath, ok := agentSkillPaths[strings.ToLower(target)] + target = strings.ToLower(target) + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return "", errors.New(reason) + } + + // Look up predefined agent paths. + _, ok := agentSkillPaths[target] if !ok { return "", fmt.Errorf("unsupported target") } @@ -496,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return filepath.Join(homeDir, relPath), nil + destination := resolveSkillSetupBase(homeDir, target) + if isUniversalSkillInstallTarget(target) { + destination = filepath.Join(homeDir, ".agents", "skills") + } + return destination, nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 381c87bc..b8406cb0 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -28,6 +28,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestResolveSkillTargetPath(t *testing.T) { @@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) { { name: "cursor target", target: "cursor", - wantSuffix: filepath.Join(".cursor", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "codex target", target: "codex", - wantSuffix: filepath.Join(".codex", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "opencode target", target: "opencode", - wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { @@ -118,6 +119,37 @@ func TestResolveSkillTargetPath(t *testing.T) { } } +func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + if isUniversalSkillInstallTarget("missing-agent") { + t.Fatal("unknown Agent target classified as universal") + } + for _, target := range []string{ + "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", + "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", + "github-copilot", "kimi-code-cli", "loaf", "opencode", "replit", + "universal", "warp", "zed", + } { + got, err := resolveSkillTargetPath(target) + if err != nil { + t.Fatalf("resolve %s: %v", target, err) + } + if want := filepath.Join(home, ".agents", "skills"); got != want { + t.Errorf("resolve %s = %s, want canonical %s", target, got, want) + } + } + for target, want := range map[string]string{ + "claude": filepath.Join(home, ".claude", "skills"), + "qoder": filepath.Join(home, ".qoder", "skills"), + "zcode": filepath.Join(home, ".zcode", "skills"), + } { + if got, err := resolveSkillTargetPath(target); err != nil || got != want { + t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want) + } + } +} + func TestResolveSkillTargetPathCurrentDir(t *testing.T) { // Test "." target returns current working directory cwd, err := os.Getwd() @@ -477,8 +509,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { for _, p := range agentSkillPaths { paths[p] = true } + legacyCleanupOnly := map[string]bool{ + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } for _, home := range skillSetupAgentHomes { - if !paths[home] { + if !paths[home] && !legacyCleanupOnly[home] { t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ "add it to agentSkillPaths so users can address it via --target ", home) } diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 62ab2c17..873ca5a7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -23,22 +24,77 @@ import ( // agree on the install footprint. var skillSetupAgentHomes = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } const ( @@ -65,15 +121,20 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupLstat = os.Lstat + skillSetupGetenv = os.Getenv + skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir + skillSetupRestoreBackup = upgrade.RestoreSkillPath skillSetupMkdirAll = os.MkdirAll skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel skillSetupReadlink = os.Readlink + skillSetupEvalSymlinks = filepath.EvalSymlinks skillSetupOpen = os.Open skillSetupOpenFile = os.OpenFile skillSetupWriteFile = os.WriteFile @@ -82,7 +143,10 @@ var ( skillSetupReadState = skillstate.Read skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove + skillSetupPublishPath = upgrade.PublishSkillPathNoReplace + skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications skillSetupNow = time.Now + skillSetupFoldPathCase = runtime.GOOS == "windows" ) type skillSetupBackup struct { @@ -91,9 +155,11 @@ type skillSetupBackup struct { } type skillSetupTargetPlan struct { - Destination string - Backups []skillSetupBackup - CleanupOnly bool + Destination string + CanonicalBase string + Backups []skillSetupBackup + CleanupOnly bool + LinkCanonical bool } type skillSetupPlan struct { @@ -149,8 +215,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录; -未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。 +DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装, +无需用户手动处理,也不会让同一个 Skill 重复出现。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } } - // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。 @@ -316,6 +382,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 { + retiredNames := append([]string(nil), multiSkillNames...) + if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) { + retiredNames = append(retiredNames, multiMiscSkill) + } + if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { + // Retiring an obsolete universal copy installs nothing; report it and + // keep the successful installation rather than failing the run. + fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr) + } + } if skipped > 0 { return fmt.Errorf( "Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill", @@ -350,7 +427,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) - fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") + fmt.Fprintln(out, " 统一安装位置:~/.agents/skills") + fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent") + fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。") return nil } @@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool { } // resolveSkillSetupTargets returns the list of absolute Agent home destinations. -// If target == "all", returns every agent home whose parent directory exists. -// Otherwise returns the single matching home (whether or not it currently exists). +// The canonical ~/.agents/skills destination is always first. If target == +// "all", detected concrete Agent roots follow it. A specific target follows +// canonical as well so unknown/future Agents retain the universal copy. // // 末段约定: // - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) @@ -923,15 +1003,88 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { } target = strings.ToLower(strings.TrimSpace(target)) + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) if target == "" || target == "all" { return detectExistingAgentHomes(home, mode), nil } - rel, ok := agentSkillPaths[target] + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return nil, errors.New(reason) + } + _, ok := agentSkillPaths[target] if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil + dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode) + if sameSkillSetupPath(dest, canonical) { + return []string{canonical}, nil + } + return []string{canonical, dest}, nil +} + +func resolveOpenClawSetupBase(home string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(home, name) + if info, err := skillSetupStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(home, ".openclaw", "skills") +} + +func resolveSkillSetupBase(home, target string) string { + switch target { + case "claude", "claude-code": + if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + return filepath.Join(custom, "skills") + } + case "codex": + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "hermes", "hermes-agent": + if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "autohand-code": + if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "grok": + if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "mistral-vibe": + if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "openclaw": + return resolveOpenClawSetupBase(home) + case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi": + configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(home, ".config") + } + switch target { + case "opencode": + return filepath.Join(configHome, "opencode", "skills") + case "amp", "replit", "universal": + return filepath.Join(configHome, "agents", "skills") + case "crush": + return filepath.Join(configHome, "crush", "skills") + case "devin": + return filepath.Join(configHome, "devin", "skills") + case "goose": + return filepath.Join(configHome, "goose", "skills") + case "kimchi": + return filepath.Join(configHome, "kimchi", "harness", "skills") + } + case "github", "github-copilot": + return filepath.Join(home, ".copilot", "skills") + case "windsurf": + return filepath.Join(home, ".codeium", "windsurf", "skills") + } + return filepath.Join(home, agentSkillPaths[target]) } // agentHomeForMode appends the mode-specific tail segment to an agent home base. @@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string { } func detectExistingAgentHomes(home, mode string) []string { - var specific []string + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) + dests := []string{canonical} + canonicalKey := skillSetupPathKey(canonical) + seen := map[string]bool{canonicalKey: true} + addDetected := func(rel, base string) { + detectedDir := filepath.Dir(base) + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".config/kimchi/harness/skills", ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(base)) + case ".zcode/skills": + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so setup matches npm, Shell, and PowerShell. + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" + } + case ".minimax/skills": + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + if detectedDir != "" { + if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() { + return + } + } + dest := agentHomeForMode(base, mode) + key := skillSetupPathKey(dest) + if !seen[key] { + seen[key] = true + dests = append(dests, dest) + } + } for i, rel := range skillSetupAgentHomes { if i == 0 { continue } base := filepath.Join(home, rel) - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { - continue + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".claude/skills": + base = resolveSkillSetupBase(home, "claude-code") + case ".codex/skills": + base = resolveSkillSetupBase(home, "codex") + case ".hermes/skills": + base = resolveSkillSetupBase(home, "hermes-agent") + case ".autohand/skills": + base = resolveSkillSetupBase(home, "autohand-code") + case ".grok/skills": + base = resolveSkillSetupBase(home, "grok") + case ".vibe/skills": + base = resolveSkillSetupBase(home, "mistral-vibe") + case ".openclaw/skills": + base = resolveSkillSetupBase(home, "openclaw") + case ".config/opencode/skills": + base = resolveSkillSetupBase(home, "opencode") + case ".config/agents/skills": + base = resolveSkillSetupBase(home, "amp") + case ".config/crush/skills": + base = resolveSkillSetupBase(home, "crush") + case ".config/devin/skills": + base = resolveSkillSetupBase(home, "devin") + case ".config/goose/skills": + base = resolveSkillSetupBase(home, "goose") + case ".config/kimchi/harness/skills": + base = resolveSkillSetupBase(home, "kimchi") } - specific = append(specific, agentHomeForMode(base, mode)) + addDetected(rel, base) } - if len(specific) > 0 { - return specific + for _, target := range []string{"github-copilot", "windsurf"} { + addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target)) } - return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)} + return dests } -func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { - // Derive HOME from a concrete Agent destination instead of resolving it a - // second time. The destinations were already resolved from HOME by the - // caller, and a later/transient UserHomeDir failure must not turn an - // otherwise valid setup plan into an error. Direct/custom destinations that - // do not match a known concrete Agent root have no generic-root migration. - home := "" - for _, dest := range dests { - base := dest - if filepath.Base(dest) == "dws" { - base = filepath.Dir(dest) - } - base = filepath.Clean(base) - for i, rel := range skillSetupAgentHomes { - if i == 0 { - continue - } - suffix := filepath.Clean(filepath.FromSlash(rel)) - needle := string(filepath.Separator) + suffix - if strings.HasSuffix(base, needle) { - home = strings.TrimSuffix(base, needle) - break - } - } - if home != "" { - break - } +func skillSetupBaseForMode(dest, mode string) string { + if mode == skillSetupModeMono { + return filepath.Dir(dest) } - if home == "" { - return nil, nil - } - genericBase := filepath.Join(home, ".agents", "skills") + return dest +} - target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true} - add := func(path, reason string) { - if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason}) +func isUniversalSkillSetupBase(base string) bool { + cleanBase := filepath.Clean(base) + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) { + return true + } + if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" { + if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) || + sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) { + return true } } - add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual) - entries, readErr := skillSetupReadDir(genericBase) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr) - } - for _, entry := range entries { - path := filepath.Join(genericBase, entry.Name()) - if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale}) + base = filepath.ToSlash(cleanBase) + for rel := range map[string]bool{ + ".config/agents/skills": true, ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, ".codex/skills": true, + ".cursor/skills": true, ".deepagents/agent/skills": true, + ".firebender/skills": true, ".gemini/skills": true, + ".copilot/skills": true, ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only. + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } { + if strings.HasSuffix(base, "/"+rel) { + return true } } - if len(target.Backups) == 0 { - return nil, nil + return false +} + +func sameSkillSetupPath(left, right string) bool { + return skillSetupPathKey(left) == skillSetupPathKey(right) +} + +func skillSetupPathKey(path string) string { + clean := filepath.Clean(path) + if skillSetupFoldPathCase { + clean = strings.ToLower(clean) } - sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - return target, nil + return clean +} + +func canonicalSkillSetupBase(dests []string, mode string) string { + for _, dest := range dests { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) + if strings.HasSuffix(base, "/.agents/skills") { + return skillSetupBaseForMode(dest, mode) + } + } + return "" +} + +func samePhysicalSkillSetupPath(left, right string) bool { + leftReal, leftErr := skillSetupEvalSymlinks(left) + rightReal, rightErr := skillSetupEvalSymlinks(right) + return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) { @@ -1030,10 +1245,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } sort.Strings(plan.MultiSkillNames) sortedDests := append([]string(nil), dests...) - sort.Strings(sortedDests) + sort.Slice(sortedDests, func(i, j int) bool { + leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills") + rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills") + if leftCanonical != rightCanonical { + return leftCanonical + } + return sortedDests[i] < sortedDests[j] + }) managedNames := currentManagedSkillNames() + canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { - target := skillSetupTargetPlan{Destination: dest} + base := skillSetupBaseForMode(dest, mode) + target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase} + if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) { + if isUniversalSkillSetupBase(base) { + target.CleanupOnly = true + } else { + target.LinkCanonical = true + } + } seen := map[string]bool{} add := func(path, reason string) { if seen[path] { @@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } for _, path := range replacements { - info, statErr := skillSetupStat(path) + if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + continue + } + _, statErr := skillSetupLstat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue } return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr) } - if info.IsDir() { - add(path, skillSetupBackupReplace) - } + add(path, skillSetupBackupReplace) } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - plan.Targets = append(plan.Targets, target) - } - cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames) - if cleanupErr != nil { - return nil, cleanupErr - } - if cleanupTarget != nil { - plan.Targets = append(plan.Targets, *cleanupTarget) + if !target.CleanupOnly || len(target.Backups) > 0 { + plan.Targets = append(plan.Targets, target) + } } return plan, nil } @@ -1140,6 +1367,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins } } +func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error { + home, err := skillSetupUserHomeDir() + if err != nil { + return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err) + } + seen := map[string]bool{} + var victims []skillSetupBackup + for _, target := range targets { + if !isUniversalSkillSetupBase(target) { + continue + } + for _, name := range names { + path := filepath.Join(target, name) + if seen[path] { + continue + } + seen[path] = true + victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + } + sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path }) + if _, err := backupSkillSetupTarget(home, victims, out); err != nil { + return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err) + } + return nil +} + func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source) if plan.Mode == skillSetupModeMulti { @@ -1148,12 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, " · %s\n", name) } } - fmt.Fprintln(out, " destinations:") + fmt.Fprintln(out, " 安装与适配位置:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination) + } else if target.LinkCanonical { + fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination) } else { - fmt.Fprintf(out, " - %s\n", target.Destination) + fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination) } } fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):") @@ -1324,8 +1580,12 @@ func installMultiSkillsWithEventMigration( } migrationSet := make(map[string]struct{}, len(migrationTargets)) + physicalMigrationTargets := make([]string, 0, len(migrationTargets)) for _, dest := range migrationTargets { migrationSet[dest] = struct{}{} + if !isUniversalSkillSetupBase(dest) { + physicalMigrationTargets = append(physicalMigrationTargets, dest) + } } var ordinaryTargets []string for _, dest := range dests { @@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration( } } - if len(ordinaryTargets) > 0 { + var canonicalTargets, otherOrdinaryTargets []string + for _, dest := range ordinaryTargets { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti))) + if strings.HasSuffix(base, "/.agents/skills") { + canonicalTargets = append(canonicalTargets, dest) + } else { + otherOrdinaryTargets = append(otherOrdinaryTargets, dest) + } + } + installOrdinary := func(names, targets []string) error { + if len(targets) == 0 { + return nil + } var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered) + n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered) installed += n skipped += nSkipped if err != nil { - return installed, skipped, err + return err } if nSkipped > 0 { - return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) + return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) } + return nil + } + canonicalNames := append([]string(nil), skillNames...) + if !containsSkillName(canonicalNames, multiMiscSkill) { + canonicalNames = append(canonicalNames, multiMiscSkill) + sort.Strings(canonicalNames) + } + if err := installOrdinary(canonicalNames, canonicalTargets); err != nil { + return installed, skipped, err + } + if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil { + return installed, skipped, err } // The folded pair is excluded from the ordinary best-effort installer. All // other selected skills (especially dingtalk-shared) must succeed before the // old Event route is touched. - for _, dest := range migrationTargets { + for _, dest := range physicalMigrationTargets { if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil { return installed, skipped + len(skillNames), cleanupErr } @@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration( prerequisiteNames = append(prerequisiteNames, name) } } - if len(prerequisiteNames) > 0 { + if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 { var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true) + n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true) installed += n skipped += nSkipped if err != nil { @@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration( } } - migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut) + migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut) installed += migrated if migrationErr != nil { return installed, skipped, migrationErr @@ -1646,9 +1930,32 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } }() + realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent) + if realParentErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr) + } stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) + if target.LinkCanonical { + canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest)) + if samePhysicalSkillSetupPath(dest, canonicalTarget) { + return nil + } + realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget) + if realTargetErr != nil { + return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr) + } + relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget) + if relErr != nil { + return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) + } + if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil { + return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr) + } + staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest}) + return nil + } if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil { return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err) } @@ -1675,16 +1982,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s // restoreSkillSetupTarget removes a partially published replacement and // restores every original directory from its exact backup path. -func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error { - var restoreErr error - 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)) - } - } +func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error { + restoreErr := skillSetupRollbackPaths(published) for i := len(backups) - 1; i >= 0; i-- { item := backups[i] - if _, err := skillSetupStat(item.original); err == nil { + if _, err := skillSetupLstat(item.original); err == nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup)) continue } else if !errors.Is(err, os.ErrNotExist) { @@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup)) continue } - if err := skillSetupPublishRename(item.backup, item.original); err != nil { + if err := skillSetupRestoreBackup(item.backup, item.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup)) } } @@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ } func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error { - published := make([]string, 0, len(staged)) + published := make([]upgrade.SkillPathPublication, 0, len(staged)) for _, item := range staged { - // Record before rename so rollback also removes a destination created by - // a platform-specific partial failure. - published = append(published, item.dest) - if err := skillSetupPublishRename(item.staged, item.dest); err != nil { + publication, err := skillSetupPublishPath(item.staged, item.dest) + if err != nil { publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) } return publishErr } + published = append(published, publication) } return nil } func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() - perTarget := 1 - if plan.Mode == skillSetupModeMulti { - perTarget = len(plan.MultiSkillNames) + hasCanonicalDependents := false + for _, candidate := range plan.Targets { + if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { + hasCanonicalDependents = true + break + } } for _, target := range plan.Targets { + perTarget := 1 + if plan.Mode == skillSetupModeMulti { + perTarget = len(plan.MultiSkillNames) + } + isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { - if skipped > 0 { - continue - } + // Nothing is installed below a universal root, so a stale copy that + // resists retirement must not count as a skipped install: any skipped + // count fails the whole setup, even when every real target succeeded. if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr) } continue } if len(target.Backups) > 0 && homeErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr) + } if plan.Mode == skillSetupModeMono { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) } else { @@ -1777,7 +2087,16 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) + if stageErr != nil && target.LinkCanonical { + fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) + fallback := target + fallback.LinkCanonical = false + stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) + } if stageErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr) + } fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget continue @@ -1787,6 +2106,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr) + } fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) skipped += perTarget continue @@ -1797,7 +2119,19 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr != nil { publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } - fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + if isCanonical && hasCanonicalDependents { + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr) + } + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) + } + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + // The destination may belong to a concurrent writer and was + // deliberately retained; the rollback refuses to displace it. + fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr) + } else { + fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + } skipped += perTarget continue } @@ -1836,7 +2170,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[ } var victims []string for _, e := range entries { - if !e.IsDir() || keepSet[e.Name()] { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] { continue } if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) { diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go new file mode 100644 index 00000000..9f492abb --- /dev/null +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -0,0 +1,160 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "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 canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { + t.Helper() + home := t.TempDir() + src := t.TempDir() + skill := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skill, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + dependent := filepath.Join(home, ".claude", "skills") + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + return home, plan +} + +func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) { + t.Run("home", func(t *testing.T) { + _, plan := canonicalFailurePlan(t) + canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonical, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") { + t.Fatalf("home failure = %v", err) + } + }) + + t.Run("backup", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") { + t.Fatalf("backup failure = %v", err) + } + }) + + t.Run("publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + 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 发布失败") { + t.Fatalf("publish failure = %v", err) + } + }) + + t.Run("uncertain-publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + }) + errOut := &bytes.Buffer{} + _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") { + t.Fatalf("uncertain publish = %v", err) + } + // The destination was deliberately retained, so the canonical error + // must not claim a rollback happened. + if strings.Contains(err.Error(), "回滚") { + t.Fatalf("uncertain error must not claim a rollback: %v", err) + } + }) + + t.Run("uncertain dependent target is retained and reported", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + if strings.HasPrefix(destination, filepath.Join(home, ".claude")) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + } + return originalPublish(staged, destination) + }) + errOut := &bytes.Buffer{} + _, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err != nil { + t.Fatalf("dependent uncertain publish = %v", err) + } + if skipped != 1 { + t.Fatalf("skipped = %d, want 1", skipped) + } + if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") { + t.Fatalf("errOut = %q, want retained-destination notice", errOut.String()) + } + }) +} + +func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { + home, plan := canonicalFailurePlan(t) + canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonicalTarget, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + linked := plan.Targets[1] + + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == linked.Destination { + return "", errors.New("parent denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical parent error = %v", err) + } + }) + + t.Run("canonical-target", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == canonicalTarget { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical target error = %v", err) + } + }) + + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative path error = %v", err) + } + }) + + _ = home +} diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go new file mode 100644 index 00000000..1932e55b --- /dev/null +++ b/internal/app/skill_setup_canonical_test.go @@ -0,0 +1,395 @@ +package app + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupAgentHomes, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + if len(dests) != 4 || dests[0] != canonical { + t.Fatalf("targets = %v", dests) + } + + src := t.TempDir() + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeSkills := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeSkills, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("Codex duplicate remains: %v", err) + } + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link %s = %#v, %v", link, info, err) + } + } + } + // Re-running setup must recognize the existing links as already correct; + // canonical refreshes in place without turning links into copied trees. + plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. + home := t.TempDir() + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") + originalStat := skillSetupStat + testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { + if path == zcodeApp || path == minimaxApp { + return appInfo, nil + } + return originalStat(path) + }) + dests := detectExistingAgentHomes(home, skillSetupModeMulti) + for _, target := range []string{ + filepath.Join(home, ".config", "kimchi", "harness", "skills"), + filepath.Join(home, ".tabnine", "agent", "skills"), + filepath.Join(home, ".zcode", "skills"), + filepath.Join(home, ".minimax", "skills"), + } { + if !containsSkillName(dests, target) { + t.Errorf("detected targets %v missing %s", dests, target) + } + } +} + +func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { + home := t.TempDir() + customClaude := filepath.Join(t.TempDir(), "claude") + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(name string) string { + switch name { + case "CLAUDE_CONFIG_DIR": + return customClaude + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".agents", "skills"), + filepath.Join(customClaude, "skills"), + filepath.Join(customCodex, "skills"), + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".moltbot", "skills"), + filepath.Join(home, ".copilot", "skills"), + filepath.Join(home, ".config", "opencode", "skills"), + filepath.Join(home, ".config", "agents", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + found := false + for _, got := range dests { + if sameSkillSetupPath(got, want) { + found = true + break + } + } + if !found { + t.Fatalf("resolved targets %v missing %s", dests, want) + } + } + if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) { + t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets") + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + oldClaude := filepath.Join(claude, "dingtalk-chat") + if err := os.MkdirAll(oldClaude, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil { + t.Fatal(err) + } + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("canonical failure = %v", err) + } + body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md")) + if readErr != nil || string(body) != "old remains" { + t.Fatalf("dependent Claude target changed: %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") }) + + plan, err := buildSkillSetupPlan( + skillSetupModeMulti, + src, + []string{canonical, claude}, + []string{"dingtalk-chat"}, + false, + ) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("human-readable fallback message missing: %s", errOut.String()) + } + info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat")) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } +} + +func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + + expected := map[string]string{ + "aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills", + "antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills", + "astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills", + "augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills", + "codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills", + "droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills", + "gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills", + "grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills", + "kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills", + "mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills", + "openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills", + "replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills", + "roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills", + "universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills", + "zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills", + } + if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 { + t.Fatalf("upstream agent enumeration = %d, want 76", got) + } + for target, rel := range expected { + mapped, ok := agentSkillPaths[target] + if !ok || filepath.Clean(mapped) != filepath.Clean(rel) { + t.Errorf("agent %s map = %q, want %q", target, mapped, rel) + } + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) { + t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel)) + } + } + for _, target := range []string{"eve", "promptscript"} { + if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil { + t.Errorf("%s unexpectedly resolved a global setup root", target) + } + if _, err := resolveSkillTargetPath(target); err == nil { + t.Errorf("%s unexpectedly resolved a marketplace install root", target) + } + } + if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") { + t.Fatalf("supported targets omit no-global upstream agents: %s", got) + } + + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"), + "CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"), + "HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"), + "XDG_CONFIG_HOME": filepath.Join(home, "xdg"), + } + testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] }) + customCases := map[string]string{ + "autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(custom["CODEX_HOME"], "skills"), + "grok": filepath.Join(custom["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"), + "amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + "devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + "goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + "kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + "opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for target, want := range customCases { + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) { + t.Errorf("custom %s root = %q, want %q", target, got, want) + } + } + for _, target := range []string{"codex", "amp", "opencode"} { + if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) { + t.Errorf("custom %s root not classified universal", target) + } + } +} + +func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) { + testseam.Swap(t, &skillSetupFoldPathCase, true) + if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) { + t.Fatal("case-insensitive platform path normalization failed") + } +} diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 957496d4..b346eb3d 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -15,6 +15,7 @@ import ( "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/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func useManagedSkillNames(t *testing.T, names ...string) { @@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test return originalBackup(homeDir, dir) }) } else { - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { 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("remove published", func(t *testing.T) { - testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure }) - if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) { + testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure }) + if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) { t.Fatalf("remove published error = %v", err) } }) @@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("stat", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") { t.Fatalf("restore stat error = %v", err) } }) t.Run("mkdir", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, 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 failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") { @@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("rename", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, 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, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") { t.Fatalf("restore rename error = %v", err) @@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } return "", failure }) - testseam.Swap(t, &skillSetupStat, 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 }) restoreErr := errors.New("restore failure") - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr }) _, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard) if !errors.Is(err, failure) || !errors.Is(err, restoreErr) { t.Fatalf("backup rollback error = %v", err) @@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(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, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + 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, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) err := publishSkillSetupTarget( []skillSetupStagedDir{{staged: "staged", dest: "dest"}}, @@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("after publish failure", func(t *testing.T) { plan := newPlan(t) - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { 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 cleanupErr := errors.New("cleanup after publish failure") diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go new file mode 100644 index 00000000..928f672d --- /dev/null +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -0,0 +1,139 @@ +package app + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies +// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal +// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the +// Event/misc migration must not leave physical duplicates there. Universal +// homes read ~/.agents/skills directly, so their old folded copies are retired +// (backed up) and the split standalone event + clean misc land in canonical. +func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + // beta.6 physical copies alongside the folded misc. + for _, name := range []string{multiEventSkill, multiSharedSkill} { + if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + canonical := filepath.Join(home, ".agents", "skills") + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + + // P0: the universal home must not retain any physical dingtalk-* copy. + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) { + t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err) + } + } + // canonical owns the new standalone event + clean misc (+ shared). + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical missing %s: %v", name, err) + } + } + if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil { + t.Fatalf("canonical misc still contains folded Event content: %v", err) + } + if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil { + t.Fatalf("idempotent rerun failed: %v", err) + } +} + +func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + canonical := filepath.Join(home, ".agents", "skills") + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + writeOldStandaloneEvent(t, codexSkills) + chat := filepath.Join(codexSkills, "dingtalk-chat") + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil { + t.Fatal(err) + } + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"}) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes") + if err != nil { + t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if strings.Contains(stdout, "Event 原子迁移") { + t.Fatalf("unrelated selection migrated Event/misc: %s", stdout) + } + assertOldEventMiscPair(t, codexSkills) + if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" { + t.Fatalf("unselected chat changed: body=%q err=%v", body, err) + } +} + +func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) { + failure := errors.New("retirement denied") + + t.Run("home", func(t *testing.T) { + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) + codex := filepath.Join(t.TempDir(), ".codex", "skills") + if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) { + t.Fatalf("home failure = %v, want %v", err, failure) + } + }) + + t.Run("deduplicate", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + if err := retireMigratedUniversalSkills( + []string{codex, codex}, + []string{multiEventSkill, multiEventSkill}, + io.Discard, + ); err != nil { + t.Fatalf("deduplicated retirement failed: %v", err) + } + }) + + // Retiring an obsolete universal copy installs nothing, so its failure is + // surfaced as a warning and the successful installation is kept. + t.Run("backup failure warns without failing the command", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codex) + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { + return "", failure + }) + _, stderr, err := executeMultiSkillSetupTest( + t, + src, + []string{filepath.Join(home, ".agents", "skills"), codex}, + "--skill", "event", "--yes", + ) + if err != nil { + t.Fatalf("retirement backup failure must not fail the command: %v", err) + } + if !strings.Contains(stderr, "退役 universal Agent") { + t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr) + } + }) +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index 02da441f..8ead0740 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) - t.Run("ordinary and prerequisite install errors", func(t *testing.T) { + t.Run("ordinary install error", func(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill}) + copyCalls := 0 + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail }) + migration := filepath.Join(t.TempDir(), "migration") + ordinary := filepath.Join(t.TempDir(), "ordinary") + if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") { + t.Fatalf("ordinary install failure = %v", err) + } + if copyCalls == 0 { + t.Fatal("ordinary staging failure seam was not exercised") + } + }) + + t.Run("canonical ordinary install error", func(t *testing.T) { + testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { + return 0, 0, fail + }) + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + universalMigration := filepath.Join(home, ".codex", "skills") + if _, _, err := installMultiSkillsWithEventMigration( + "src", + []string{multiEventSkill}, + []string{canonical, universalMigration}, + []string{universalMigration}, + true, + io.Discard, + io.Discard, + ); !errors.Is(err, fail) { + t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail) + } + }) + + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }) migration := filepath.Join(t.TempDir(), "migration") - ordinary := filepath.Join(t.TempDir(), "ordinary") - if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { - t.Fatalf("ordinary install failure = %v", err) - } if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { t.Fatalf("prerequisite install failure = %v", err) } diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index fcc5ceb0..90b13869 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t backupCalls, copyCalls := []string{}, 0 testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) { backupCalls = append(backupCalls, path) + if err := os.RemoveAll(path); err != nil { + return "", err + } return "backup", 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, &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) var dryRunOut bytes.Buffer dryRunCmd.SetOut(&dryRunOut) @@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t // A filtered multi plan replaces only selected same-name skills and leaves // 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) if err != nil { t.Fatal(err) @@ -149,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) { home := t.TempDir() dest := filepath.Join(home, ".codex", "skills") - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { + canonical := filepath.Join(home, ".agents", "skills") + oldCodex := filepath.Join(dest, "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { t.Fatal(err) } testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("transient HOME failure") }) - plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true) if err != nil { t.Fatal(err) } - if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) { - t.Fatalf("generic cleanup target = %#v", plan.Targets) + if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest { + t.Fatalf("universal cleanup target = %#v", plan.Targets) } - if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono { - t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups) + if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex { + t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups) } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") { - t.Fatalf("generic cleanup preview missing: %s", preview.String()) + if !strings.Contains(preview.String(), "改用统一安装位置") { + t.Fatalf("universal cleanup preview missing: %s", preview.String()) } - t.Run("managed multi and scan failure", func(t *testing.T) { - managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true}) - if targetErr != nil || target == nil || len(target.Backups) != 2 { - t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr) - } - failure := errors.New("generic scan failure") - testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) - if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { - t.Fatalf("generic scan error = %v", targetErr) - } - if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) { - t.Fatalf("generic cleanup plan error = %v", planErr) - } - }) } func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) { failure := errors.New("cleanup failure") cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}} - t.Run("prior skip suppresses cleanup", func(t *testing.T) { + t.Run("cleanup runs even after an install skip", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil }) plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}} installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard) @@ -206,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. } }) + // A cleanup-only target installs nothing, so its failure is a warning and + // must never increment skipped — runSkillSetup turns any skipped count into + // a hard error and would fail an otherwise complete installation. t.Run("home failure keeps generic copy", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -220,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") { t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -247,11 +233,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin t.Fatal(err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") { t.Fatalf("replacement stat error = %v", err) } testseam.Swap(t, &skillSetupStat, 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, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") { t.Fatalf("stale scan error = %v", err) diff --git a/internal/app/skill_setup_publication_race_test.go b/internal/app/skill_setup_publication_race_test.go new file mode 100644 index 00000000..8d954e3a --- /dev/null +++ b/internal/app/skill_setup_publication_race_test.go @@ -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) + } +} diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 2a776355..f5e3eb28 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills", "dws") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t t.Fatal(err) } want := filepath.Join(home, ".codex", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } } @@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } want := filepath.Join(home, ".zcode", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono) @@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } wantMono := filepath.Join(want, "dws") - if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) { - t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono) + if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) { + t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono) } } @@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets( if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { t.Fatal(err) } - stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") if err != nil { t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) } - if !strings.Contains(stdout, "重新加载 Skills") { + if !strings.Contains(stdout, "请重启已打开的 Agent") { t.Fatalf("completion should tell the user to reload skills: %s", stdout) } diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 6388ce46..5c11dd5d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { for _, d := range succeeded { fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir))) } + for _, d := range result.RetireWarnings() { + fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error())) + } } else { fmt.Printf(" %s\n", ugGreen("✓")) } diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index ae777a9d..b96f5b7d 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { if stage == "install-failed-dir" { return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil } + if stage == "install-retire-warning" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{ + {Dir: "/ok", Status: upgradepkg.SkillDirOK}, + {Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")}, + }}, nil + } return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil } } @@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", - "success", "success-no-skills", + "success", "success-no-skills", "install-retire-warning", } { t.Run(stage, func(t *testing.T) { configure(stage) @@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { opts.skipSkills = true } err := runUpgrade(context.Background(), opts) - wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning" if wantError && err == nil { t.Fatalf("stage %s succeeded", stage) } if !wantError && err != nil { t.Fatalf("stage %s failed: %v", stage, err) } - if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned { t.Fatal("successful upgrade did not clean backups") } if stage == "success" { diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 4b5b93c9..8827cceb 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -3422,7 +3422,7 @@ var generatedParamAliases = []ParamAliasEntry{ "uid": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role update", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 3a2f9a8b..4b9086b4 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -155,7 +155,6 @@ "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, - "chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, "doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true}, @@ -438,7 +437,6 @@ {"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"}, {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 04741dce..b94cc080 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -230,9 +230,10 @@ const ( // - Validate / PostMount — orchestration only; must not register business flags // or assemble business params that belong in Flags/ConstParams. // -// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at -// construction time. corecmd stays dispatch-agnostic and never calls a backend: -// the adapters (FromLeafSpec / FromShortcut) supply the body. +// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New +// validates this at construction time. corecmd stays dispatch-agnostic and +// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the +// body. type Spec struct { Use string Short string @@ -259,7 +260,8 @@ type Spec struct { // backend call). ConfirmFirst bool // ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly). - // They are payload declaration, not user flags, and never satisfy Required. + // They are payload declaration, not user flags, never satisfy Required, and + // require an Invoke or ResultInvoke dispatcher that consumes assembled args. ConstParams map[string]any // Contract is the authoring-time leaf contract declaration (selection / // interface / parameters / dry-run / identity). When non-empty, embed @@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // malformed spec can never run the pipeline — write-confirmation prompt // included — and then silently exit 0 having done nothing. func New(spec Spec) *cobra.Command { + spec.ConstParams = cloneConstParams(spec.ConstParams) validateDispatchDecl(spec) + validateConstParamsDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) validateInputSpecs(spec.Use, spec.Flags) @@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command { if spec.PostMount != nil { spec.PostMount(cmd) } + attachInterfaceBoolConstParams(cmd, spec.ConstParams) if spec.OutputRollout != "" { output.SetCommandRollout(cmd, spec.OutputRollout) } @@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command { return cmd } +func cloneConstParams(params map[string]any) map[string]any { + if params == nil { + return nil + } + frozen := make(map[string]any, len(params)) + for key, value := range params { + frozen[key] = value + } + return frozen +} + // ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The // delivery gate reads it to tell a declared guard-first command apart from an // accidental confirm-before-validate inversion: guard-first is only legitimate @@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) { } } +func validateConstParamsDecl(spec Spec) { + if len(spec.ConstParams) == 0 { + return + } + if spec.Invoke == nil && spec.ResultInvoke == nil { + panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use)) + } + for _, flag := range spec.Flags { + key := bindKey(flag) + if _, conflicts := spec.ConstParams[key]; conflicts { + panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name)) + } + } +} + // validateSafetySpec rejects partial safety declarations. A zero value remains // the historical read-only default, but once any field is authored all four // independent Schema dimensions must be explicit. diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index a5ae5b96..f9743865 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -26,6 +26,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) { } } -func TestNewCommandMergesConstParams(t *testing.T) { +func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) { var got map[string]any + declared := map[string]any{ + "precheckOnly": false, + "convThreadEnabled": true, + } cmd := New(Spec{ Use: "pub", Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}}, - ConstParams: map[string]any{"precheckOnly": false}, + ConstParams: declared, Invoke: func(_ *Ctx, toolArgs map[string]any) error { got = toolArgs return nil }, }) + declared["precheckOnly"] = true + declared["forgedAfterNew"] = true _ = cmd.Flags().Set("id", "V1") if err := cmd.RunE(cmd, nil); err != nil { t.Fatal(err) @@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) { if got["precheckOnly"] != false { t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"]) } + if got["convThreadEnabled"] != true { + t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"]) + } + if _, exists := got["forgedAfterNew"]; exists { + t.Fatalf("caller mutation leaked into dispatch args: %#v", got) + } + evidence := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) { + t.Fatalf("bool ConstParams evidence = %#v", evidence) + } + if got["precheckOnly"] != evidence["precheckOnly"] { + t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence) + } +} + +func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) { + var got map[string]any + cmd := New(Spec{ + Use: "mixed", + ConstParams: map[string]any{ + "convThreadEnabled": true, + "retryLimit": 3, + }, + Invoke: func(_ *Ctx, toolArgs map[string]any) error { + got = toolArgs + return nil + }, + }) + + if evidence := InterfaceBoolConstParams(cmd); evidence != nil { + t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if got["convThreadEnabled"] != true || got["retryLimit"] != 3 { + t.Fatalf("mixed ConstParams dispatch args = %#v", got) + } +} + +func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) { + mustPanic := func(name string, spec Spec, needle string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("%s: expected panic", name) + } + if msg, _ := r.(string); !strings.Contains(msg, needle) { + t.Fatalf("%s: panic=%v, want %q", name, r, needle) + } + }() + New(spec) + } + + mustPanic("RunE", Spec{ + Use: "run-e", + ConstParams: map[string]any{"fixed": true}, + RunE: func(*cobra.Command, []string) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("Orchestrate", Spec{ + Use: "orchestrate", + ConstParams: map[string]any{"fixed": true}, + Orchestrate: func(*Ctx) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("explicit bind conflict", Spec{ + Use: "bind-conflict", + Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}}, + ConstParams: map[string]any{"convThreadEnabled": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --thread") + mustPanic("default bind conflict", Spec{ + Use: "default-bind-conflict", + Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}}, + ConstParams: map[string]any{"fixedValue": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --fixed-value") + + result := New(Spec{ + Use: "result", + OutputRollout: output.RolloutUnifiedActive, + ConstParams: map[string]any{"fixed": true}, + ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) { + return output.Success(nil), nil + }, + }) + if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] { + t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence) + } } func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) { diff --git a/internal/corecmd/interface_const_params.go b/internal/corecmd/interface_const_params.go new file mode 100644 index 00000000..261e64d1 --- /dev/null +++ b/internal/corecmd/interface_const_params.go @@ -0,0 +1,63 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "sync" + + "github.com/spf13/cobra" +) + +var interfaceBoolConstParamsRegistry sync.Map + +// attachInterfaceBoolConstParams records framework-owned ConstParams evidence. +// Only a non-empty, entirely boolean declaration is representable in v1; +// empty or mixed declarations remove any evidence for the command. +func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) { + if cmd == nil { + return + } + if len(params) == 0 { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools := make(map[string]bool, len(params)) + for key, value := range params { + boolValue, ok := value.(bool) + if !ok { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools[key] = boolValue + } + interfaceBoolConstParamsRegistry.Store(cmd, bools) +} + +// InterfaceBoolConstParams returns a clone of framework-owned boolean +// ConstParams evidence. Callers cannot mutate the private registry. +func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool { + if cmd == nil { + return nil + } + stored, ok := interfaceBoolConstParamsRegistry.Load(cmd) + if !ok { + return nil + } + params := stored.(map[string]bool) + clone := make(map[string]bool, len(params)) + for key, value := range params { + clone[key] = value + } + return clone +} diff --git a/internal/corecmd/interface_const_params_test.go b/internal/corecmd/interface_const_params_test.go new file mode 100644 index 00000000..ea01d24e --- /dev/null +++ b/internal/corecmd/interface_const_params_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package corecmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) { + attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true}) + if got := InterfaceBoolConstParams(nil); got != nil { + t.Fatalf("nil command evidence = %#v, want nil", got) + } + + cmd := &cobra.Command{Use: "send"} + declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false} + attachInterfaceBoolConstParams(cmd, declared) + declared["convThreadEnabled"] = false + + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + got := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want) + } + got["convThreadEnabled"] = false + if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) { + t.Fatalf("reader leaked mutable registry state: %#v", again) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3}) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("mixed ConstParams retained evidence: %#v", got) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true}) + attachInterfaceBoolConstParams(cmd, nil) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("empty ConstParams retained evidence: %#v", got) + } +} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index af6b16b1..ea69d203 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -27,6 +27,38 @@ import ( "github.com/spf13/cobra" ) +func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { + roleIDChanged := cmd.Flags().Changed("role-id") + roleIDsChanged := cmd.Flags().Changed("role-ids") + switch { + case roleIDChanged && roleIDsChanged: + // PreRunE rejects callers that explicitly pass both flags. Reaching this + // branch means the hidden legacy flag was promoted to satisfy Cobra's + // required marker on the public canonical flag. + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + case roleIDChanged: + roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) + if len(roleIDs) == 0 { + return nil, apperrors.NewValidation("--role-id 不能为空") + } + if len(roleIDs) > 1 { + return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份") + } + return roleIDs, nil + case roleIDsChanged: + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + default: + return nil, apperrors.NewValidation("缺少必填参数 --role-id") + } +} + +func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error { + if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") { + return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + } + return promoteLegacyChatString(cmd, "role-id", "role-ids") +} + // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when @@ -7753,17 +7785,23 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", - Example: ` dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2 + Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, + PreRunE: func(cmd *cobra.Command, args []string) error { + return prepareChatGroupRoleSetUserRoleID(cmd) + }, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } - roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) + roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + return err + } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), @@ -7798,13 +7836,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Selection: contract.SelectionSpec{ AgentSummary: "为指定群成员设置自定义角色", - UseWhen: []string{"需要把一个或多个已有角色分配给成员时"}, + UseWhen: []string{"需要把一个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, - Examples: []string{"dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role set-user --conversation-id --user --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, - {Name: "role-ids", Property: "openRoleIds"}, + {Name: "role-id", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, @@ -7814,8 +7852,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") - chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份") - _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids") + chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") + chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") + _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") + corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") + _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 118cc983..b2f70a1b 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -243,6 +243,155 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing. } } +func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "public single role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, + want: []string{"r1"}, + }, + { + name: "hidden legacy role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"}, + want: []string{"r1", "r2"}, + }, + { + name: "hidden legacy empty role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := runChatCoverageCommand(t, caller, tc.args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("tool calls = %d, want 1", caller.calls) + } + if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2") + if err == nil { + t.Fatal("set-user accepted --role-id with --role-ids, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2") + if err == nil { + t.Fatal("set-user accepted multiple --role-id values, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1"}, + want: "role-id", + }, + { + name: "empty public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="}, + want: "--role-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, tc.args...) + if err == nil { + t.Fatal("set-user accepted a missing or empty --role-id, want validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } + }) + } +} + +func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) { + newCommand := func(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().String("role-id", "", "") + cmd.Flags().String("role-ids", "", "") + return cmd + } + + t.Run("legacy flag without pre-run promotion", func(t *testing.T) { + cmd := newCommand(t) + if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil { + t.Fatal(err) + } + got, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + t.Fatal(err) + } + if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("role IDs = %#v, want %#v", got, want) + } + }) + + t.Run("no role flag", func(t *testing.T) { + _, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t)) + if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") { + t.Fatalf("error = %v, want missing --role-id validation", err) + } + }) +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -293,7 +442,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, {"mark-read", "--conversation-id=cid", "--message-id=mid"}, {"text", "translate", "--query=hello", "--to=zh_CN"}, - {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"}, + {"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, {"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"}, {"group-role", "query-user", "--group=cid", "--user=D1"}, {"group", "set-admin", "--group=cid", "--users=u1,D1"}, @@ -523,7 +672,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"}, {name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"}, {name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"}, - {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"}, {name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, {name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"}, {name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"}, diff --git a/internal/helpers/framework2_changed_lines_coverage_test.go b/internal/helpers/framework2_changed_lines_coverage_test.go index 00053300..a80a0751 100644 --- a/internal/helpers/framework2_changed_lines_coverage_test.go +++ b/internal/helpers/framework2_changed_lines_coverage_test.go @@ -58,6 +58,12 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { t.Fatalf("decoded data=%#v err=%v", data, err) } + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"count":7}`}}}}) + data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil) + if err != nil || data.(map[string]any)["count"] != float64(7) { + t.Fatalf("legacy float64 number data=%#v err=%v", data, err) + } + InitDeps(&coverageErrorCaller{err: errors.New("transport failed")}) if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected transport error") @@ -73,6 +79,16 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected invalid JSON error") } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {}`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "存在多个 JSON 值") { + t.Fatalf("multiple JSON values error = %v", err) + } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "解析 get_thing 返回失败") { + t.Fatalf("trailing invalid JSON error = %v", err) + } } func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) { diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index be92c38e..f2607027 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -305,7 +306,14 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg return map[string]any{}, nil } var data any - if err := json.Unmarshal([]byte(text), &data); err != nil { + decoder := json.NewDecoder(strings.NewReader(text)) + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) } return data, nil diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 8dfe47b5..9e3393f9 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -8,6 +8,7 @@ import ( "io" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -322,6 +323,95 @@ func newOAAttachmentCommand() *cobra.Command { return attachmentCmd } +// oaAdminQueryMaxPageSize is the pageSize upper bound of +// get_process_instances_by_admin. +const oaAdminQueryMaxPageSize = float64(20) + +// validateOARequestProcessCode checks the processCode field of a decoded +// --request payload: the tool requires it, and the backend answers a bad +// processCode with success:true and an empty list, so reject it client-side. +func validateOARequestProcessCode(request map[string]any) error { + v, ok := request["processCode"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 processCode") + } + s, ok := v.(string) + if !ok || s == "" { + return fmt.Errorf("--request processCode 必须为非空字符串") + } + return nil +} + +// validateOARequestPageSize checks the pageSize field of a decoded +// --request payload (json.Number values from decodeOARequest). +func validateOARequestPageSize(request map[string]any) error { + v, ok := request["pageSize"] + if !ok { + return nil + } + n, ok := v.(json.Number) + if !ok { + return fmt.Errorf("pageSize 必须为数字") + } + f, err := n.Float64() + if err != nil || f < 1 || f > oaAdminQueryMaxPageSize { + return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String()) + } + return nil +} + +// oaAdminTimeLayout is the startTime/endTime wire format of +// get_process_instances_by_admin since the 2026-08 MCP contract update +// (both fields changed from epoch millis to strings). +const oaAdminTimeLayout = "2006-01-02 15:04:05" + +// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in +// error messages. +const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss" + +var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600) + +// formatOAAdminQueryTime renders a millisecond timestamp into the +// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin. +func formatOAAdminQueryTime(ms int64) string { + return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout) +} + +// validateOARequestTimeRange checks startTime/endTime of a decoded +// --request payload: startTime is required, both must be +// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after +// startTime (matching simple mode's ValidateTimeRange). +func validateOARequestTimeRange(request map[string]any) error { + startRaw, ok := request["startTime"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 startTime") + } + startStr, ok := startRaw.(string) + if !ok { + return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr) + } + endRaw, ok := request["endTime"] + if !ok { + return nil + } + endStr, ok := endRaw.(string) + if !ok { + return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr) + } + if !end.After(start) { + return fmt.Errorf("--request endTime 必须晚于 startTime") + } + return nil +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, @@ -329,7 +419,8 @@ func newOAAttachmentCommand() *cobra.Command { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance, get_attachment_download_url, auth_download_file, +// start_process_instance, get_process_instances_by_admin, +// get_attachment_download_url, auth_download_file, // auth_preview_attachment // ────────────────────────────────────────────────────────── @@ -1432,6 +1523,129 @@ func newOaCommand() *cobra.Command { }, }, }) + // 以管理员身份查询审批实例列表 + listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"} + approvalListByAdminCmd := &cobra.Command{ + Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表", + Example: ` dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行, + // 这里先校验同一组约束,保证用户看到的是中文错误。 + request, _ := cmd.Flags().GetString("request") + processCode, _ := cmd.Flags().GetString("process-code") + if request == "" && processCode == "" { + return fmt.Errorf("--request、--process-code 至少指定一个") + } + if request != "" { + for _, name := range listByAdminSimpleFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--request 与 --%s 不能同时指定", name) + } + } + } + if processCode != "" && !cmd.Flags().Changed("start") { + return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if raw, _ := cmd.Flags().GetString("request"); raw != "" { + request, err := decodeOARequest(raw) + if err != nil { + return fmt.Errorf("--request JSON 解析失败: %w", err) + } + if err := validateOARequestProcessCode(request); err != nil { + return err + } + if err := validateOARequestPageSize(request); err != nil { + return err + } + if err := validateOARequestTimeRange(request); err != nil { + return err + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + } + if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil { + return err + } + startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + if err != nil { + return err + } + cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64) + if err != nil { + return fmt.Errorf("--cursor 必须为数字: %w", err) + } + pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64) + if err != nil { + return fmt.Errorf("--limit 必须为数字: %w", err) + } + if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize { + return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit")) + } + request := map[string]any{ + "processCode": mustGetFlag(cmd, "process-code"), + "startTime": formatOAAdminQueryTime(startMs), + "cursor": cursor, + "pageSize": pageSize, + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + endMs, err := parseISOTimeToMillis("end", v) + if err != nil { + return err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return err + } + request["endTime"] = formatOAAdminQueryTime(endMs) + } + if v, _ := cmd.Flags().GetString("user-ids"); v != "" { + request["userIds"] = strings.Split(v, ",") + } + if v, _ := cmd.Flags().GetString("statuses"); v != "" { + request["statuses"] = strings.Split(v, ",") + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + }, + } + DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_process_instances_by_admin", + CanonicalPath: "oa.get_process_instances_by_admin", + CLIPath: "oa approval list-by-admin", + PrimaryCLIPath: "oa approval list-by-admin", + }, + Description: "以管理员身份获取审批单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表", + UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"}, + AvoidWhen: []string{ + "只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc", + "无 OA 管理员权限时不要使用,该命令查不到数据", + }, + Examples: []string{ + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20", + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"", + }, + }, + // Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest). + Parameters: []contract.ParamDecl{ + {Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"}, + }, + }, + }) approvalCreateCmd := &cobra.Command{ Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)", Example: "dws oa approval create-instance --process-code --form-values '{\"事由\":\"测试\"}' --yes", @@ -1610,6 +1824,27 @@ func newOaCommand() *cobra.Command { RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}}, }) + approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)") + approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0") + approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20") + approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)") + approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)") + approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)") + approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code") + approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start") + listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags)) + for _, name := range listByAdminSimpleFlags { + approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name) + listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name}) + } + cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: listByAdminMutuallyExclusive, + RequireOneOf: [][]string{{"request", "process-code"}}, + RequireTogether: [][]string{{"process-code", "start"}}, + }) + approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID") approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)") @@ -1656,6 +1891,7 @@ func newOaCommand() *cobra.Command { approvalRevertTaskCmd, approvalFormSchemaCmd, approvalForecastCmd, + approvalListByAdminCmd, approvalCreateCmd, ) approvalCmd.AddCommand(newOAAttachmentCommand()) diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index 70cb18cc..019b64e8 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes } } +func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "list-by-admin", + "--process-code", "PROC", + "--start", "2030-01-01T09:00:00+08:00", + "--end", "2030-01-01T10:00:00+08:00", + "--cursor", "5", + "--limit", "20", + "--user-ids", "user-1,user-2", + "--statuses", "RUNNING,COMPLETED", + ) + if err != nil { + t.Fatalf("list by admin: %v", err) + } + if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" { + t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool) + } + request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any) + if !ok { + t.Fatalf("request payload = %#v", caller.args) + } + if got := request["processCode"]; got != "PROC" { + t.Fatalf("processCode = %#v", got) + } + if got := request["cursor"]; got != float64(5) { + t.Fatalf("cursor = %#v", got) + } + if got := request["pageSize"]; got != float64(20) { + t.Fatalf("pageSize = %#v", got) + } + if got := request["startTime"]; got != "2030-01-01 09:00:00" { + t.Fatalf("startTime = %#v", got) + } + if got := request["endTime"]; got != "2030-01-01 10:00:00" { + t.Fatalf("endTime = %#v", got) + } + if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" { + t.Fatalf("userIds = %#v", got) + } + if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" { + t.Fatalf("statuses = %#v", got) + } +} + func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) { validCases := []struct { name string @@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"}, tool: "start_process_instance", }, + { + name: "list-by-admin simple mode", + args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode without pageSize", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode with endTime", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, } for _, tc := range validCases { t.Run(tc.name, func(t *testing.T) { @@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"}, + {"approval", "list-by-admin"}, + {"approval", "list-by-admin", "--process-code", "PROC"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", ""}, + {"approval", "list-by-admin", "--request", "{"}, + {"approval", "list-by-admin", "--request", "null"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go new file mode 100644 index 00000000..cb2979c9 --- /dev/null +++ b/internal/helpers/recruit.go @@ -0,0 +1,678 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +const ( + recruitServerID = "recruit" + recruitCreateJobTool = "create_job" + recruitGetJobTool = "get_job_detail" + recruitListJobsTool = "list_jobs" +) + +var recruitDryRun = &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewRequest, + RemoteReads: false, +} + +var ( + recruitListResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`), + } + recruitJobDetailResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`), + } + recruitCreateJobResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`), + } +) + +func recruitCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"} +} + +func newRecruitCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "recruit", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询和创建钉钉招聘职位", + UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"}, + AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"}, + }, + }) + + root := &cobra.Command{ + Use: "recruit", + Short: "钉钉招聘", + Long: "查询和创建钉钉招聘中的职位信息。", + RunE: groupRunE, + } + job := &cobra.Command{ + Use: "job", + Short: "招聘职位管理", + RunE: groupRunE, + } + job.AddCommand( + newRecruitJobListCommand(), + newRecruitJobGetCommand(), + newRecruitJobCreateCommand(), + ) + root.AddCommand(job) + return root +} + +func newRecruitJobListCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + Flags: []LeafFlag{ + {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, + {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, + {Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses}, + {Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true}, + {Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"}, + {Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, + {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, + {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor}, + {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"}, + }, + Validate: validateRecruitList, + ResultCall: recruitResultCall, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, + Description: "按条件分页查询招聘职位", + DryRun: recruitDryRun, + Result: recruitListResult, + Pagination: recruitCursorPagination(), + Interface: recruitMCPInterface(recruitListJobsTool), + Selection: contract.SelectionSpec{ + AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", + UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"}, + AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"}, + Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`}, + }, + Parameters: recruitListParamDecls(), + }, + }) +} + +func recruitListToolArgs(args map[string]any) map[string]any { + params := map[string]any{"param": map[string]any{}, "size": args["size"]} + query := params["param"].(map[string]any) + for key, value := range args { + switch key { + case "cursor": + params[key] = value + case "size": + default: + query[key] = value + } + } + return params +} + +func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + toolArgs := args + if tool == recruitListJobsTool { + toolArgs = recruitListToolArgs(args) + } + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "tool": tool, "arguments": toolArgs, "executed": false, + }, output.WithDryRun()), nil + } + data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs) + if err != nil { + return nil, err + } + clean, err := recruitBusinessResultData(data, tool) + if err != nil { + return recruitResponseFailure(err), nil + } + switch tool { + case recruitListJobsTool: + listData, meta, err := recruitListResultData(clean) + if err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(listData, output.WithMeta(meta)), nil + case recruitGetJobTool, recruitCreateJobTool: + if err := validateRecruitJobResult(clean, tool, args); err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(clean), nil + default: + return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil + } +} + +func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error { + jobID, ok := data["jobId"].(string) + if !ok || strings.TrimSpace(jobID) == "" { + return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool) + } + if tool != recruitGetJobTool { + return nil + } + requestedJobID, ok := args["jobId"].(string) + if !ok || strings.TrimSpace(requestedJobID) == "" { + return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool) + } + if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) { + return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID) + } + return nil +} + +func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) { + text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var data any + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return data, nil +} + +func recruitResponseFailure(err error) output.CommandResult { + var businessFailure *recruitBusinessFailure + if errors.As(err, &businessFailure) { + return output.Failure(&output.ErrorInfo{ + Type: "api", Message: businessFailure.Error(), + }) + } + return recruitInvalidResponse(err) +} + +type recruitBusinessFailure struct { + message string +} + +func (e *recruitBusinessFailure) Error() string { + return e.message +} + +func recruitBusinessResultData(data any, tool string) (map[string]any, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool) + } + successValue, hasSuccess := object["success"] + resultValue, hasResult := object["result"] + if !hasSuccess && !hasResult { + return object, nil + } + if !hasSuccess || !hasResult { + return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool) + } + success, ok := successValue.(bool) + if !ok { + return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool) + } + if !success { + message, _ := object["message"].(string) + if strings.TrimSpace(message) == "" { + message = "Connector 返回 success=false" + } + return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)} + } + result, ok := resultValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool) + } + return result, nil +} + +func recruitInvalidResponse(err error) output.CommandResult { + return output.Failure(&output.ErrorInfo{ + Type: "api", Subtype: "invalid_response", Message: err.Error(), + Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。", + }) +} + +func recruitListResultData(data any) (any, *output.Meta, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool) + } + // The Connector envelope has already been removed by recruitResultCall. + // Only normalize the list business payload here so fields named success or + // result inside that payload cannot be mistaken for another envelope. + hasMore, ok := object["hasMore"].(bool) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") + } + nextToken := "" + if hasMore { + if raw, exists := object["nextCursor"]; exists && raw != nil { + var err error + nextToken, err = normalizeRecruitNextCursor(raw) + if err != nil { + return nil, nil, err + } + } + if nextToken == "" { + return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") + } + } + clean := make(map[string]any, len(object)) + for key, value := range object { + if key != "hasMore" && key != "nextCursor" { + clean[key] = value + } + } + if jobs, exists := clean["list"]; exists { + if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized { + clean["jobs"] = jobs + } + delete(clean, "list") + } + pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1} + meta := &output.Meta{Pagination: pagination} + if jobs, exists := clean["jobs"].([]any); exists { + meta.Count = output.NewCount(len(jobs)) + pagination.Items = len(jobs) + } + return clean, meta, nil +} + +func normalizeRecruitNextCursor(raw any) (string, error) { + var value string + switch cursor := raw.(type) { + case string: + value = strings.TrimSpace(cursor) + case json.Number: + value = string(cursor) + case float64: + value = strconv.FormatFloat(cursor, 'f', -1, 64) + default: + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标") + } + return strconv.FormatInt(parsed, 10), nil +} + +func newRecruitJobGetCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, + Flags: []LeafFlag{{ + Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, + Required: true, RequiredHint: "--job-id 为必填", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, + Description: "根据职位 ID 查询招聘职位详情", + DryRun: recruitDryRun, + Result: recruitJobDetailResult, + Interface: recruitMCPInterface(recruitGetJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定招聘职位的完整信息", + UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"}, + AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"}, + Examples: []string{"dws recruit job get --job-id --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}}, + }, + }) +} + +func newRecruitJobCreateCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, + Flags: []LeafFlag{{ + Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, + Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, + Description: "从 JSON 文件创建招聘职位", + DryRun: recruitDryRun, + Result: recruitCreateJobResult, + Interface: recruitMCPInterface(recruitCreateJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "使用结构化 JSON 创建招聘职位", + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"}, + AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, + Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}}, + }, + }) +} + +func recruitListParamDecls() []contract.ParamDecl { + return []contract.ParamDecl{ + {Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"}, + {Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"}, + {Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}}, + {Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"}, + {Name: "campus", Property: "param.campus", InterfaceType: "boolean"}, + {Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"}, + {Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"}, + {Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"}, + {Name: "keyword", Property: "param.keyword", InterfaceType: "string"}, + {Name: "category", Property: "param.category", InterfaceType: "string"}, + {Name: "cursor", Property: "cursor", InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, + } +} + +func recruitMCPInterface(tool string) *contract.InterfaceSpec { + return &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool}, + } +} + +func recruitSafetyRead() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func recruitSafetyCreate() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"} +} + +func transformRecruitStatuses(raw string) (any, error) { + values := strings.Split(raw, ",") + statuses := make([]int, 0, len(values)) + seen := make(map[int]bool, len(values)) + lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3} + for _, value := range values { + name := strings.ToLower(strings.TrimSpace(value)) + if name == "" { + continue + } + status, ok := lookup[name] + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value)) + } + if !seen[status] { + statuses = append(statuses, status) + seen[status] = true + } + } + return statuses, nil +} + +func transformRecruitCursor(raw string) (any, error) { + value := strings.TrimSpace(raw) + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数") + } + return cursor, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { + size, _ := cmd.Flags().GetInt("size") + if cmd.Flags().Changed("size") && (size < 1 || size > 100) { + return apperrors.NewValidation("--size 必须在 1 到 100 之间") + } + requiredEdu, _ := cmd.Flags().GetInt("required-edu") + if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) { + return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间") + } + return nil +} + +func loadRecruitJobFile(path string) (any, error) { + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("读取职位 JSON 失败: %w", err) + } + var job map[string]any + if err := json.Unmarshal(data, &job); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err)) + } + if job == nil { + return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象") + } + if err := validateRecruitJob(job); err != nil { + return nil, err + } + return job, nil +} + +func validateRecruitJob(job map[string]any) error { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} { + value, ok := job[name] + if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) + } + } + for _, name := range []string{"name", "description", "jobNature"} { + if !isString(job[name]) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + if job["jobNature"].(string) != "FULL-TIME" { + return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME") + } + requiredEdu, ok := job["requiredEdu"].(float64) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字") + } + if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数") + } + + minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary") + if err != nil { + return err + } + maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary") + if err != nil { + return err + } + if hasMinSalary && hasMaxSalary && minSalary > maxSalary { + return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + } + + for _, name := range []string{"province", "city", "district", "category"} { + if value, exists := job[name]; exists && value != nil && !isString(value) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + if value, exists := job["campus"]; exists && value != nil { + if _, ok := value.(bool); !ok { + return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值") + } + } + if err := validateRecruitIdentityFields(job); err != nil { + return err + } + if err := validateRecruitAddress(job); err != nil { + return err + } + extData, ok := job["extData"].(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象") + } + if err := validateRecruitExtData(extData); err != nil { + return err + } + return nil +} + +func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) { + value, exists := values[name] + if !exists || value == nil { + return 0, false, nil + } + number, ok := value.(float64) + if !ok { + return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + } + return number, true, nil +} + +func validateRecruitIdentityFields(job map[string]any) error { + if _, ok := job["creatorUserId"].(string); !ok { + return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串") + } + if value, exists := job["ownerUserIds"]; exists && value != nil { + ownerUserIDs, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组") + } + for _, owner := range ownerUserIDs { + ownerUserID, ok := owner.(string) + if !ok || strings.TrimSpace(ownerUserID) == "" { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串") + } + } + } + return nil +} + +func validateRecruitAddress(job map[string]any) error { + value, exists := job["address"] + if !exists || value == nil { + return nil + } + address, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 address 必须是对象") + } + for _, name := range []string{"name", "detail", "longitude", "latitude"} { + field, exists := address[name] + text, ok := field.(string) + if !exists || !ok || strings.TrimSpace(text) == "" { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name)) + } + } + return nil +} + +func validateRecruitExtData(extData map[string]any) error { + if value, exists := extData["headCount"]; exists && value != nil { + headCount, ok := value.(float64) + if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) { + return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数") + } + } + if value, exists := extData["source"]; exists && value != nil && !isString(value) { + return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串") + } + if value, exists := extData["fullTimeExtData"]; exists && value != nil { + fullTime, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象") + } + if err := validateRecruitFullTimeExtData(fullTime); err != nil { + return err + } + } + if value, exists := extData["tags"]; exists && value != nil { + tags, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组") + } + for _, value := range tags { + tag, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象") + } + name, ok := tag["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串") + } + } + } + return nil +} + +func validateRecruitFullTimeExtData(fullTime map[string]any) error { + salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth") + if err != nil { + return err + } + if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数") + } + minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience") + if err != nil { + return err + } + maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience") + if err != nil { + return err + } + if hasMinExperience && minExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0") + } + if hasMaxExperience && maxExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0") + } + if hasMinExperience && hasMaxExperience && minExperience > maxExperience { + return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience") + } + return nil +} + +func isString(value any) bool { + _, ok := value.(string) + return ok +} diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go new file mode 100644 index 00000000..bb8879b4 --- /dev/null +++ b/internal/helpers/recruit_test.go @@ -0,0 +1,1050 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type recruitCapturedCall struct { + productID string + tool string + args map[string]any +} + +type recruitCaptureCaller struct { + productID string + tool string + args map[string]any + calls []recruitCapturedCall + err error + text string + dryRun bool +} + +func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.tool = tool + c.args = args + c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) + if c.err != nil { + return nil, c.err + } + text := c.text + if text == "" { + text = `{}` + switch tool { + case recruitListJobsTool: + text = `{"jobs":[],"hasMore":false}` + case recruitCreateJobTool: + text = `{"jobId":"created-job"}` + } + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (c *recruitCaptureCaller) Format() string { return "json" } +func (c *recruitCaptureCaller) DryRun() bool { return c.dryRun } +func (c *recruitCaptureCaller) Fields() string { return "" } +func (c *recruitCaptureCaller) JQ() string { return "" } + +func withRecruitCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func prepareRecruitTestCommand(cmd *cobra.Command) *cobra.Command { + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return cmd +} + +func TestRecruitJobListWrapsQueryParameters(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{ + "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", + "--campus=false", "--required-edu", "9", "--cursor", "10", "--size", "30", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) + } + if caller.args["cursor"] != int64(10) || caller.args["size"] != 30 { + t.Fatalf("pagination args = %#v", caller.args) + } + param, ok := caller.args["param"].(map[string]any) + if !ok { + t.Fatalf("param = %#v, want object", caller.args["param"]) + } + if param["keyword"] != "Java" || param["campus"] != false || param["requiredEdu"] != 9 { + t.Fatalf("param = %#v", param) + } + statuses, ok := param["statusList"].([]int) + if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 { + t.Fatalf("statusList = %#v", param["statusList"]) + } + creators, ok := param["creatorUserIds"].([]string) + if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" { + t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"]) + } +} + +func TestRecruitJobListCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", "9223372036854775807"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("cursor = %#v (%T), want max int64", got, got) + } + + for _, cursor := range []string{"not-a-cursor", "9223372036854775808"} { + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--cursor", cursor}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0 的整数") { + t.Fatalf("cursor %q error = %v", cursor, err) + } + } +} + +func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` + + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || envelope.Meta.Pagination == nil { + t.Fatalf("envelope = %#v", envelope) + } + meta := envelope.Meta + if got := meta.Pagination.NextToken; got != "9223372036854775807" { + t.Fatalf("next token = %q, want lossless max int64", got) + } + + caller.text = `{"jobs":[],"hasMore":false}` + cmd = prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("replayed cursor = %#v (%T), want max int64", got, got) + } +} + +func TestRecruitJobListDefaultsAndValidation(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.args["size"] != 20 { + t.Fatalf("size = %#v, want 20", caller.args["size"]) + } + if _, ok := caller.args["cursor"]; ok { + t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) + } + + badStatus := prepareRecruitTestCommand(newRecruitJobListCommand()) + badStatus.SetArgs([]string{"--status", "unknown"}) + if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { + t.Fatalf("invalid status error = %v", err) + } + + badSize := prepareRecruitTestCommand(newRecruitJobListCommand()) + badSize.SetArgs([]string{"--size", "101"}) + if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { + t.Fatalf("invalid size error = %v", err) + } + + for _, requiredEdu := range []string{"0", "10"} { + t.Run("required edu "+requiredEdu, func(t *testing.T) { + caller := withRecruitCaller(t) + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--required-edu", requiredEdu}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 9") { + t.Fatalf("required edu %s error = %v", requiredEdu, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid required edu = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestRecruitJobGetDispatchesJobID(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + cmd.SetArgs([]string{"--job-id", "job-1"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" { + t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args) + } +} + +func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { + t.Helper() + path := filepath.Join(t.TempDir(), "job.json") + job := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": 6, "minSalary": 20000, "maxSalary": 35000, + "creatorUserId": "creator-user-id", "ownerUserIds": []string{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": 1, + "fullTimeExtData": map[string]any{"salaryMonth": 12, "minJobExperience": 1, "maxJobExperience": 3}, + }, + } + data, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, job +} + +func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"--from", path}) + err := cmd.Execute() + var appErr *apperrors.Error + if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls) + } +} + +func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls) + } + call := caller.calls[0] + if call.productID != recruitServerID || call.tool != recruitCreateJobTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool) + } + wantArgs := map[string]any{"atsAddJobParam": map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, + }} + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestRecruitCreateValidatesJobFile(t *testing.T) { + withRecruitCaller(t) + badPath := filepath.Join(t.TempDir(), "bad-job.json") + if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { + t.Fatal(err) + } + bad := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + bad.Flags().Bool("yes", false, "确认创建") + bad.SetArgs([]string{"--from", badPath, "--yes"}) + if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("missing field error = %v", err) + } +} + +func TestRecruitCreateRejectsInvalidCreatorBeforeRemoteCall(t *testing.T) { + for _, test := range []struct { + name string + creator string + }{ + {name: "missing", creator: ""}, + {name: "null", creator: `,"creatorUserId":null`}, + {name: "blank", creator: `,"creatorUserId":" "`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{}` + test.creator + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "creatorUserId") { + t.Fatalf("creator validation error = %v, want creatorUserId error", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid creator = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestRecruitCreateAllowsOptionalOwners(t *testing.T) { + for _, test := range []struct { + name string + owner string + }{ + {name: "missing"}, + {name: "null", owner: `,"ownerUserIds":null`}, + {name: "empty array", owner: `,"ownerUserIds":[]`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{},"creatorUserId":"creator-user-id"` + test.owner + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("optional owner create error = %v, want nil", err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls with optional owner = %d, want 1", len(caller.calls)) + } + }) + } +} + +func TestRecruitValidationBranches(t *testing.T) { + statuses, err := transformRecruitStatuses(" open, ,open,closed ") + if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) { + t.Fatalf("statuses = %#v, err = %v", statuses, err) + } + + negativeCursor := prepareRecruitTestCommand(newRecruitJobListCommand()) + negativeCursor.SetArgs([]string{"--cursor", "-1"}) + if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { + t.Fatalf("negative cursor error = %v", err) + } + + nullPath := filepath.Join(t.TempDir(), "null.json") + if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { + t.Fatalf("null job error = %v", err) + } + if _, err := loadRecruitJobFile(filepath.Join(t.TempDir(), "missing.json")); err == nil || !strings.Contains(err.Error(), "读取职位 JSON 失败") { + t.Fatalf("missing job file error = %v", err) + } + invalidPath := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(invalidPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(invalidPath); err == nil || !strings.Contains(err.Error(), "不是有效的 JSON 对象") { + t.Fatalf("invalid job JSON error = %v", err) + } + + base := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, + } + for _, test := range []struct { + name string + mutate func(map[string]any) + want string + }{ + {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, + {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, + {name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"}, + {name: "missing creator", mutate: func(job map[string]any) { delete(job, "creatorUserId") }, want: "缺少必填字段 creatorUserId"}, + {name: "null creator", mutate: func(job map[string]any) { job["creatorUserId"] = nil }, want: "缺少必填字段 creatorUserId"}, + {name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"}, + {name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"}, + {name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"}, + {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, + {name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"}, + {name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"}, + {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "缺少必填字段 creatorUserId"}, + {name: "creator non-string", mutate: func(job map[string]any) { job["creatorUserId"] = float64(123) }, want: "creatorUserId 必须是非空字符串"}, + {name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"}, + {name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"}, + {name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"}, + {name: "region type", mutate: func(job map[string]any) { job["city"] = 330100 }, want: "city 必须是字符串"}, + {name: "address type", mutate: func(job map[string]any) { job["address"] = "杭州" }, want: "address 必须是对象"}, + {name: "address required field", mutate: func(job map[string]any) { + job["address"] = map[string]any{"name": "亲橙Park", "detail": "杭州市余杭区", "longitude": "120.0"} + }, want: "address.latitude 必须是非空字符串"}, + {name: "head count range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"headCount": float64(0)} + }, want: "headCount 必须是 1 到 999 的整数"}, + {name: "source type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"source": true} + }, want: "extData.source 必须是字符串"}, + {name: "full time ext data type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": "invalid"} + }, want: "fullTimeExtData 必须是对象"}, + {name: "salary month range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": float64(25)}} + }, want: "salaryMonth 必须是 12 到 24 的整数"}, + {name: "salary month type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": "12"}} + }, want: "salaryMonth 必须是数字"}, + {name: "min experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": "1"}} + }, want: "minJobExperience 必须是数字"}, + {name: "max experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": "3"}} + }, want: "maxJobExperience 必须是数字"}, + {name: "negative min experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(-1)}} + }, want: "minJobExperience 不能小于 0"}, + {name: "negative max experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": float64(-1)}} + }, want: "maxJobExperience 不能小于 0"}, + {name: "experience range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(5), "maxJobExperience": float64(3)}} + }, want: "minJobExperience 不能大于 maxJobExperience"}, + {name: "tags type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": "五险一金"} + }, want: "extData.tags 必须是数组"}, + {name: "tag item type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{"五险一金"}} + }, want: "extData.tags 只能包含对象"}, + {name: "tag name", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{map[string]any{"name": ""}}} + }, want: "tags[].name 必须是非空字符串"}, + } { + t.Run(test.name, func(t *testing.T) { + job := make(map[string]any, len(base)) + for key, value := range base { + job[key] = value + } + test.mutate(job) + if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } + + withoutSalary := make(map[string]any, len(base)) + for key, value := range base { + withoutSalary[key] = value + } + delete(withoutSalary, "minSalary") + delete(withoutSalary, "maxSalary") + if err := validateRecruitJob(withoutSalary); err != nil { + t.Fatalf("optional salary validation error = %v, want nil", err) + } + + completeAddress := make(map[string]any, len(base)+1) + for key, value := range base { + completeAddress[key] = value + } + completeAddress["address"] = map[string]any{ + "name": "亲橙Park", "detail": "浙江省杭州市余杭区", "longitude": "120.008822", "latitude": "30.270739", + } + completeAddress["campus"] = false + completeAddress["province"] = "330000" + completeAddress["city"] = "330100" + completeAddress["district"] = "330110" + if err := validateRecruitJob(completeAddress); err != nil { + t.Fatalf("complete job validation error = %v, want nil", err) + } +} + +func TestRecruitListResultData(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"}, + {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"}, + {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"}, + {name: "exponent cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1e3")}, want: "非负十进制 int64"}, + {name: "decimal cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1.5")}, want: "非负十进制 int64"}, + {name: "overflow cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("9223372036854775808")}, want: "非负十进制 int64"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + + data, meta, err := recruitListResultData(map[string]any{ + "jobs": []any{map[string]any{"jobId": "job-1"}}, "hasMore": true, "nextCursor": float64(12), + }) + if err != nil { + t.Fatal(err) + } + if meta.Count == nil || *meta.Count != 1 || meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "12" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 1 { + t.Fatalf("meta = %#v", meta) + } + clean := data.(map[string]any) + if _, exists := clean["hasMore"]; exists || clean["jobs"] == nil { + t.Fatalf("clean data = %#v", clean) + } + + _, normalizedCursor, err := recruitListResultData(map[string]any{ + "jobs": []any{}, "hasMore": true, "nextCursor": "00012", + }) + if err != nil || normalizedCursor.Pagination.NextToken != "12" { + t.Fatalf("normalized cursor meta = %#v, err = %v", normalizedCursor, err) + } + + _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"}) + if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { + t.Fatalf("terminal meta = %#v, err = %v", terminal, err) + } + + normalized, normalizedMeta, err := recruitListResultData(map[string]any{ + "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}}, + "hasMore": false, + "nextCursor": nil, + "totalCount": float64(1), + "success": true, + "result": map[string]any{"source": "business-payload"}, + }) + if err != nil { + t.Fatal(err) + } + normalizedData := normalized.(map[string]any) + jobs, ok := normalizedData["jobs"].([]any) + if !ok || len(jobs) != 1 || normalizedData["totalCount"] != float64(1) { + t.Fatalf("normalized data = %#v", normalizedData) + } + if _, exists := normalizedData["list"]; exists { + t.Fatalf("normalized data retained Connector list field: %#v", normalizedData) + } + if normalizedData["success"] != true || !reflect.DeepEqual( + normalizedData["result"], map[string]any{"source": "business-payload"}, + ) { + t.Fatalf("business success/result fields were unwrapped: %#v", normalizedData) + } + if normalizedMeta.Count == nil || *normalizedMeta.Count != 1 || !normalizedMeta.Pagination.EndpointExhausted { + t.Fatalf("normalized meta = %#v", normalizedMeta) + } +} + +func TestRecruitListResultDataAcceptsMinimalTerminalPage(t *testing.T) { + data, meta, err := recruitListResultData(map[string]any{"hasMore": false}) + if err != nil { + t.Fatal(err) + } + clean, ok := data.(map[string]any) + if !ok || len(clean) != 0 { + t.Fatalf("data = %#v, want empty object", data) + } + if meta == nil || meta.Pagination == nil || !meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 0 { + t.Fatalf("meta = %#v, want exhausted terminal page", meta) + } + if meta.Count != nil { + t.Fatalf("count = %#v, want nil when jobs is absent", meta.Count) + } + + caller := withRecruitCaller(t) + caller.text = `{"success":true,"result":{"hasMore":false}}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || + envelope.Meta.Pagination == nil || !envelope.Meta.Pagination.EndpointExhausted { + t.Fatalf("envelope = %#v, want successful exhausted terminal page", envelope) + } +} + +func TestRecruitResultCallPropagatesMCPError(t *testing.T) { + caller := withRecruitCaller(t) + caller.err = stderrors.New("transport failed") + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + if _, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}); err == nil { + t.Fatal("expected MCP error") + } +} + +func TestRecruitMCPDataDecoderIsLosslessAndStrict(t *testing.T) { + for _, test := range []struct { + name string + text string + wantErr string + }{ + {name: "empty", text: " "}, + {name: "invalid JSON", text: "{", wantErr: "解析 list_jobs 返回失败"}, + {name: "multiple values", text: `{} {}`, wantErr: "存在多个 JSON 值"}, + {name: "invalid trailing value", text: `{} {`, wantErr: "解析 list_jobs 返回失败"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || len(data.(map[string]any)) != 0 { + t.Fatalf("empty response data = %#v, err = %v", data, err) + } + }) + } + + caller := withRecruitCaller(t) + caller.text = `{"nextCursor":9223372036854775807}` + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if err != nil || data.(map[string]any)["nextCursor"] != json.Number("9223372036854775807") { + t.Fatalf("lossless recruit number data = %#v, err = %v", data, err) + } +} + +func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) { + for _, test := range []struct { + name string + tool string + text string + want map[string]any + }{ + { + name: "get detail", + tool: recruitGetJobTool, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + want: map[string]any{"jobId": "job-detail", "name": "Java 工程师", "status": json.Number("1")}, + }, + { + name: "create job", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + want: map[string]any{"jobId": "job-created"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := callRecruitMCPToolData(context.Background(), test.tool, map[string]any{}) + if err != nil { + t.Fatal(err) + } + clean, err := recruitBusinessResultData(data, test.tool) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(clean, test.want) { + t.Fatalf("normalized data = %#v, want %#v", clean, test.want) + } + if _, hasSuccess := clean["success"]; hasSuccess { + t.Fatalf("normalized data retained Connector envelope: %#v", clean) + } + if _, hasResult := clean["result"]; hasResult { + t.Fatalf("normalized data retained nested result: %#v", clean) + } + }) + } +} + +func TestRecruitCommandsPublishUnwrappedConnectorResult(t *testing.T) { + for _, test := range []struct { + name string + leaf func() *cobra.Command + args func(*testing.T) []string + text string + wantID string + }{ + { + name: "get detail", + leaf: newRecruitJobGetCommand, + args: func(*testing.T) []string { return []string{"--job-id", "job-detail"} }, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + wantID: "job-detail", + }, + { + name: "create job", + leaf: newRecruitJobCreateCommand, + args: func(t *testing.T) []string { + path, _ := writeRecruitJobFixture(t) + return []string{"--from", path, "--yes"} + }, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + wantID: "job-created", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("fields", "", "") + root.PersistentFlags().String("jq", "", "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(cmd) + return err + } + leaf := test.leaf() + root.AddCommand(leaf) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs(append([]string{leaf.Name()}, test.args(t)...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output %q: %v", stdout.String(), err) + } + if envelope.Data["jobId"] != test.wantID { + t.Fatalf("data = %#v, want top-level jobId %q", envelope.Data, test.wantID) + } + if _, exists := envelope.Data["success"]; exists { + t.Fatalf("public data retained Connector success: %#v", envelope.Data) + } + if _, exists := envelope.Data["result"]; exists { + t.Fatalf("public data retained Connector result: %#v", envelope.Data) + } + }) + } +} + +func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing result", data: map[string]any{"success": true}, want: "同时包含 success 和 result"}, + {name: "missing success", data: map[string]any{"result": map[string]any{}}, want: "同时包含 success 和 result"}, + {name: "invalid success", data: map[string]any{"success": "true", "result": map[string]any{}}, want: "success 必须是布尔值"}, + {name: "business failure", data: map[string]any{"success": false, "message": "职位不存在", "result": map[string]any{}}, want: "职位不存在"}, + {name: "business failure without message", data: map[string]any{"success": false, "result": map[string]any{}}, want: "Connector 返回 success=false"}, + {name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := recruitBusinessResultData(test.data, recruitGetJobTool) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + var businessFailure *recruitBusinessFailure + isBusinessFailure := stderrors.As(err, &businessFailure) + wantBusinessFailure := strings.HasPrefix(test.name, "business failure") + if isBusinessFailure != wantBusinessFailure { + t.Fatalf("business failure classification = %t, want %t", isBusinessFailure, wantBusinessFailure) + } + }) + } +} + +func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) { + for _, test := range []struct { + name string + text string + wantSubtype string + wantMessage string + wantHint bool + }{ + { + name: "business failure", + text: `{"success":false,"message":"职位不存在","result":{}}`, + wantMessage: "职位不存在", + }, + { + name: "malformed envelope", + text: `{"success":true}`, + wantSubtype: "invalid_response", + wantMessage: "同时包含 success 和 result", + wantHint: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + var payload any + if err := json.Unmarshal([]byte(test.text), &payload); err != nil { + t.Fatal(err) + } + _, responseErr := recruitBusinessResultData(payload, recruitGetJobTool) + if responseErr == nil { + t.Fatal("expected response error") + } + result := recruitResponseFailure(responseErr) + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != test.wantSubtype || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v", envelope.Error) + } + if (envelope.Error.Hint != "") != test.wantHint { + t.Fatalf("hint = %q, want present %t", envelope.Error.Hint, test.wantHint) + } + }) + } +} + +func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { + for _, response := range []string{ + `{"jobs":[],"hasMore":true}`, + `{"jobs":[],"hasMore":true,"nextCursor":1e3}`, + `{"jobs":[],"hasMore":true,"nextCursor":1.5}`, + `{"jobs":[],"hasMore":true,"nextCursor":9223372036854775808}`, + } { + t.Run(response, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = response + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" { + t.Fatalf("envelope = %#v, want invalid_response failure", envelope) + } + }) + } +} + +func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) { + for _, test := range []struct { + name string + tool string + args map[string]any + text string + wantMessage string + }{ + { + name: "create empty response", + tool: recruitCreateJobTool, + text: " ", + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty object", + tool: recruitCreateJobTool, + text: `{}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create envelope missing job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":" "}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create non-string job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":123}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get missing job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"name":"Java 工程师"}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get request missing job id", + tool: recruitGetJobTool, + args: map[string]any{}, + text: `{"success":true,"result":{"jobId":"job-returned"}}`, + wantMessage: "请求缺少非空字符串字段 jobId", + }, + { + name: "get mismatched job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"jobId":"job-other"}}`, + wantMessage: "与请求的 jobId", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), test.tool, test.args) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v, want failure", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != "invalid_response" || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v, want invalid_response containing %q", envelope.Error, test.wantMessage) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls = %d, want exactly one", len(caller.calls)) + } + }) + } +} + +func TestRecruitResultCallRejectsToolWithoutResultValidator(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"jobId":"job-created"}` + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), "future_write_tool", nil) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" || !strings.Contains(envelope.Error.Message, "不支持校验") { + t.Fatalf("envelope = %#v, want unsupported validator failure", envelope) + } +} + +func TestRecruitResultCallDryRun(t *testing.T) { + caller := withRecruitCaller(t) + caller.dryRun = true + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeSuccess || len(caller.calls) != 0 { + t.Fatalf("outcome = %s, calls = %d", result.Outcome(), len(caller.calls)) + } +} + +func TestRecruitCommandTree(t *testing.T) { + root := newRecruitCommand() + for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { + cmd, _, err := root.Find(path) + if err != nil || cmd == root || cmd.Name() != path[len(path)-1] { + t.Fatalf("Find(%v) = %v, %v", path, cmd, err) + } + } +} + +func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) { + root := newRecruitCommand() + tests := []struct { + path []string + canonical string + resultField string + pagination bool + }{ + {path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true}, + {path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"}, + {path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"}, + } + for _, test := range tests { + t.Run(test.canonical, func(t *testing.T) { + leaf, _, err := root.Find(test.path) + if err != nil { + t.Fatal(err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical { + t.Fatalf("final identity = %#v, found = %v", final.Identity, ok) + } + if final.Result == nil { + t.Fatal("final Result is nil") + } + wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure} + if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) { + t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes) + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil { + t.Fatalf("unmarshal result data_schema: %v", err) + } + if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 { + t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField) + } + if test.pagination { + if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor || + final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath { + t.Fatalf("pagination = %#v, want cursor contract", final.Pagination) + } + } else if final.Pagination != nil { + t.Fatalf("pagination = %#v, want nil", final.Pagination) + } + }) + } +} diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go index 7e89a565..16933d43 100644 --- a/internal/helpers/register_products_test.go +++ b/internal/helpers/register_products_test.go @@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t for _, want := range []string{ "agoal", "aisearch", "aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "doc", "drive", "live", "mail", - "markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard", + "markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard", } { if !seenProducts[want] { t.Errorf("public product %q was not registered", want) diff --git a/internal/helpers/register_recruit.go b/internal/helpers/register_recruit.go new file mode 100644 index 00000000..3ba9ceb2 --- /dev/null +++ b/internal/helpers/register_recruit.go @@ -0,0 +1,11 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。 +func init() { + RegisterPublic(func() Handler { + return wukongHandler{name: "recruit", buildFn: newRecruitCommand} + }) +} diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go new file mode 100644 index 00000000..053db474 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations.go @@ -0,0 +1,639 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" +) + +const CommandMigrationManifestVersion = 1 + +const ( + CommandMigrationPending = "pending" + CommandMigrationConsumed = "consumed" + + CommandMigrationMove = "command_move" + CommandMigrationFlagExtraction = "flag_extraction" +) + +// CommandMigrationManifest governs compatibility-preserving surface moves that +// cannot be represented as an in-command flag rename. The merge-base owns the +// authorization; the candidate copy is only a lifecycle receipt. +type CommandMigrationManifest struct { + Version int `json:"version"` + Migrations []CommandMigration `json:"migrations"` +} + +type CommandMigration struct { + Kind string `json:"kind"` + Legacy CommandMigrationSide `json:"legacy"` + Replacement CommandMigrationSide `json:"replacement"` + LegacyFlag CommandMigrationFlag `json:"legacy_flag"` + Schema CommandMigrationSchema `json:"schema"` + State string `json:"state"` + Reason string `json:"reason"` +} + +type CommandMigrationSide struct { + Command string `json:"command"` + Before CommandMigrationState `json:"before"` + After CommandMigrationState `json:"after"` +} + +type CommandMigrationState struct { + Present bool `json:"present"` + Runnable bool `json:"runnable,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type CommandMigrationFlag struct { + Name string `json:"name,omitempty"` + Before FlagMigrationState `json:"before"` + After FlagMigrationState `json:"after"` +} + +type CommandMigrationSchema struct { + ProductID string `json:"product_id"` + SourceToolID string `json:"source_tool_id"` + ReplacementToolID string `json:"replacement_tool_id"` + Parameters []CommandParameterMigration `json:"parameters"` +} + +type CommandParameterMigration struct { + From string `json:"from"` + To string `json:"to,omitempty"` + ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"` +} + +type CommandReplacementConstant struct { + Property string `json:"property"` + Value bool `json:"value"` +} + +func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err) + } + var manifest CommandMigrationManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return CommandMigrationManifest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values") + } + return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err) + } + strict := json.NewDecoder(bytes.NewReader(data)) + strict.UseNumber() + if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil { + return CommandMigrationManifest{}, err + } + if err := manifest.Validate(); err != nil { + return CommandMigrationManifest{}, err + } + return manifest, nil +} + +func (m CommandMigrationManifest) Validate() error { + if m.Version != CommandMigrationManifestVersion { + return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion) + } + if m.Migrations == nil { + return fmt.Errorf("command migration manifest migrations must be an array") + } + seen := make(map[string]bool, len(m.Migrations)) + for index, migration := range m.Migrations { + if err := migration.validate(); err != nil { + return fmt.Errorf("command migration %d: %w", index, err) + } + if seen[migration.key()] { + return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey()) + } + seen[migration.key()] = true + } + return nil +} + +func (m CommandMigration) validate() error { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid kind %q", m.Kind) + } + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } + if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { + return fmt.Errorf("migration must include a non-empty trimmed reason") + } + if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { + return fmt.Errorf("invalid state %q", m.State) + } + for label, state := range map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + "replacement before": m.Replacement.Before, + "replacement after": m.Replacement.After, + } { + if err := state.validate(label); err != nil { + return err + } + } + if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || + !m.Legacy.After.Present || !m.Legacy.After.Runnable { + return fmt.Errorf("legacy command must remain runnable and start visible") + } + if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") + } + if err := m.Schema.validate(m.Kind); err != nil { + return err + } + switch m.Kind { + case CommandMigrationMove: + if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") || + strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") { + return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship") + } + if !m.Legacy.After.Hidden { + return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("command_move must not declare legacy_flag") + } + if m.Schema.SourceToolID != m.Schema.ReplacementToolID { + return fmt.Errorf("command_move must retain one stable Schema tool identity") + } + case CommandMigrationFlagExtraction: + if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After { + return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged") + } + if err := m.LegacyFlag.validate(); err != nil { + return err + } + if m.Schema.SourceToolID == m.Schema.ReplacementToolID { + return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool") + } + if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" { + return fmt.Errorf("flag_extraction legacy_flag must be an optional bool") + } + var extracted *CommandParameterMigration + constantCount := 0 + for index := range m.Schema.Parameters { + parameter := &m.Schema.Parameters[index] + if parameter.ReplacementConstant == nil { + continue + } + constantCount++ + if parameter.From == m.LegacyFlag.Name { + extracted = parameter + } + } + if constantCount != 1 { + return fmt.Errorf("flag_extraction must declare exactly one replacement constant") + } + if extracted == nil { + return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant") + } + if !extracted.ReplacementConstant.Value { + return fmt.Errorf("flag_extraction v1 replacement_constant must be true") + } + wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value) + if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { + return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) + } + } + return nil +} + +func (s CommandMigrationState) validate(label string) error { + if !s.Present { + if s.Runnable || s.Hidden { + return fmt.Errorf("%s absent state must not declare command attributes", label) + } + return nil + } + if !s.Runnable { + return fmt.Errorf("%s present state must be runnable", label) + } + return nil +} + +func (f CommandMigrationFlag) validate() error { + if !isExactFlagName(f.Name) { + return fmt.Errorf("legacy_flag name must be an exact flag") + } + if err := f.Before.validate("legacy_flag before"); err != nil { + return err + } + if err := f.After.validate("legacy_flag after"); err != nil { + return err + } + if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden { + return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present") + } + if f.Before.Required || f.After.Required { + return fmt.Errorf("legacy_flag must remain optional before and after extraction") + } + before := f.Before + after := f.After + before.Hidden = false + after.Hidden = false + if before != after { + return fmt.Errorf("legacy_flag may change only hidden visibility") + } + return nil +} + +func (s CommandMigrationSchema) validate(kind string) error { + for label, value := range map[string]string{ + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, + "replacement_tool_id": s.ReplacementToolID, + } { + if !isExactSchemaIdentifier(value) { + return fmt.Errorf("schema %s must be an exact identifier", label) + } + } + if s.Parameters == nil { + return fmt.Errorf("schema parameters must be an array") + } + seenFrom := map[string]bool{} + seenTo := map[string]bool{} + seenConstantProperty := map[string]bool{} + for index, parameter := range s.Parameters { + if !isExactFlagName(parameter.From) { + return fmt.Errorf("schema parameter %d from must be an exact parameter name", index) + } + if seenFrom[parameter.From] { + return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From) + } + seenFrom[parameter.From] = true + if kind == CommandMigrationMove { + if parameter.ReplacementConstant != nil { + return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index) + } + if !isExactFlagName(parameter.To) || parameter.To == parameter.From { + return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + if kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid command migration kind %q", kind) + } + + hasTo := parameter.To != "" + hasConstant := parameter.ReplacementConstant != nil + if !hasTo && !hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index) + } + if hasTo && hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index) + } + if hasTo { + if !isExactFlagName(parameter.To) { + return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + if seenConstantProperty[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + property := parameter.ReplacementConstant.Property + if !isExactSchemaIdentifier(property) { + return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index) + } + if seenConstantProperty[property] { + return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property) + } + if seenTo[property] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property) + } + seenConstantProperty[property] = true + } + return nil +} + +func isExactSchemaIdentifier(value string) bool { + return value != "" && value == strings.TrimSpace(value) && + !strings.ContainsAny(value, "*?[]{} /\\\t\r\n") +} + +func (m CommandMigration) key() string { + return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00") +} + +func (m CommandMigration) displayKey() string { + return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) +} + +type commandMigrationPhase string + +const ( + commandMigrationBefore commandMigrationPhase = "before" + commandMigrationAfter commandMigrationPhase = "after" + commandMigrationPartial commandMigrationPhase = "partial" +) + +func AuthorizeCommandMigrations( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if err := current.Validate(); err != nil { + return nil, fmt.Errorf("validate current interface snapshot: %w", err) + } + for label, snapshot := range references { + if err := snapshot.Validate(); err != nil { + return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err) + } + } + if err := authority.Validate(); err != nil { + return nil, fmt.Errorf("validate approved command migrations: %w", err) + } + if err := candidate.Validate(); err != nil { + return nil, fmt.Errorf("validate candidate command migrations: %w", err) + } + type commandMoveTopologyCheck struct { + label string + snapshot Snapshot + manifest CommandMigrationManifest + } + topologyChecks := []commandMoveTopologyCheck{ + {label: "approved", snapshot: current, manifest: authority}, + {label: "candidate", snapshot: current, manifest: candidate}, + } + if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok { + topologyChecks = append(topologyChecks, + commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority}, + commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate}, + ) + } + for _, check := range topologyChecks { + if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil { + return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err) + } + } + return evaluateCommandMigrationLifecycle(current, references, authority, candidate) +} + +func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { + commands := commandIndex(snapshot) + for _, migration := range manifest.Migrations { + if migration.Kind != CommandMigrationMove { + continue + } + if _, present := commands[migration.Legacy.Command]; !present { + continue + } + for _, command := range snapshot.Commands { + if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { + return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + } + } + } + return nil +} + +func evaluateCommandMigrationLifecycle( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 { + return nil, nil + } + if _, ok := references["stable"]; !ok { + return nil, fmt.Errorf("command migration lifecycle requires a stable reference") + } + mergeBase, label, ok := flagMigrationAuthoritySnapshot(references) + if !ok { + return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference") + } + authorityByKey := commandMigrationIndex(authority) + candidateByKey := commandMigrationIndex(candidate) + authorizations := make([]CommandMigration, 0, len(authority.Migrations)) + for _, approved := range authority.Migrations { + basePhase := matchCommandMigrationPhase(mergeBase, approved) + wantBase := commandMigrationBefore + if approved.State == CommandMigrationConsumed { + wantBase = commandMigrationAfter + } + if basePhase != wantBase { + return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State) + } + proposed, exists := candidateByKey[approved.key()] + if exists && !sameCommandMigrationApproval(approved, proposed) { + return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey()) + } + currentPhase := matchCommandMigrationPhase(current, approved) + switch approved.State { + case CommandMigrationPending: + if !exists { + return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey()) + } + switch currentPhase { + case commandMigrationBefore: + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey()) + } + case commandMigrationAfter: + if proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey()) + } + authorizations = append(authorizations, approved) + default: + return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey()) + } + case CommandMigrationConsumed: + if currentPhase != commandMigrationAfter { + return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey()) + } + allAfter := true + for _, reference := range references { + if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter { + allAfter = false + break + } + } + if allAfter { + if exists { + return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey()) + } + continue + } + if !exists || proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey()) + } + authorizations = append(authorizations, approved) + } + } + for _, proposed := range candidate.Migrations { + if _, exists := authorityByKey[proposed.key()]; exists { + continue + } + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey()) + } + if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey()) + } + if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey()) + } + } + return authorizations, nil +} + +func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration { + index := make(map[string]CommandMigration, len(manifest.Migrations)) + for _, migration := range manifest.Migrations { + index[migration.key()] = migration + } + return index +} + +func sameCommandMigrationApproval(left, right CommandMigration) bool { + left.State = "" + right.State = "" + return reflect.DeepEqual(left, right) +} + +func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { + commands := commandIndex(snapshot) + legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) + before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before + after := legacy == migration.Legacy.After && replacement == migration.Replacement.After + if migration.Kind == CommandMigrationFlagExtraction { + command, exists := commands[migration.Legacy.Command] + flagState := FlagMigrationState{} + if exists { + flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name) + } + before = before && flagState == migration.LegacyFlag.Before + after = after && flagState == migration.LegacyFlag.After + if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists { + after = after && commandMigrationReplacementConstantsMatch(replacement, migration) + } else { + after = false + } + } + if before { + return commandMigrationBefore + } + if after { + return commandMigrationAfter + } + return commandMigrationPartial +} + +func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool { + expected := make(map[string]bool) + for _, parameter := range migration.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value + } + return reflect.DeepEqual(command.BoolConstParams, expected) +} + +func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState { + command, exists := commands[path] + if !exists { + return CommandMigrationState{} + } + return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden} +} + +// CompareAllWithInterfaceMigrations applies both migration families to one +// ordinary report, so the two ledgers cannot mask each other's unrelated +// findings. +func CompareAllWithInterfaceMigrations( + current Snapshot, + references map[string]Snapshot, + flagAuthority FlagMigrationManifest, + flagCandidate FlagMigrationManifest, + commandAuthority CommandMigrationManifest, + commandCandidate CommandMigrationManifest, +) (Report, error) { + flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate) + if err != nil { + return Report{}, err + } + commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate) + if err != nil { + return Report{}, err + } + report := CompareAll(current, references) + for index := range report.Comparisons { + comparison := &report.Comparisons[index] + reference := references[comparison.Reference] + filtered := comparison.Blocking[:0] + for _, change := range comparison.Blocking { + if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) || + commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) { + continue + } + filtered = append(filtered, change) + } + comparison.Blocking = filtered + comparison.Compatible = len(filtered) == 0 + } + report.Compatible = true + for _, comparison := range report.Comparisons { + if !comparison.Compatible { + report.Compatible = false + break + } + } + return report, nil +} + +func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool { + canonicalPath := acceptedPathIndex(reference)[change.Path] + if canonicalPath == "" { + canonicalPath = change.Path + } + for _, migration := range authorizations { + if canonicalPath != migration.Legacy.Command || + matchCommandMigrationPhase(reference, migration) != commandMigrationBefore || + matchCommandMigrationPhase(current, migration) != commandMigrationAfter { + continue + } + switch migration.Kind { + case CommandMigrationMove: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } + case CommandMigrationFlagExtraction: + if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { + return true + } + } + } + return false +} diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go new file mode 100644 index 00000000..9585afa1 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -0,0 +1,744 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "errors" + "os" + "strings" + "testing" +) + +type commandMigrationErrorReader struct{} + +func (commandMigrationErrorReader) Read([]byte) (int, error) { + return 0, errors.New("command migration read failure") +} + +func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json") + if err != nil { + t.Fatalf("open approved command migration manifest: %v", err) + } + defer manifest.Close() + if _, err := ReadCommandMigrationManifest(manifest); err != nil { + t.Fatalf("approved command migration manifest is invalid: %v", err) + } +} + +func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) { + valid := commandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil { + t.Fatalf("valid command migration manifest: %v", err) + } + validExtraction := flagExtractionCommandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil { + t.Fatalf("valid flag extraction command migration manifest: %v", err) + } + for _, test := range []struct { + input string + want string + }{ + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"}, + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"}, + {strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"}, + {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"}, + {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"}, + {`{"version":1,"migrations":null}`, "must be an array"}, + {strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"}, + {strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`}, + {strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"}, + } { + if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want) + } + } + if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") { + t.Fatalf("reader error=%v", err) + } + for _, input := range []string{valid + ` {}`, valid + ` {`} { + if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing input error=%v", err) + } + } +} + +func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { + validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0] + validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1] + + duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}} + if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("duplicate error=%v", err) + } + if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") { + t.Fatalf("nil migrations error=%v", err) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"}, + {"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"}, + {"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"}, + {"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"}, + {"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"}, + {"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"}, + {"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"}, + {"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"}, + {"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"}, + {"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := validMove + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"}, + {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"}, + {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"}, + {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"}, + {"wrong type", func(m *CommandMigration) { + m.LegacyFlag.Before.Type = "string" + m.LegacyFlag.After.Type = "string" + }, "optional bool"}, + {"wrong no opt", func(m *CommandMigration) { + m.LegacyFlag.Before.NoOpt = "false" + m.LegacyFlag.After.NoOpt = "false" + }, "no_opt"}, + {"missing extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = m.Schema.Parameters[:3] + }, "exactly one replacement constant"}, + {"duplicate extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3]) + }, "duplicates from"}, + {"multiple constants", func(m *CommandMigration) { + m.Schema.Parameters[0] = CommandParameterMigration{ + From: "name", + ReplacementConstant: &CommandReplacementConstant{ + Property: "name", + Value: true, + }, + } + }, "exactly one replacement constant"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := cloneCommandMigration(validExtraction) + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + falseConstant := cloneCommandMigration(validExtraction) + falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false + falseConstant.LegacyFlag.Before.NoOpt = "false" + falseConstant.LegacyFlag.After.NoOpt = "false" + if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") { + t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err) + } + + for _, state := range []CommandMigrationState{ + {Runnable: true}, + {Present: true}, + } { + if err := state.validate("state"); err == nil { + t.Fatalf("invalid command state accepted: %#v", state) + } + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + want string + }{ + {"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"}, + {"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"}, + {"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"}, + {"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"}, + {"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"}, + } { + t.Run("flag "+test.name, func(t *testing.T) { + flag := validExtraction.LegacyFlag + test.mutate(&flag) + if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("flag validate error=%v, want %q", err, test.want) + } + }) + } + + validSchema := validMove.Schema + for _, test := range []struct { + name string + kind string + mutate func(*CommandMigrationSchema) + want string + }{ + {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, + {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, + {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, + {"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"}, + {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To}) + }, "duplicates to"}, + {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true} + }, "must not declare replacement_constant"}, + {"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"}, + } { + t.Run("schema "+test.name, func(t *testing.T) { + schema := validSchema + schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...) + test.mutate(&schema) + if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationSchema) + want string + }{ + {"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"}, + {"both targets", func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true} + }, "exactly one target"}, + {"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"}, + {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"}, + {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"}, + {"duplicate constant target", func(s *CommandMigrationSchema) { + s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}} + }, "duplicates replacement_constant property"}, + {"constant before parameter target", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0] + }, "duplicates parameter target"}, + {"parameter target before constant", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + }, "duplicates parameter target"}, + } { + t.Run("extraction schema "+test.name, func(t *testing.T) { + schema := cloneCommandMigration(validExtraction).Schema + test.mutate(&schema) + if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + }{ + {"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }}, + {"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }}, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] + test.mutate(&migration.LegacyFlag) + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { + t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = "dws agoal" + migration.Replacement.Command = "dws goal" + migration.Schema = CommandMigrationSchema{ + ProductID: "agoal", + SourceToolID: "agoal.root", + ReplacementToolID: "agoal.root", + Parameters: []CommandParameterMigration{}, + } + authority := CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{migration}, + } + consumed := authority + consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...) + consumed.Migrations[0].State = CommandMigrationConsumed + + before := testSnapshot( + testCommand("dws"), + testCommand("dws agoal"), + testCommand("dws agoal strategy"), + testCommand("dws agoal strategy list"), + ) + after := testSnapshot( + testCommand("dws"), + Command{Path: "dws agoal", Runnable: true, Hidden: true}, + testCommand("dws goal"), + ) + + _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + authority, + consumed, + ) + if err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err) + } +} + +func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + after.Commands = append(after.Commands, testCommand("dws chat topic new detail")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil { + t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err) + } + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("replacement parent command_move was rejected: %v", err) + } + if len(got) != 1 { + t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got)) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) { + for _, test := range []struct { + name string + legacy string + replacement string + }{ + { + name: "legacy ancestor", + legacy: "dws chat message", + replacement: "dws chat message list", + }, + { + name: "replacement ancestor", + legacy: "dws chat message list", + replacement: "dws chat message", + }, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = test.legacy + migration.Replacement.Command = test.replacement + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") { + t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + partial := commandMigrationSnapshot(false, false) + partial.Commands = append(partial.Commands, testCommand("dws chat topic new")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 { + t.Fatalf("empty lifecycle=%#v, %v", got, err) + } + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + want string + }{ + {"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"}, + {"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"}, + {"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"}, + {"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"}, + {"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"}, + {"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"}, + {"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"}, + {"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"}, + {"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"}, + {"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"}, + {"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"}, + {"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"}, + {"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"}, + {"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("lifecycle error=%v, want %q", err, test.want) + } + }) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 { + t.Fatalf("retained consumed receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 { + t.Fatalf("cleaned stale receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 { + t.Fatalf("candidate pending plan=%#v, %v", got, err) + } + + invalidSnapshot := before + invalidSnapshot.SchemaVersion = 0 + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + }{ + {"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty}, + {"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty}, + {"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty}, + {"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}}, + } { + t.Run("invalid "+test.name, func(t *testing.T) { + if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil { + t.Fatal("invalid authorization input accepted") + } + }) + } + invalidFlags := FlagMigrationManifest{} + validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil { + t.Fatal("combined compare accepted invalid flag lifecycle") + } + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil { + t.Fatal("combined compare accepted invalid command lifecycle") + } + if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) { + t.Fatal("unrelated command change was authorized") + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := commandMigrationManifest(CommandMigrationPending) + consumed := commandMigrationManifest(CommandMigrationConsumed) + emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatalf("governed command migration: %v", err) + } + if !report.Compatible { + t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons) + } + + unrelated := commandMigrationSnapshot(true, true) + report, err = CompareAllWithInterfaceMigrations( + unrelated, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatal(err) + } + if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") { + t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons) + } + + emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyCommands, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error=%v", err) + } + + partial := commandMigrationSnapshot(true, false) + partial.Commands = partial.Commands[:len(partial.Commands)-1] + if _, err := AuthorizeCommandMigrations( + partial, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("partial command migration error=%v", err) + } +} + +func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := flagExtractionCommandMigrationManifest(CommandMigrationPending) + consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + migration := pending.Migrations[0] + + if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore { + t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore) + } + if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter { + t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter) + } + + for _, test := range []struct { + name string + values map[string]bool + }{ + {name: "missing", values: nil}, + {name: "wrong property", values: map[string]bool{"otherProperty": true}}, + {name: "wrong value", values: map[string]bool{"convThreadEnabled": false}}, + {name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}}, + } { + t.Run(test.name, func(t *testing.T) { + current := withCommandBoolConstParams(after, "dws chat topic create", test.values) + if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial { + t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("constant evidence self-authorization error=%v", err) + } + }) + } +} + +func commandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.old", + "replacement_tool_id": "chat.old", + "parameters": [{"from": "old-id", "to": "new-id"}] + }, + "state": "pending", + "reason": "Reviewed command move." + }] +}` +} + +func flagExtractionCommandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group", + "replacement_tool_id": "chat.create_topic", + "parameters": [ + {"from":"name","to":"name"}, + {"from":"type","to":"type"}, + {"from":"users","to":"users"}, + {"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}} + ] + }, + "state": "pending", + "reason": "Reviewed flag extraction." + }] +}` +} + +func commandMigrationManifest(state string) CommandMigrationManifest { + move := CommandMigration{ + Kind: CommandMigrationMove, + Legacy: CommandMigrationSide{ + Command: "dws chat message old", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic new", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.old", + ReplacementToolID: "chat.old", + Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + State: state, + Reason: "Reviewed command move.", + } + extraction := CommandMigration{ + Kind: CommandMigrationFlagExtraction, + Legacy: CommandMigrationSide{ + Command: "dws chat group create", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic create", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: CommandMigrationFlag{ + Name: "thread", + Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &CommandReplacementConstant{ + Property: "convThreadEnabled", + Value: true, + }, + }, + }, + }, + State: state, + Reason: "Reviewed flag extraction.", + } + return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}} +} + +func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[1:] + return manifest +} + +func cloneCommandMigration(source CommandMigration) CommandMigration { + cloned := source + cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + for index, parameter := range source.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + constant := *parameter.ReplacementConstant + cloned.Schema.Parameters[index].ReplacementConstant = &constant + } + return cloned +} + +func singleCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[:1] + return manifest +} + +func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { + modified := source + modified.Migrations = append([]CommandMigration(nil), source.Migrations...) + modified.Migrations[0].Reason = "Modified reason." + return modified +} + +func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot { + oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}} + groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}} + commands := []Command{ + testCommand("dws"), + testCommand("dws chat group create", groupFlags...), + testCommand("dws chat message old", oldFlags...), + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + if removeUnrelated { + commands[2].LocalFlags = commands[2].LocalFlags[:1] + } + topicCreate := testCommand("dws chat topic create") + topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true} + commands = append(commands, + topicCreate, + testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}), + ) + } + return testSnapshot(commands...) +} + +func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot { + cloned := snapshot + cloned.Commands = append([]Command(nil), snapshot.Commands...) + for index := range cloned.Commands { + if cloned.Commands[index].Path == path { + cloned.Commands[index].BoolConstParams = values + break + } + } + return cloned +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index e520626d..5de0dd9e 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report { // alias), // - flags accepted at each command path may not disappear, change type, or // become required; an existing path may not gain a new required flag, +// - once bool ConstParams evidence exists, its exact property/value map is a +// durable contract; adding the first evidence to an older snapshot remains +// a silent bootstrap, // - new commands and flags are allowed. // // The single exception to the type rule is an individually reviewed migration @@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison { } func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) { + result.Blocking = append(result.Blocking, Change{ + Kind: "bool_const_params_changed", + Path: acceptedPath, + Before: fmt.Sprintf("%v", oldCommand.BoolConstParams), + After: fmt.Sprintf("%v", newCommand.BoolConstParams), + }) + } if oldCommand.Runnable && !newCommand.Runnable { result.Blocking = append(result.Blocking, Change{ Kind: "command_became_non_runnable", diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 9dab9936..e73aa074 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T } } +func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + comparison := Compare(current, base, "base") + if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 { + t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + for _, test := range []struct { + name string + current map[string]bool + }{ + {name: "removed", current: nil}, + {name: "value flipped", current: map[string]bool{"convThreadEnabled": false}}, + {name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}}, + } { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = test.current + + comparison := Compare(current, base, "base") + if comparison.Compatible { + t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison) + } + if len(comparison.Blocking) != 1 { + t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking) + } + change := comparison.Blocking[0] + if change.Kind != "bool_const_params_changed" || change.Path != "dws send" { + t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking) + } + if len(comparison.Additions) != 0 { + t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions) + } + }) + } +} + func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) { base := testSnapshot( testCommand("dws"), diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 892a7c55..a16395f8 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -91,22 +91,32 @@ func validateFlagMigrationJSONSchema(data []byte) error { } func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error { + return validateLabeledMigrationJSONValue(decoder, path, schema, "flag") +} + +func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error { token, err := decoder.Token() if err != nil { - return fmt.Errorf("read flag migration manifest value at %s: %w", path, err) + return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err) + } + for schema.Kind() == reflect.Pointer { + if token == nil { + return migrationJSONTypeError(path, schema.Elem(), token, label) + } + schema = schema.Elem() } switch schema.Kind() { case reflect.Struct: if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } fields := migrationJSONFields(schema) seen := make(map[string]bool, len(fields)) for decoder.More() { keyToken, keyErr := decoder.Token() if keyErr != nil { - return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr) + return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr) } // encoding/json guarantees object member names are string tokens. key := keyToken.(string) @@ -115,57 +125,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle for canonical := range fields { if strings.EqualFold(key, canonical) { return fmt.Errorf( - "flag migration manifest contains non-canonical field %q at %s (want %q)", + "%s migration manifest contains non-canonical field %q at %s (want %q)", + label, key, path, canonical, ) } } - return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path) } if seen[key] { - return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path) } seen[key] = true - if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr) } return nil case reflect.Slice: if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } for index := 0; decoder.More(); index++ { - if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr) } return nil case reflect.String: if _, ok := token.(string); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Int: if _, ok := token.(json.Number); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Bool: if _, ok := token.(bool); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil default: - return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema) + return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema) } } @@ -188,9 +199,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type { return fields } -func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error { +func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error { return fmt.Errorf( - "flag migration manifest value at %s must be %s, got %s", + "%s migration manifest value at %s must be %s, got %s", + label, path, migrationJSONKindDescription(want), migrationJSONTokenDescription(token), diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go index 408805d3..11a7bf46 100644 --- a/internal/interfacesnapshot/snapshot.go +++ b/internal/interfacesnapshot/snapshot.go @@ -22,12 +22,13 @@ import ( "sort" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const SchemaVersion = 2 +const SchemaVersion = 3 // FlagAliasOfAnnotation records a framework-originated reviewed relationship // between a retained compatibility flag and an exact canonical Cobra flag. @@ -66,13 +67,14 @@ type Snapshot struct { // Command contains compatibility-relevant command metadata. Path always // includes the root command name (for example, "dws chat message send"). type Command struct { - Path string `json:"path"` - Runnable bool `json:"runnable"` - Hidden bool `json:"hidden"` - Deprecated string `json:"deprecated,omitempty"` - Aliases []string `json:"aliases"` - LocalFlags []Flag `json:"local_flags"` - InheritedFlags []Flag `json:"inherited_flags"` + Path string `json:"path"` + Runnable bool `json:"runnable"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` + Aliases []string `json:"aliases"` + LocalFlags []Flag `json:"local_flags"` + InheritedFlags []Flag `json:"inherited_flags"` + BoolConstParams map[string]bool `json:"bool_const_params,omitempty"` } // Flag contains the stable pflag contract visible at a command node. @@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot { } aliases = compactSorted(aliases) + boolConstParams := corecmd.InterfaceBoolConstParams(cmd) snapshot.Commands = append(snapshot.Commands, Command{ - Path: path, - Runnable: cmd.Runnable(), - Hidden: cmd.Hidden, - Deprecated: strings.TrimSpace(cmd.Deprecated), - Aliases: aliases, - LocalFlags: captureFlags(cmd.LocalFlags()), - InheritedFlags: captureFlags(cmd.InheritedFlags()), + Path: path, + Runnable: cmd.Runnable(), + Hidden: cmd.Hidden, + Deprecated: strings.TrimSpace(cmd.Deprecated), + Aliases: aliases, + LocalFlags: captureFlags(cmd.LocalFlags()), + InheritedFlags: captureFlags(cmd.InheritedFlags()), + BoolConstParams: boolConstParams, }) children := append([]*cobra.Command(nil), cmd.Commands()...) @@ -198,6 +202,9 @@ func (s Snapshot) Validate() error { return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path) } seenCommands[command.Path] = true + if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 { + return fmt.Errorf("command %q must omit empty bool_const_params", command.Path) + } if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { return err } diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go index eb27274e..2bb6324d 100644 --- a/internal/interfacesnapshot/snapshot_test.go +++ b/internal/interfacesnapshot/snapshot_test.go @@ -17,6 +17,7 @@ import ( "bytes" "encoding/json" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { input := bytes.NewBufferString(`{ - "schema_version": 2, - "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, - "commands": [], - "future_field": true + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true }`) if _, err := Read(input); err == nil { t.Fatal("Read accepted an unknown field") } } +func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := corecmd.New(corecmd.Spec{ + Use: "send", + ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false}, + Invoke: func(*corecmd.Ctx, map[string]any) error { return nil }, + }) + root.AddCommand(leaf) + + snapshot := Capture(root) + got := commandIndex(snapshot)["dws send"].BoolConstParams + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want) + } + + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write: %v", err) + } + decoded, err := Read(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) { + t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) { + root := &cobra.Command{Use: "dws"} + direct := &cobra.Command{ + Use: "direct", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + "dws.compat.bool_const_params": `{"forged":true}`, + "dws.compat.const_params_origin": "corecmd.const_params.v1", + }, + } + businessConstructor := func() *cobra.Command { + payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "") + originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "") + origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "") + return &cobra.Command{ + Use: "business", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + payloadKey: `{"forged":true}`, + originKey: origin, + }, + } + } + root.AddCommand(direct, businessConstructor()) + + snapshot := Capture(root) + if err := snapshot.Validate(); err != nil { + t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err) + } + for _, path := range []string{"dws direct", "dws business"} { + if got := commandIndex(snapshot)[path].BoolConstParams; got != nil { + t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got) + } + } + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write snapshot without forged evidence: %v", err) + } +} + +func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) { + for _, payload := range []string{`{"fixed":"true"}`, `{}`} { + input := bytes.NewBufferString(`{ + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [{ + "path": "dws send", + "runnable": true, + "aliases": [], + "local_flags": [], + "inherited_flags": [], + "bool_const_params": ` + payload + ` + }] + }`) + if _, err := Read(input); err == nil { + t.Fatalf("Read accepted malformed bool_const_params %s", payload) + } + } +} + func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) { if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf { t.Fatalf( diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index 067f922c..5d62242c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -26,6 +26,7 @@ package attendance import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) const ( @@ -86,7 +88,7 @@ func flexDateTime(s string) (string, error) { } // dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date -// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +// resolves to the final millisecond before the next local calendar day. func dateToMillis(s string, endOfDay bool) (int64, error) { s = strings.TrimSpace(s) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { @@ -94,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) { } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { if endOfDay { - return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil } return t.UnixMilli(), nil } @@ -197,14 +199,43 @@ var CheckResult = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, - {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,最多 100 个,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "--offset 不能小于 0,默认 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "--limit 必须大于 0 且不超过 1000,默认 100"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复,最多 100 个"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 1000"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"offset"}, Description: "--offset 不能小于 0"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 100); err != nil { + return err + } + if err := attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")); err != nil { + return err + } + if limit := rt.Int("limit"); limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if rt.Int("offset") < 0 { + return fmt.Errorf("--offset 不能小于 0") + } + return nil }, Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { + limit, offset := rt.Int("limit"), rt.Int("offset") + if limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if offset < 0 { + return fmt.Errorf("--offset 不能小于 0") + } from, err := dayDateTime(rt.Str("start")) if err != nil { return err @@ -213,15 +244,40 @@ var CheckResult = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_result", map[string]any{ + params := map[string]any{ "QueryCheckResultRequest": map[string]any{ "userIds": rt.StrSlice("users"), "workDateFrom": from, "workDateTo": to, - "offset": rt.Int("offset"), - "limit": rt.Int("limit"), + "offset": offset, + "limit": limit, }, - }) + } + data, err := rt.CallMCPData(serverWukong, "query_check_result", params) + if err != nil { + return err + } + records, err := responsecheck.RequireObjectCollection(data, serverWukong+"/query_check_result", "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(records, serverWukong+"/query_check_result", "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { + return err + } + complete := len(records) < limit + extra := map[string]any{"limit": limit} + nextToken := "" + if !complete { + nextOffset := offset + len(records) + extra["nextOffset"] = nextOffset + nextToken = strconv.Itoa(nextOffset) + } + return attendanceOutputCollection(rt, "records", records, complete, extra, true, nextToken) }, } @@ -259,9 +315,20 @@ var CheckRecord = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + return attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")) }, Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -273,13 +340,30 @@ var CheckRecord = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_record", map[string]any{ + data, err := rt.CallMCPData(serverWukong, "query_check_record", map[string]any{ "QueryCheckRecordRequest": map[string]any{ "userIds": rt.StrSlice("users"), "checkDateFrom": from, "checkDateTo": to, }, }) + if err != nil { + return err + } + operation := serverWukong + "/query_check_record" + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(items, operation, "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil { + return err + } + return attendanceOutputCollection(rt, "records", items, true, nil, false, "") }, } @@ -319,10 +403,47 @@ var ListApprove = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "--types 不能为空,映射后的审批类型不能重复;overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, Description: "--types 不能为空,映射后的审批类型不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + seen := map[int]struct{}{} + for _, value := range rt.StrSlice("types") { + mapped, ok := approveTypeMapping[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return fmt.Errorf("无效的审批类型: %s", value) + } + if _, duplicate := seen[mapped]; duplicate { + return fmt.Errorf("审批类型不能重复") + } + seen[mapped] = struct{}{} + } + if len(seen) == 0 { + return fmt.Errorf("--types 不能为空") + } + return nil }, Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -346,14 +467,25 @@ var ListApprove = shortcut.Shortcut{ } bizTypes = append(bizTypes, bizType) } - return rt.CallMCP("query_user_approve", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_user_approve", map[string]any{ "QueryUserApproveRequest": map[string]any{ "userIds": rt.StrSlice("users"), "bizTypes": bizTypes, "fromDate": from, "toDate": to, }, - }) + }, "approvals", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_user_approve", "id"); err != nil { + return err + } + requestedTypes := make(map[int]struct{}, len(bizTypes)) + for _, value := range bizTypes { + requestedTypes[value] = struct{}{} + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) + }, "result") }, } @@ -405,9 +537,11 @@ var GetApproveTemplate = shortcut.Shortcut{ default: return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input) } - return rt.CallMCP("query_at_approve_template", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, - }) + }, "templates", true, nil, func(items []map[string]any) error { + return attendanceValidateApproveTemplates(items, serverWukong+"/query_at_approve_template", approveType) + }, "result") }, } @@ -450,9 +584,31 @@ var GetSchedule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dateToMillis(rt.Str("start"), false) + if err != nil { + return err + } + end, err := dateToMillis(rt.Str("end"), true) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil }, Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -464,13 +620,18 @@ var GetSchedule = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("getScheduleByRange", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "getScheduleByRange", map[string]any{ "GetScheduleByRangeRequest": map[string]any{ "userIdList": rt.StrSlice("users"), "workDateBegin": begin, "workDateEnd": end, }, - }) + }, "schedules", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/getScheduleByRange", "id"); err != nil { + return err + } + return attendanceValidateUserAndTimeBinding(items, serverWukong+"/getScheduleByRange", rt.StrSlice("users"), "userId", "workDate", begin, end) + }, "result") }, } @@ -560,18 +721,21 @@ var SearchClass = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"}, {Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, Execute: func(rt *shortcut.RuntimeContext) error { - pageQuery := map[string]any{} - if v := rt.Int("page"); v > 0 { - pageQuery["pageIndex"] = v - } - if v := rt.Int("limit"); v > 0 { - pageQuery["pageSize"] = v + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } + pageQuery := map[string]any{"pageIndex": page, "pageSize": limit} shiftParam := map[string]any{} if v := rt.Str("query"); v != "" { shiftParam["searchName"] = v @@ -590,69 +754,64 @@ var SearchClass = shortcut.Shortcut{ if err != nil { return err } - classes := searchClassProject(data) - return rt.Output(map[string]any{"count": len(classes), "classes": classes}) + classes, err := searchClassProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit, len(classes)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "classes", classes, complete, extra, true, nextToken) }, } // searchClassProject reshapes the raw get_class_list response into a clean class // list ({classId, name, ownerName}) — clean output projection. Both // the list container and per-item field names are probed defensively across -// candidate keys, so an unknown/empty shape yields an empty list rather than a -// crash or fabricated data. -func searchClassProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "classList", "shiftList", "list", "items") +// result.items is the reviewed wire path. Missing/wrong collections and bad +// rows fail closed instead of becoming an empty list. +func searchClassProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_class_list", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, item := range raw { + m := item // get_class_list wraps each shift's identity/label under shiftVO // (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every // row projects empty and the whole command silently returns nothing. - if vo, ok := m["shiftVO"].(map[string]any); ok { + if value, present := m["shiftVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "malformed_item", fmt.Sprintf("result.items[%d].shiftVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { - row["classId"] = v + v, ok := attendanceFirst(m, "id", "classId", "class_id") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index)) } + row["classId"] = identity if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok { row["ownerName"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// attendanceResolveList locates the list payload inside a response, tolerating a -// bare top-level array under a common container key or nesting one level deeper -// under result/data. extraKeys are probed ahead of the generic containers. -func attendanceResolveList(data map[string]any, extraKeys ...string) []any { - keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items") - for _, key := range keys { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } + if err := attendanceValidatePositiveIntegerIDs(out, serverWukong+"/get_class_list", "classId"); err != nil { + return nil, err } - return []any{} + return out, nil } // attendanceFirst returns the first present candidate key's value. @@ -673,14 +832,58 @@ var GetClass = shortcut.Shortcut{ Description: "根据班次 ID 查询班次详情", Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "attendance", + Name: "shortcut_get_class", + CanonicalPath: "attendance.shortcut_get_class", + CLIPath: "attendance +get-class", + PrimaryCLIPath: "attendance +get-class", + }, + Description: "根据班次 ID 查询班次详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns strict response validation and output projection for this attendance detail operation.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据班次 ID 查询班次详情", + UseWhen: []string{"当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。"}, + AvoidWhen: []string{"不知道班次 ID 时先用 +search-class;不要用本读取命令代替创建或更新班次。"}, + Examples: []string{"dws attendance +get-class --class-id 12345"}, + }, + }, Flags: []shortcut.Flag{ - {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + {Name: "class-id", Type: shortcut.FlagInt, Desc: "--class-id 必须大于 0,表示班次 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"class-id"}, Description: "--class-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("class-id") <= 0 { + return fmt.Errorf("--class-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-class --class-id 1170996821`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_class_detail", map[string]any{ - "classId": int64(rt.Int("class-id")), - }) + requested := int64(rt.Int("class-id")) + data, err := rt.CallMCPData(serverWukong, "get_class_detail", map[string]any{"classId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_class_detail") + if err != nil { + return err + } + identityValue, ok := attendanceNestedValue(value, "shiftVO", "id") + identity, valid := attendancePositiveInteger(identityValue) + if !ok || !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_class_detail", "object_identity_mismatch", "响应 shiftVO.id 与请求 classId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -784,9 +987,9 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Description: "根据补卡规则主键 ID 查询补卡规则详情", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情", @@ -800,7 +1003,7 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_adjustment_rule_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_adjustment_rule_detail", map[string]any{ "adjustmentId": int64(rt.Int("adjustment-id")), }) }, @@ -812,7 +1015,7 @@ var SearchAdjustmentRule = shortcut.Shortcut{ Command: "+search-adjustment-rule", Product: serverWukong, Description: "查询当前用户可管理的补卡规则列表", - Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。", + Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -834,29 +1037,30 @@ var SearchAdjustmentRule = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的补卡规则列表", - UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。"}, + AvoidWhen: []string{"需要完整补卡规则详情时应报告当前下游详情能力 unavailable,不要把搜索摘要或空 result 当作详情成功"}, Examples: []string{"dws attendance +search-adjustment-rule --query \"标准\" --page 1 --limit 50"}, }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -866,44 +1070,62 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_adjustment_rule", "result.adjustmentList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } // searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule -// response into a clean rule list ({ruleId, name}) — output-projection fidelity -// for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchRuleProject(data map[string]any) []map[string]any { +// response into a clean rule list ({ruleId, name}). Each caller supplies the +// single reviewed collection path; missing/wrong collections and malformed +// rows fail closed instead of becoming an empty list. +func searchRuleProject(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { // get_overtime_rule nests the list under result.atRuleList and // get_adjustment_rule under result.adjustmentList; both wrap each rule's // identity/label under entityVO. Probe those container keys AND unwrap // entityVO, otherwise +search-overtime-rule / +search-adjustment-rule // silently return empty despite the backend returning rules. - raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items") + raw, err := responsecheck.RequireObjectCollection(data, operation, paths...) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if vo, ok := m["entityVO"].(map[string]any); ok { + for index, item := range raw { + m := item + if value, present := m["entityVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("规则结果第 %d 项 entityVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { - row["ruleId"] = v + v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index)) } + row["ruleId"] = identity if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { row["name"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + if err := attendanceValidatePositiveIntegerIDs(out, operation, "ruleId"); err != nil { + return nil, err + } + return out, nil } // ── overtime rule ─────────────────────────────────────────── @@ -942,13 +1164,31 @@ var GetOvertimeRule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true}, + {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "--overtime-id 必须大于 0,表示加班规则主键 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"overtime-id"}, Description: "--overtime-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("overtime-id") <= 0 { + return fmt.Errorf("--overtime-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_overtime_rule_detail", map[string]any{ - "overtimeId": int64(rt.Int("overtime-id")), - }) + requested := int64(rt.Int("overtime-id")) + data, err := rt.CallMCPData(serverWukong, "get_overtime_rule_detail", map[string]any{"overtimeId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_overtime_rule_detail") + if err != nil { + return err + } + identity, valid := attendancePositiveInteger(value["id"]) + if !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_overtime_rule_detail", "object_identity_mismatch", "响应 id 与请求 overtimeId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -987,22 +1227,23 @@ var SearchOvertimeRule = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -1012,8 +1253,19 @@ var SearchOvertimeRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_overtime_rule", "result.atRuleList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } @@ -1041,9 +1293,9 @@ var SearchGroup = shortcut.Shortcut{ }, Description: "查询当前用户可管理的考勤组列表", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的考勤组列表", @@ -1079,13 +1331,9 @@ var SearchGroup = shortcut.Shortcut{ searchParam["queryPositionAndWifiNames"] = false searchParam["queryBleDeviceList"] = false } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{ "param": searchParam, @@ -1097,39 +1345,44 @@ var SearchGroup = shortcut.Shortcut{ if err != nil { return err } - groups := searchGroupProject(data) - return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + groups, err := searchGroupProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit, len(groups)) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("groups", groups, complete, extra)) }, } // searchGroupProject reshapes the raw get_simple_groups response into a clean // attendance-group list ({groupId, name, type}) — clean output projection. -// The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchGroupProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "groupList", "groups", "list", "items") +// The exact result.items collection and each row identity are required, so an +// unknown shape cannot masquerade as a legitimate empty search result. +func searchGroupProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_simple_groups", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, m := range raw { row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok { - row["groupId"] = v + v, ok := attendanceFirst(m, "id", "groupId", "group_id") + if !ok || v == nil { + return nil, responsecheck.Error(serverWukong+"/get_simple_groups", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少考勤组 ID", index)) } + row["groupId"] = v if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok { row["type"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + return out, nil } // GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。 @@ -1379,7 +1632,7 @@ var GetSummary = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("get_user_attendance_summary", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_user_attendance_summary", map[string]any{ "userId": rt.Str("user"), "queryDate": queryDate, "statsType": rt.Str("stats-type"), @@ -1427,19 +1680,80 @@ var GetSelfSetting = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, - {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "--user 不能为空,表示查询用户 userId", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + return nil }, Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_self_setting", map[string]any{ + userID := rt.Str("user") + scene := rt.Str("setting-scene") + data, err := rt.CallMCPData(serverWukong, "query_self_setting", map[string]any{ "RuleMcpQuerySelfSettingRequest": map[string]any{ - "settingScene": rt.Str("setting-scene"), - "userId": rt.Str("user"), + "settingScene": scene, + "userId": userID, }, }) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/query_self_setting") + if err != nil { + return err + } + if err := attendanceValidateSelfSetting(value, userID, scene); err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) }, } +func attendanceValidateSelfSetting(value map[string]any, requestedUser, scene string) error { + operation := serverWukong + "/query_self_setting" + requestedUser = strings.TrimSpace(requestedUser) + if requestedUser == "" { + return responsecheck.Error(operation, "object_identity_mismatch", "请求 userId 不能为空") + } + returnedUser, ok := value["userId"].(string) + if !ok || returnedUser == "" || returnedUser != requestedUser { + return responsecheck.Error(operation, "object_identity_mismatch", "响应 userId 与请求用户不一致") + } + type sceneContract struct { + field string + valid func(any) bool + } + contracts := map[string]sceneContract{ + "checkRemind": {field: "checkRemindSetting", valid: func(v any) bool { _, ok := v.(map[string]any); return ok }}, + "fastCheck": {field: "fastCheckLateNeedConfirm", valid: func(v any) bool { _, ok := v.(bool); return ok }}, + "checkResultNotify": {field: "checkResultMsg", valid: attendanceSettingInteger}, + "lackRemind": {field: "lackRemindUser", valid: attendanceSettingInteger}, + "personalAttendStatNotify": {field: "personDailyReportSwitch", valid: attendanceSettingInteger}, + "bossAttendStatNotify": {field: "bossMonthReportType", valid: attendanceSettingInteger}, + } + expected, ok := contracts[scene] + if !ok { + return fmt.Errorf("无效的 --setting-scene: %s", scene) + } + setting, present := value[expected.field] + if !present || setting == nil { + return responsecheck.Error(operation, "setting_scene_mismatch", "响应缺少请求场景对应的非空设置字段") + } + if !expected.valid(setting) { + return responsecheck.Error(operation, "malformed_setting", "响应中请求场景的设置字段类型不正确") + } + return nil +} + +func attendanceSettingInteger(value any) bool { + number, ok := value.(float64) + return ok && !math.IsNaN(number) && !math.IsInf(number, 0) && math.Trunc(number) == number +} + // ── global setting ────────────────────────────────────────── // GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。 @@ -1502,9 +1816,9 @@ var QueryReportData = shortcut.Shortcut{ }, Description: "根据字段查询考勤报表数据(仅管理员)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据字段查询考勤报表数据(仅管理员)", @@ -1541,7 +1855,7 @@ var QueryReportData = shortcut.Shortcut{ } columnIds = append(columnIds, id) } - return rt.CallMCP("get_report_columns_value", map[string]any{ + return attendanceCallValue(rt, serverWukong, "get_report_columns_value", map[string]any{ "McpQueryParam": map[string]any{ "targetUserIds": rt.StrSlice("users"), "columnIds": columnIds, @@ -1624,9 +1938,11 @@ var ListLeaveTypes = shortcut.Shortcut{ }, Tips: []string{`dws attendance +list-leave-types`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_leave_types", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{ "McpLeaveTypeRequest": map[string]any{}, - }) + }, "leaveTypes", true, nil, func(items []map[string]any) error { + return attendanceValidateExpectedStrings(items, serverWukong+"/get_leave_types", "leaveCode", "") + }, "result") }, } @@ -1695,6 +2011,27 @@ var GetLeaveRecords = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 去空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 YYYY-MM-DD 且 end 不早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil + }, Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`}, Execute: func(rt *shortcut.RuntimeContext) error { start, err := dayMillis(rt.Str("start")) @@ -1713,9 +2050,9 @@ var GetLeaveRecords = shortcut.Shortcut{ if v := rt.Str("leave-code"); v != "" { req["leaveCode"] = v } - return rt.CallMCP("get_leave_balance_records_v2", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{ "McpLeaveRecordRequest": req, - }) + }, "records", true, nil, nil, "result") }, } @@ -1879,6 +2216,33 @@ var GetCheckinRecord = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "--staff-ids 去空白后必须为 1..100 个且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 yyyy-MM-dd HH:mm:ss,end 不早于 start,且跨度不超过 7 天"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("staff-ids"), 100); err != nil { + return err + } + if rt.Str("operator-corp-id") == "" || rt.Str("operator-staff-id") == "" { + return fmt.Errorf("--operator-corp-id 与 --operator-staff-id 不能为空") + } + start, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("start"), time.Local) + if err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("end"), time.Local) + if err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.Sub(start) > 7*24*time.Hour { + return fmt.Errorf("--start 到 --end 的跨度不能超过 7 天") + } + return nil + }, Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`}, Execute: func(rt *shortcut.RuntimeContext) error { startStr := rt.Str("start") @@ -1894,7 +2258,7 @@ var GetCheckinRecord = shortcut.Shortcut{ if endT.Before(startT) { return fmt.Errorf("--end 不能早于 --start") } - return rt.CallMCP("get_checkin_record", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_checkin_record", map[string]any{ "QueryMcpUserRecordRequest": map[string]any{ "operatorCorpId": rt.Str("operator-corp-id"), "operatorStaffId": rt.Str("operator-staff-id"), @@ -1902,7 +2266,7 @@ var GetCheckinRecord = shortcut.Shortcut{ "startTime": startStr, "endTime": endStr, }, - }) + }, "records", true, nil, nil, "result.list") }, } @@ -1954,6 +2318,7 @@ var BossCheck = shortcut.Shortcut{ } func init() { + hardenPublicAttendanceContracts() shortcut.Register( CheckResult, CheckRecord, diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go new file mode 100644 index 00000000..53d19d38 --- /dev/null +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -0,0 +1,1176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func attendanceRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +type attendanceCoverageCaller struct { + text string + err error + calls int + tools []string + arguments []map[string]any +} + +func (caller *attendanceCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.tools = append(caller.tools, tool) + caller.arguments = append(caller.arguments, arguments) + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.text}}}, nil +} + +func (*attendanceCoverageCaller) Format() string { return "json" } +func (*attendanceCoverageCaller) DryRun() bool { return false } +func (*attendanceCoverageCaller) Fields() string { return "" } +func (*attendanceCoverageCaller) JQ() string { return "" } + +func executeAttendanceResponse(t *testing.T, declaration shortcut.Shortcut, values map[string]string, response string) (*attendanceCoverageCaller, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: response} + helpers.InitDepsForTest(t, caller) + err := declaration.Execute(attendanceRuntimeForTest(t, declaration, values)) + return caller, err +} + +func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { + userCases := []struct { + name string + items []map[string]any + valid bool + }{ + {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, + {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false}, + {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false}, + {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, + {"missing time", []map[string]any{{"userId": "u1"}}, false}, + {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, + {"before range", []map[string]any{{"userId": "u1", "workDate": float64(899)}}, false}, + {"after range", []map[string]any{{"userId": "u1", "workDate": float64(1101)}}, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("user/time %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + + approvalCases := []struct { + name string + item map[string]any + valid bool + }{ + {"valid", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, true}, + {"missing user", map[string]any{"bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong user", map[string]any{"userId": "u2", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"missing type", map[string]any{"userId": "u1", "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, + {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true}, + {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true}, + {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, + } + for _, tc := range approvalCases { + err := attendanceValidateApprovalBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, map[int]struct{}{3: {}}, 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("approval %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}}, "attendance/test", "approveType", "OVERTIME"); err == nil { + t.Fatal("expected string mismatch was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) { + const startMillis, endMillis = int64(1000), int64(2000) + items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}} + if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil { + t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err) + } + + invalid := []struct { + name string + item map[string]any + reason string + }{ + {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"}, + {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"}, + {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"}, + {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"}, + {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"}, + {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || !ok || typed.Reason != tc.reason { + t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + startMillis, _ := dayMillis(values["start"]) + execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(CheckRecord)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + err := CheckRecord.Execute(shortcut.RuntimeContextForTest(cmd, CheckRecord)) + if err != nil { + return nil, err + } + if caller.calls != 1 { + t.Fatalf("calls=%d", caller.calls) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%t err=%v", code, emitted, err) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope, nil + } + + t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) { + envelope, err := execute(t, []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000}, + {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis}, + }) + if err != nil { + t.Fatal(err) + } + data, ok := envelope["data"].(map[string]any) + records, recordsOK := data["records"].([]any) + if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) { + t.Fatalf("data=%#v", envelope["data"]) + } + }) + + t.Run("actual-time overfetch fails closed", func(t *testing.T) { + if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil { + t.Fatal("out-of-range actual check was silently filtered") + } + }) + + invalid := []struct { + name string + records []map[string]any + }{ + {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}}, + {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if _, err := execute(t, tc.records); err == nil { + t.Fatal("invalid raw overfetch was hidden") + } + }) + } + + t.Run("downstream failure", func(t *testing.T) { + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckRecord.Execute(attendanceRuntimeForTest(t, CheckRecord, values)); err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("missing collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, CheckRecord, values, `{"success":true,"result":null}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) { + detailCases := []struct { + name string + declaration shortcut.Shortcut + flags map[string]string + valid string + invalid []string + }{ + { + name: "class", + declaration: GetClass, + flags: map[string]string{"class-id": "7"}, + valid: `{"success":true,"result":{"shiftVO":{"id":7}}}`, + invalid: []string{ + `{"result":{"shiftVO":{"id":7}}}`, + `{"success":false,"result":{"shiftVO":{"id":7}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"shiftVO":{"id":8}}}`, + }, + }, + { + name: "overtime", + declaration: GetOvertimeRule, + flags: map[string]string{"overtime-id": "9"}, + valid: `{"success":true,"result":[{"id":9}]}`, + invalid: []string{ + `{"result":[{"id":9}]}`, + `{"success":false,"result":[{"id":9}]}`, + `{"success":true,"result":null}`, + `{"success":true,"result":["bad"]}`, + `{"success":true,"result":[{"id":10}]}`, + }, + }, + } + for _, detail := range detailCases { + t.Run(detail.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, detail.valid) + if err != nil || caller.calls != 1 { + t.Fatalf("valid detail err=%v calls=%d", err, caller.calls) + } + }) + for index, response := range detail.invalid { + t.Run(fmt.Sprintf("%s invalid %d", detail.name, index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid detail err=%v calls=%d", err, caller.calls) + } + }) + } + } +} + +func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) { + invalid := []string{ + `{"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":false,"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"userId":"other","checkRemindSetting":{}}}`, + `{"success":true,"result":{"userId":"user"}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":null}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":true}}`, + } + for index, response := range invalid { + t.Run(fmt.Sprintf("invalid %d", index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": "user", "setting-scene": "checkRemind"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid self setting err=%v calls=%d", err, caller.calls) + } + }) + } + t.Run("normalizes user once", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": " user ", "setting-scene": "checkRemind"}, `{"success":true,"result":{"userId":"user","checkRemindSetting":{}}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("normalized self setting err=%v calls=%d", err, caller.calls) + } + request, ok := caller.arguments[0]["RuleMcpQuerySelfSettingRequest"].(map[string]any) + if !ok || request["userId"] != "user" { + t.Fatalf("normalized request = %#v", caller.arguments[0]) + } + }) + + validTypes := []struct { + scene string + field string + value any + }{ + {"checkRemind", "checkRemindSetting", map[string]any{}}, + {"fastCheck", "fastCheckLateNeedConfirm", false}, + {"checkResultNotify", "checkResultMsg", float64(0)}, + {"lackRemind", "lackRemindUser", float64(1)}, + {"personalAttendStatNotify", "personDailyReportSwitch", float64(1)}, + {"bossAttendStatNotify", "bossMonthReportType", float64(3)}, + } + for _, item := range validTypes { + value := map[string]any{"userId": "user", item.field: item.value} + if err := attendanceValidateSelfSetting(value, "user", item.scene); err != nil { + t.Errorf("scene %s rejected observed type: %v", item.scene, err) + } + } + +} + +func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { + expected := map[*shortcut.Shortcut]map[string]string{ + &CheckResult: {"users": "users", "start": "start", "end": "end", "offset": "offset", "limit": "limit"}, + &CheckRecord: {"users": "users", "start": "start", "end": "end"}, + &ListApprove: {"users": "users", "types": "types", "start": "start", "end": "end"}, + &GetApproveTemplate: {"type": "type"}, + &GetSchedule: {"users": "users", "start": "start", "end": "end"}, + &SearchClass: {"query": "query", "filter-type": "filterType", "page": "page", "limit": "limit"}, + &GetClass: {"class-id": "classId"}, + &SearchAdjustmentRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetOvertimeRule: {"overtime-id": "overtimeId"}, + &SearchOvertimeRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetSelfSetting: {"setting-scene": "settingScene", "user": "user"}, + } + for declaration, want := range expected { + got := make(map[string]string, len(declaration.Contract.Parameters)) + for _, parameter := range declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if len(got) != len(want) { + t.Errorf("%s parameter count=%d want=%d: %#v", declaration.Command, len(got), len(want), got) + } + for name, property := range want { + if got[name] != property { + t.Errorf("%s --%s property=%q want=%q", declaration.Command, name, got[name], property) + } + } + } +} + +func TestCrossPlatformCoverageAttendanceRuntimeConstraintsFailBeforeCall(t *testing.T) { + manyUsers := make([]string, 101) + for index := range manyUsers { + manyUsers[index] = fmt.Sprintf("u%d", index) + } + cases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {"check result too many users", CheckResult, map[string]string{"users": strings.Join(manyUsers, ","), "start": "2026-01-01", "end": "2026-01-31"}}, + {"check result reversed dates", CheckResult, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"check result over month", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-02-02"}}, + {"check result bad limit", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1001"}}, + {"check result bad offset", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}}, + {"check record duplicate users", CheckRecord, map[string]string{"users": "u1,u1", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "types": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve reversed dates", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-02-01", "end": "2026-01-31"}}, + {"schedule empty users", GetSchedule, map[string]string{"users": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"schedule reversed dates", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"search class page zero", SearchClass, map[string]string{"page": "0"}}, + {"search adjustment limit too large", SearchAdjustmentRule, map[string]string{"limit": "201"}}, + {"search overtime limit zero", SearchOvertimeRule, map[string]string{"limit": "0"}}, + {"class id zero", GetClass, map[string]string{"class-id": "0"}}, + {"overtime id zero", GetOvertimeRule, map[string]string{"overtime-id": "0"}}, + {"leave records reversed dates", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"checkin duplicate users", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1,u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}}, + {"checkin over seven days", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-08 00:00:01"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no Validate", tc.declaration.Command) + } + if err := tc.declaration.Validate(rt); err == nil { + t.Fatalf("invalid values accepted for %s", tc.declaration.Command) + } + if caller.calls != 0 { + t.Fatalf("%s made %d downstream calls before validation", tc.declaration.Command, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendancePaginationLivesInEnvelopeMeta(t *testing.T) { + emit := func(complete bool, nextToken string) map[string]any { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(CheckResult)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, CheckResult) + items := []map[string]any{{"id": int64(1), "userId": "redacted", "workDate": int64(1)}} + if err := attendanceOutputCollection(rt, "records", items, complete, map[string]any{"limit": 1, "nextOffset": 1}, true, nextToken); err != nil { + t.Fatal(err) + } + if _, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted { + t.Fatalf("emit err=%v emitted=%t", err, emitted) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope + } + + envelope := emit(false, "1") + data, ok := envelope["data"].(map[string]any) + if !ok || data["count"] != float64(1) { + t.Fatalf("business data=%#v", envelope["data"]) + } + for _, forbidden := range []string{"complete", "limit", "nextOffset", "nextPage", "totalCount", "totalPage"} { + if _, present := data[forbidden]; present { + t.Errorf("pagination field %q leaked into business data", forbidden) + } + } + meta, ok := envelope["meta"].(map[string]any) + if !ok { + t.Fatalf("meta=%#v", envelope["meta"]) + } + pagination, ok := meta["pagination"].(map[string]any) + if !ok || pagination["endpoint_exhausted"] != false || pagination["next_token"] != "1" { + t.Fatalf("pagination=%#v", meta["pagination"]) + } + exhaustedEnvelope := emit(true, "") + exhaustedMeta := exhaustedEnvelope["meta"].(map[string]any) + exhaustedPagination := exhaustedMeta["pagination"].(map[string]any) + if exhaustedPagination["endpoint_exhausted"] != true { + t.Fatalf("exhausted pagination=%#v", exhaustedPagination) + } + if _, present := exhaustedPagination["next_token"]; present { + t.Fatalf("exhausted pagination published next_token: %#v", exhaustedPagination) + } +} + +func TestCrossPlatformCoverageAttendanceCommonCallersFailClosed(t *testing.T) { + tests := []struct { + name string + run func(*shortcut.RuntimeContext) error + text string + err error + want bool + }{ + { + name: "collection downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "collection malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + text: `{"success":true,"result":null}`, + want: false, + }, + { + name: "collection validator", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, func([]map[string]any) error { + return fmt.Errorf("binding mismatch") + }, "result") + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "collection success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, map[string]any{"source": "test"}, nil, "result") + }, + text: `{"success":true,"result":[]}`, + want: true, + }, + { + name: "value downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "value malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true}`, + want: false, + }, + { + name: "value success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true,"result":false}`, + want: true, + }, + { + name: "object downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "object malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "object success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":{"id":1}}`, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: tc.text, err: tc.err} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, CheckResult, nil) + err := tc.run(rt) + if (err == nil) != tc.want || caller.calls != 1 { + t.Fatalf("err=%v calls=%d wantSuccess=%t", err, caller.calls, tc.want) + } + }) + } + + t.Run("legacy output", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, ListLeaveTypes, nil) + if err := attendanceOutputCollection(rt, "items", []map[string]any{{"leaveCode": "code"}}, true, map[string]any{"source": "legacy"}, false, ""); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid continuation", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, CheckResult, nil) + if err := attendanceOutputCollection(rt, "records", nil, false, nil, true, ""); err == nil { + t.Fatal("non-terminal pagination without next token was accepted") + } + }) +} + +func TestCrossPlatformCoverageAttendanceIdentityAndInputHelpers(t *testing.T) { + if value, ok := attendanceNestedValue(map[string]any{"outer": map[string]any{"id": int32(7)}}, "outer", "id"); !ok || value != int32(7) { + t.Fatalf("nested identity=%#v ok=%t", value, ok) + } + for _, path := range [][]string{{"missing"}, {"outer", "id"}} { + item := map[string]any{"outer": "not-an-object"} + if _, ok := attendanceNestedValue(item, path...); ok { + t.Fatalf("invalid nested path accepted: %#v", path) + } + } + + positive := []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} + for _, value := range positive { + if _, ok := attendancePositiveInteger(value); !ok { + t.Errorf("positive identity rejected: %#v", value) + } + } + invalid := []any{int(0), int32(-1), int64(0), 1.5, json.Number("bad"), "1", nil} + for _, value := range invalid { + if _, ok := attendancePositiveInteger(value); ok { + t.Errorf("invalid identity accepted: %#v", value) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"nested": map[string]any{"id": int32(1)}}}, "attendance/test", "nested", "id"); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"id": 0}}, + {{"id": 1}, {"id": 1}}, + } { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid identity collection accepted: %#v", items) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"code": " A "}, {"code": "B"}}, "attendance/test", "code", ""); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"code": 1}}, + {{"code": " "}}, + {{"code": "A"}, {"code": "A"}}, + } { + if err := attendanceValidateExpectedStrings(items, "attendance/test", "code", ""); err == nil { + t.Fatalf("invalid string identity collection accepted: %#v", items) + } + } + + userCases := []struct { + values []string + maximum int + valid bool + }{ + {[]string{"u1"}, 1, true}, + {nil, 1, false}, + {[]string{"u1", "u2"}, 1, false}, + {[]string{""}, 1, false}, + {[]string{" u1"}, 1, false}, + {[]string{"u1", "u1"}, 2, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserIDs(tc.values, tc.maximum) + if (err == nil) != tc.valid { + t.Errorf("users=%#v maximum=%d err=%v", tc.values, tc.maximum, err) + } + } + + for _, tc := range []struct { + start string + end string + valid bool + }{ + {"2026-01-01", "2026-02-01", true}, + {"bad", "2026-01-01", false}, + {"2026-01-01", "bad", false}, + {"2026-02-01", "2026-01-01", false}, + {"2026-01-01", "2026-02-02", false}, + } { + err := attendanceValidateMonthRange(tc.start, tc.end) + if (err == nil) != tc.valid { + t.Errorf("month range %q..%q err=%v", tc.start, tc.end, err) + } + } +} + +func TestCrossPlatformCoverageAttendancePaginationEvidenceBoundaries(t *testing.T) { + tests := []struct { + name string + data map[string]any + page int + limit int + itemCount int + complete bool + valid bool + }{ + {"invalid request", map[string]any{"success": true, "result": map[string]any{}}, 0, 10, 0, false, false}, + {"malformed result", map[string]any{"success": true, "result": []any{}}, 1, 10, 0, false, false}, + {"current page type", map[string]any{"success": true, "result": map[string]any{"currentPage": "1", "totalPage": 1}}, 1, 10, 0, false, false}, + {"current page mismatch", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 1}}, 1, 10, 0, false, false}, + {"total page type", map[string]any{"success": true, "result": map[string]any{"totalPage": "1"}}, 1, 10, 0, false, false}, + {"negative total page", map[string]any{"success": true, "result": map[string]any{"totalPage": -1}}, 1, 10, 0, false, false}, + {"zero pages invalid current page", map[string]any{"success": true, "result": map[string]any{"totalPage": 0}}, 2, 10, 0, false, false}, + {"page out of range", map[string]any{"success": true, "result": map[string]any{"totalPage": 1}}, 2, 10, 0, false, false}, + {"total count type", map[string]any{"success": true, "result": map[string]any{"totalCount": "1"}}, 1, 10, 0, false, false}, + {"negative total count", map[string]any{"success": true, "result": map[string]any{"totalCount": -1}}, 1, 10, 0, false, false}, + {"empty with nonzero total", map[string]any{"success": true, "result": map[string]any{"totalCount": 1}}, 1, 10, 0, false, false}, + {"count before page", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 2, 10, 0, false, false}, + {"items exceed total", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 1, 10, 6, false, false}, + {"missing evidence", map[string]any{"success": true, "result": map[string]any{}}, 1, 10, 0, false, false}, + {"conflicting evidence", map[string]any{"success": true, "result": map[string]any{"totalPage": 2, "totalCount": 5}}, 1, 10, 5, false, false}, + {"total mismatch", map[string]any{"success": true, "result": map[string]any{"totalPage": 3, "totalCount": 15}}, 1, 10, 10, false, false}, + {"no progress", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 0, false, false}, + {"short page", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 5, false, false}, + {"terminal count mismatch", map[string]any{"success": true, "result": map[string]any{"totalCount": 15}}, 2, 10, 4, false, false}, + {"empty terminal", map[string]any{"success": true, "result": map[string]any{"totalPage": 0, "totalCount": 0}}, 1, 10, 0, true, true}, + {"full nonterminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 1, "totalPage": 2, "totalCount": 20}}, 1, 10, 10, false, true}, + {"short terminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 2, "totalCount": 15}}, 2, 10, 5, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + complete, extra, err := attendancePageEvidence(tc.data, "attendance/test", tc.page, tc.limit, tc.itemCount) + if (err == nil) != tc.valid { + t.Fatalf("err=%v extra=%#v", err, extra) + } + if tc.valid && complete != tc.complete { + t.Fatalf("complete=%t want=%t extra=%#v", complete, tc.complete, extra) + } + if tc.valid && !tc.complete && extra["nextPage"] != tc.page+1 { + t.Fatalf("nonterminal extra=%#v", extra) + } + }) + } + + for _, tc := range []struct { + page string + limit string + valid bool + }{ + {"1", "200", true}, + {"0", "20", false}, + {"1", "0", false}, + {"1", "201", false}, + } { + rt := attendanceRuntimeForTest(t, SearchClass, map[string]string{"page": tc.page, "limit": tc.limit}) + page, limit, err := attendancePageInput(rt) + if (err == nil) != tc.valid { + t.Errorf("page=%s limit=%s got=(%d,%d) err=%v", tc.page, tc.limit, page, limit, err) + } + if err2 := attendanceValidatePageRequest(rt); (err2 == nil) != tc.valid { + t.Errorf("validator page=%s limit=%s err=%v", tc.page, tc.limit, err2) + } + } + + for _, tc := range []struct { + value any + want int + valid bool + }{ + {int(1), 1, true}, {int32(2), 2, true}, {int64(3), 3, true}, {float64(4), 4, true}, + {float64(1.5), 0, false}, {"1", 0, false}, + } { + got, ok := attendanceInt(tc.value) + if got != tc.want || ok != tc.valid { + t.Errorf("attendanceInt(%#v)=(%d,%t), want (%d,%t)", tc.value, got, ok, tc.want, tc.valid) + } + } +} + +func attendanceResponseJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid bool + }{ + {"check result valid", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "0"}, true}, + {"check record valid", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"approve empty users", ListApprove, map[string]string{"types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid start", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "bad", "end": "2026-01-31"}, false}, + {"approve invalid end", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "bad"}, false}, + {"approve duplicate mapped type", ListApprove, map[string]string{"users": "u1", "types": "leave,请假", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid type", ListApprove, map[string]string{"users": "u1", "types": "invalid", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve valid", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"schedule invalid start", GetSchedule, map[string]string{"users": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"schedule invalid end", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"schedule reverse", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}, false}, + {"schedule valid", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"class valid", GetClass, map[string]string{"class-id": "1"}, true}, + {"overtime valid", GetOvertimeRule, map[string]string{"overtime-id": "1"}, true}, + {"self setting empty user", GetSelfSetting, map[string]string{"user": "", "setting-scene": "checkRemind"}, false}, + {"self setting valid", GetSelfSetting, map[string]string{"user": "u1", "setting-scene": "checkRemind"}, true}, + {"leave records empty user", GetLeaveRecords, map[string]string{"user": "", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"leave records invalid start", GetLeaveRecords, map[string]string{"user": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"leave records invalid end", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"leave records valid", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"checkin missing operator", GetCheckinRecord, map[string]string{"operator-corp-id": "", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid start", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "bad", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid end", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "bad"}, false}, + {"checkin reverse", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-02 00:00:00", "end": "2026-01-01 00:00:00"}, false}, + {"checkin valid", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no validator", tc.declaration.Command) + } + err := tc.declaration.Validate(rt) + if (err == nil) != tc.valid { + t.Fatalf("err=%v wantValid=%t", err, tc.valid) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { + startMillis, _ := dayMillis("2026-01-01") + endStartMillis, _ := dayMillis("2026-01-31") + endMillis, _ := dateToMillis("2026-01-31", true) + nextDayStartMillis, _ := dayMillis("2026-02-01") + finalDayNoon := endStartMillis + 12*60*60*1000 + validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + if endMillis != nextDayStartMillis-1 { + t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1) + } + explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true) + explicitWant, _ := flexMillis("2026-01-31 12:34:56") + if err != nil || explicitEnd != explicitWant { + t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err) + } + + t.Run("check result bad execute bounds", func(t *testing.T) { + for _, values := range []map[string]string{ + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "0"}, + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}, + } { + caller, err := executeAttendanceResponse(t, CheckResult, values, `{"success":true,"result":[]}`) + if err == nil || caller.calls != 0 { + t.Fatalf("values=%#v err=%v calls=%d", values, err, caller.calls) + } + } + }) + + t.Run("check result downstream and response failures", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + responses := []string{ + `{"success":true,"result":null}`, + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 0, "userId": "u1", "workDate": startMillis}}}), + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}}), + } + for _, response := range responses { + caller, err := executeAttendanceResponse(t, CheckResult, values, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckResult.Execute(attendanceRuntimeForTest(t, CheckResult, values)); err == nil || caller.calls != 1 { + t.Fatalf("downstream err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("check result short and full pages", func(t *testing.T) { + shortValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2", "offset": "0"} + caller, err := executeAttendanceResponse(t, CheckResult, shortValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("short err=%v calls=%d", err, caller.calls) + } + fullValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "4"} + caller, err = executeAttendanceResponse(t, CheckResult, fullValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("full err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("end date includes the full business day", func(t *testing.T) { + checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon} + caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls) + } + + approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"} + approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000} + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + if err != nil || caller.calls != 1 { + t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls) + } + + checkRow["workDate"] = endMillis + caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls) + } + checkRow["workDate"] = nextDayStartMillis + if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 { + t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls) + } + + approval["beginTime"] = nextDayStartMillis + approval["endTime"] = nextDayStartMillis + 1000 + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" { + t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason) + } + }) + + collectionCases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid map[string]any + invalid map[string]any + }{ + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}}, + {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, + {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + } + t.Run("schedule null responses fail closed", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "literal null", response: `null`, reason: "empty_tool_response"}, + {name: "null collection", response: `{"success":true,"result":null}`, reason: "malformed_collection"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSchedule, values, tc.response) + typed, ok := err.(*apperrors.Error) + actualReason := "" + if ok { + actualReason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || actualReason != tc.reason { + t.Fatalf("err=%v calls=%d reason=%q want=%q", err, caller.calls, actualReason, tc.reason) + } + }) + } + }) + for _, tc := range collectionCases { + t.Run(tc.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid binding", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.invalid}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid identity", func(t *testing.T) { + invalidIdentity := make(map[string]any, len(tc.valid)) + for key, value := range tc.valid { + invalidIdentity[key] = value + } + invalidIdentity["id"] = 0 + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{invalidIdentity}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceSearchProjectorsFailClosed(t *testing.T) { + classValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": map[string]any{"id": 1, "name": "n", "ownerName": "o"}}}}} + classes, err := searchClassProject(classValid) + if err != nil || len(classes) != 1 || classes[0]["classId"] != int64(1) || classes[0]["name"] != "n" || classes[0]["ownerName"] != "o" { + t.Fatalf("class projection=%#v err=%v", classes, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"classId": 1}}}}, + } { + if _, err := searchClassProject(data); err == nil { + t.Fatalf("invalid class projection accepted: %#v", data) + } + } + + ruleValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": map[string]any{"id": 2, "name": "rule"}}}}} + rules, err := searchRuleProject(ruleValid, "attendance/test", "result.items") + if err != nil || len(rules) != 1 || rules[0]["ruleId"] != int64(2) || rules[0]["name"] != "rule" { + t.Fatalf("rule projection=%#v err=%v", rules, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"ruleId": 1}}}}, + } { + if _, err := searchRuleProject(data, "attendance/test", "result.items"); err == nil { + t.Fatalf("invalid rule projection accepted: %#v", data) + } + } + + groupValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"group_id": "g1", "group_name": "group", "group_type": "FIXED"}}}} + groups, err := searchGroupProject(groupValid) + if err != nil || len(groups) != 1 || groups[0]["groupId"] != "g1" || groups[0]["name"] != "group" || groups[0]["type"] != "FIXED" { + t.Fatalf("group projection=%#v err=%v", groups, err) + } + if _, err := searchGroupProject(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}); err == nil { + t.Fatal("group without identity was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceSearchExecutorsPagination(t *testing.T) { + type searchCase struct { + name string + declaration shortcut.Shortcut + tool string + resultKey string + item map[string]any + } + tests := []searchCase{ + {"class", SearchClass, "get_class_list", "items", map[string]any{"shiftVO": map[string]any{"id": 1}}}, + {"adjustment", SearchAdjustmentRule, "get_adjustment_rule", "adjustmentList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + {"overtime", SearchOvertimeRule, "get_overtime_rule", "atRuleList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + } + for _, tc := range tests { + t.Run(tc.name+" invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" malformed collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, `{"success":true,"result":{}}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" missing pagination evidence", func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + for _, page := range []struct { + name string + totalPage int + total int + }{ + {"terminal", 1, 1}, + {"nonterminal", 2, 2}, + } { + t.Run(tc.name+" "+page.name, func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}, "currentPage": 1, "totalPage": page.totalPage, "totalCount": page.total} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + if caller.tools[0] != tc.tool { + t.Fatalf("tool=%q want=%q", caller.tools[0], tc.tool) + } + }) + } + } + + t.Run("group invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run("group malformed and pagination failure", func(t *testing.T) { + for _, response := range []string{ + `{"success":true,"result":{}}`, + `{"success":true,"result":{"items":[{"id":"g1"}]}}`, + } { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"query": "q", "type": "FIXED", "query-position": "true", "query-ble": "false", "page": "1", "limit": "1"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + }) + t.Run("group terminal", func(t *testing.T) { + response := `{"success":true,"result":{"items":[{"id":"g1","name":"group","type":"FIXED"}],"currentPage":1,"totalPage":1,"totalCount":1}}` + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "1", "limit": "1"}, response) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) { + t.Run("approve template exact type", func(t *testing.T) { + for _, tc := range []struct { + value string + response string + valid bool + calls int + }{ + {"invalid", `{"success":true,"result":[]}`, false, 0}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"OVERTIME","submitUrl":"https://example.invalid/1"}]}`, false, 1}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"LEAVE","submitUrl":"https://example.invalid/1"}]}`, true, 1}, + {"TRAVEL", `{"success":true,"result":[{"processCode":"p1","approveType":"TRAVEL","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"TRAVEL","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + {"OUT", `{"success":true,"result":[{"processCode":"p1","approveType":"OUT","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"OUT","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + } { + caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response) + if (err == nil) != tc.valid || caller.calls != tc.calls { + t.Errorf("type=%q err=%v calls=%d wantValid=%t wantCalls=%d", tc.value, err, caller.calls, tc.valid, tc.calls) + } + } + }) + + t.Run("hidden adjustment detail still fails closed", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetAdjustmentRule, map[string]string{"adjustment-id": "7"}, `{"success":true,"result":{"id":7}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("summary object", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSummary, map[string]string{"user": "u1", "date": "2026-01-01", "stats-type": "week"}, `{"success":true,"result":{"days":1}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("self setting helper rejects invalid request and scene", func(t *testing.T) { + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1", "checkRemindSetting": map[string]any{}}, " ", "checkRemind"); err == nil { + t.Fatal("empty requested user accepted") + } + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1"}, "u1", "not-a-scene"); err == nil { + t.Fatal("invalid setting scene accepted") + } + }) + + t.Run("report value", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, QueryReportData, map[string]string{"users": "u1", "columns": "1, 2", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, `{"success":true,"result":false}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave type exact collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, ListLeaveTypes, nil, `{"success":true,"result":[{"leaveCode":"code"}]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave records optional code", func(t *testing.T) { + values := map[string]string{"user": "u1", "leave-code": "code", "start": "2026-01-01", "end": "2026-01-31"} + caller, err := executeAttendanceResponse(t, GetLeaveRecords, values, `{"success":true,"result":[]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("checkin record nested collection", func(t *testing.T) { + values := map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"} + caller, err := executeAttendanceResponse(t, GetCheckinRecord, values, `{"success":true,"result":{"list":[]}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +var _ edition.ToolCaller = (*attendanceCoverageCaller)(nil) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..900f2ea2 --- /dev/null +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,267 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) { + shortcuts := []struct { + name string + rollout output.RolloutState + hasResult bool + resultSchema string + identity string + cursor string + }{ + {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"}, + {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, + {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""}, + {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, + {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, + {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + } + for _, item := range shortcuts { + if item.rollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout) + } + if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" { + t.Errorf("%s must publish a non-empty Result", item.name) + } + if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) { + t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema) + } + if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 { + t.Errorf("%s must publish reviewed sensitive_paths", item.name) + } + if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) { + t.Errorf("%s Result must not place pagination controls in business data", item.name) + } + if item.cursor == "" { + if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil { + t.Errorf("%s unexpectedly publishes Pagination", item.name) + } + } else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor { + t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor) + } + } +} + +func attendanceDeclarationByName(name string) *shortcut.Shortcut { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { + if declaration.Command == name { + return declaration + } + } + return &shortcut.Shortcut{} +} + +func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason { + t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command) + } + } + if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" { + t.Error("get-class must publish a precise unavailable Interface") + } +} + +func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, + "adjustmentList": []any{}, + "atRuleList": []any{}, + }, + } + if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err) + } + if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err) + } + if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err) + } + + broken := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"items": "not-an-array"}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}}, + } + for index, data := range broken { + if got, err := searchClassProject(data); err == nil { + t.Errorf("broken class response %d returned success: %v", index, got) + } + if got, err := searchGroupProject(data); err == nil { + t.Errorf("broken group response %d returned success: %v", index, got) + } + } + + ruleBroken := map[string]any{ + "success": true, + "result": map[string]any{ + "adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}}, + }, + } + if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil { + t.Fatalf("rule row without stable ID returned success: %v", got) + } +} + +func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) { + withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}} + if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("missing totalCount/totalPage must not be reported as a complete page") + } + firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("nonzero total with empty page must fail closed") + } + complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20) + if err != nil { + t.Fatalf("valid page evidence: %v", err) + } + if complete || extra["nextPage"] != 2 { + t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra) + } + lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}} + complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1) + if err != nil || !complete { + t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err) + } + conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil { + t.Fatal("conflicting totalPage/totalCount evidence must fail closed") + } + mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}} + if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("mismatched currentPage must fail closed") + } + negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}} + if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("negative pagination evidence must fail closed") + } + wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}} + if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("wrong pagination type must fail closed") + } +} + +func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) { + for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid integer identity accepted: %#v", items) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil { + t.Fatalf("valid integer identities rejected: %v", err) + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("blank string identity accepted") + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("duplicate string identity accepted") + } +} + +func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) { + valid := []map[string]any{ + {"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, + {"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}, + } + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil { + t.Fatalf("same approveType with unique processCode rejected: %v", err) + } + valid[0]["processCode"] = " process-1 " + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" { + t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err) + } + valid[0]["approveType"] = "OUT" + valid[1]["approveType"] = "OUT" + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil { + t.Fatalf("OUT templates with unique processCode rejected: %v", err) + } + + invalid := []struct { + name string + items []map[string]any + }{ + {"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}}, + {"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}}, + {"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}}, + {"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}}, + {"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}}, + {"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}}, + {"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil { + t.Fatalf("invalid templates accepted: %#v", tc.items) + } + }) + } + + var schema map[string]any + if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil { + t.Fatal(err) + } + items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any) + required := items["required"].([]any) + for _, name := range []string{"processCode", "approveType", "submitUrl"} { + found := false + for _, value := range required { + if value == name { + found = true + } + } + if !found { + t.Errorf("template schema required=%#v missing %s", required, name) + } + } + if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want { + t.Fatalf("template sensitive paths = %q, want %q", got, want) + } + properties := items["properties"].(map[string]any) + for _, name := range []string{"processCode", "submitUrl"} { + property := properties[name].(map[string]any) + if property["type"] != "string" || property["minLength"] != float64(1) { + t.Errorf("template schema %s = %#v, want nonempty string", name, property) + } + } + approveType := properties["approveType"].(map[string]any) + if approveType["type"] != "string" { + t.Errorf("template schema approveType = %#v, want string", approveType) + } + enum := approveType["enum"].([]any) + values := make([]string, 0, len(enum)) + for _, value := range enum { + values = append(values, value.(string)) + } + if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want { + t.Errorf("template approveType enum = %q, want %q", got, want) + } +} diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go new file mode 100644 index 00000000..1b1a15e5 --- /dev/null +++ b/internal/shortcut/attendance/common.go @@ -0,0 +1,560 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed." + +func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, identity, identity, + )), + } +} + +func attendanceApproveTemplateResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`), + } +} + +func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec { + identityConstraint := `"minLength":1` + if identityType == "integer" { + identityConstraint = `"minimum":1` + } + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, identityType, identityConstraint, identity, collection, + )), + } +} + +func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: cursorParameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenPublicAttendanceContracts() { + // Composite Shortcut properties are the stable published workflow inputs. + // Execute owns the explicit adapters to the differently named MCP fields; + // redirecting an existing non-empty Schema property requires a versioned + // migration and cannot be folded into this hardening change. + CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} + CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}} + GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}} + SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}} + SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}} + + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + identity string + identityType string + cursor string + }{ + {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, + {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, + {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, + {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, + {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, + {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType) + if item.cursor != "" { + item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor) + } + } + CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"} + CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"} + ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"} + GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive + GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult() + GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} + SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} + SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} + SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} + GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive + GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") + GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} + markAttendanceCompatibilityOnly(&GetSummary) + markAttendanceCompatibilityOnly(&ListLeaveTypes) + markAttendanceCompatibilityOnly(&GetLeaveRecords) + markAttendanceCompatibilityOnly(&GetCheckinRecord) + markAttendanceCompatibilityOnly(&GetSchedule) + markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。") + markAttendanceCompatibilityOnly(&GetSelfSetting) +} + +func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, + } +} + +func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} + +func attendanceCallCollection( + rt *shortcut.RuntimeContext, + product, tool string, + params map[string]any, + collection string, + complete bool, + extra map[string]any, + validate func([]map[string]any) error, + paths ...string, +) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...) + if err != nil { + return err + } + if validate != nil { + if err := validate(items); err != nil { + return err + } + } + return attendanceOutputCollection(rt, collection, items, complete, extra, false, "") +} + +func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any { + payload := map[string]any{ + "count": len(items), + collection: items, + "complete": complete, + } + for key, value := range extra { + payload[key] = value + } + return payload +} + +func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error { + payload := attendanceCollectionPayload(collection, items, complete, legacyExtra) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Count: output.NewCount(len(items))} + if paginated { + pagination, err := output.NewPagination(complete, nextToken) + if err != nil { + return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error()) + } + meta.Pagination = pagination + } + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error { + seen := make(map[int64]struct{}, len(items)) + for index, item := range items { + value, ok := attendanceNestedValue(item, path...) + if !ok { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index)) + } + identity, ok := attendancePositiveInteger(value) + if !ok { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return nil +} + +func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error { + expected = strings.TrimSpace(expected) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field)) + } + if expected != "" && value != expected { + return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field)) + } + if _, duplicate := seen[value]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field)) + } + seen[value] = struct{}{} + } + return nil +} + +func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error { + expectedType = strings.TrimSpace(expectedType) + seenProcessCodes := make(map[string]struct{}, len(items)) + for index, item := range items { + processCodeValue, present := item["processCode"] + if !present { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index)) + } + processCode, ok := processCodeValue.(string) + processCode = strings.TrimSpace(processCode) + if !ok || processCode == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index)) + } + item["processCode"] = processCode + if _, duplicate := seenProcessCodes[processCode]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index)) + } + seenProcessCodes[processCode] = struct{}{} + + approveType, ok := item["approveType"].(string) + if !ok || approveType != expectedType { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index)) + } + submitURL, ok := item["submitUrl"].(string) + if !ok || strings.TrimSpace(submitURL) == "" { + return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index)) + } + } + return nil +} + +func attendanceNestedValue(item map[string]any, path ...string) (any, bool) { + var current any = item + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[key] + if !ok { + return nil, false + } + } + return current, true +} + +func attendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} + +func attendanceValidateUserIDs(values []string, maximum int) error { + if len(values) == 0 { + return fmt.Errorf("用户 ID 列表不能为空") + } + if maximum > 0 && len(values) > maximum { + return fmt.Errorf("用户 ID 数量不能超过 %d", maximum) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("用户 ID 不能为空字符串") + } + if value != trimmed { + return fmt.Errorf("用户 ID 不能包含首尾空白") + } + if _, duplicate := seen[trimmed]; duplicate { + return fmt.Errorf("用户 ID 不能重复") + } + seen[trimmed] = struct{}{} + } + return nil +} + +func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + rawUser, present := item[userField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + user, ok := rawUser.(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + rawTimestamp, present := item[timeField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + } + timestamp, ok := attendancePositiveInteger(rawTimestamp) + if !ok { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + if timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) + } + } + return nil +} + +func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + user, ok := item["userId"].(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + bizType, ok := attendancePositiveInteger(item["bizType"]) + if !ok { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index)) + } + if _, requested := requestedTypes[int(bizType)]; !requested { + return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index)) + } + begin, beginOK := attendancePositiveInteger(item["beginTime"]) + end, endOK := attendancePositiveInteger(item["endTime"]) + if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index)) + } + } + return nil +} + +func attendanceValidateMonthRange(startText, endText string) error { + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local) + if err != nil { + return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local) + if err != nil { + return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.After(start.AddDate(0, 1, 0)) { + return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月") + } + return nil +} + +func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error { + _, _, err := attendancePageInput(rt) + return err +} + +func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) { + if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit { + return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效") + } + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return false, nil, err + } + extra := map[string]any{"page": page, "limit": limit} + if raw, present := result["currentPage"]; present { + currentPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数") + } + if currentPage != page { + return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + } + } + var evidence []bool + var totalPageValue, totalCountValue *int + if raw, present := result["totalPage"]; present { + totalPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数") + } + if totalPage < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数") + } + extra["totalPage"] = totalPage + totalPageValue = &totalPage + if totalPage == 0 { + if page != 1 || itemCount != 0 { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空") + } + evidence = append(evidence, true) + } else { + if page > totalPage { + return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage") + } + evidence = append(evidence, page == totalPage) + } + } + if raw, present := result["totalCount"]; present { + totalCount, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数") + } + if totalCount < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数") + } + extra["totalCount"] = totalCount + totalCountValue = &totalCount + if totalCount > 0 && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止") + } + pageStart := (page - 1) * limit + if pageStart > totalCount || pageStart+itemCount > totalCount { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾") + } + evidence = append(evidence, page*limit >= totalCount) + } + if len(evidence) == 0 { + return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整") + } + complete := evidence[0] + for _, candidate := range evidence[1:] { + if candidate != complete { + return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据") + } + } + if totalPageValue != nil && totalCountValue != nil { + expectedPages := 0 + if *totalCountValue > 0 { + expectedPages = (*totalCountValue + limit - 1) / limit + } + if *totalPageValue != expectedPages { + return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾") + } + } + if !complete && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进") + } + if !complete && itemCount != limit { + return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾") + } + if complete && totalCountValue != nil { + pageStart := (page - 1) * limit + if pageStart+itemCount != *totalCountValue { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount") + } + } + if !complete { + extra["nextPage"] = page + 1 + } + return complete, extra, nil +} + +func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) { + page, limit := rt.Int("page"), rt.Int("limit") + if page < 1 { + return 0, 0, fmt.Errorf("--page 必须大于等于 1") + } + if limit < 1 || limit > 200 { + return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间") + } + return page, limit, nil +} + +func attendanceInt(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, true + case int32: + return int(number), true + case int64: + return int(number), true + case float64: + if number != float64(int(number)) { + return 0, false + } + return int(number), true + default: + return 0, false + } +} diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go index 1d6e0165..149ecb22 100644 --- a/internal/shortcut/attendance/rule_class_projection_test.go +++ b/internal/shortcut/attendance/rule_class_projection_test.go @@ -18,12 +18,12 @@ import ( "testing" ) -// TestSearchClassProjectShiftVOShape guards against projection-data-loss: +// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss: // get_class_list nests the list under result.items and wraps each shift's // identity under shiftVO. The projection must unwrap shiftVO or +search-class // silently returns empty despite the backend returning shifts. -func TestSearchClassProjectShiftVOShape(t *testing.T) { - const raw = `{"result":{"items":[ +func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) { + const raw = `{"success":true,"result":{"items":[ {"shiftVO":{"id":957395083,"name":"default shift"}}, {"shiftVO":{"id":957395084,"name":"morning shift"}} ]}}` @@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := searchClassProject(data) + got, err := searchClassProject(data) + if err != nil { + t.Fatalf("project class response: %v", err) + } if len(got) != 2 { t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got) } @@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { } } -// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) +// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) // and get_adjustment_rule (result.adjustmentList), both wrapping the rule under // entityVO. The resolver must probe those keys and unwrap entityVO or // +search-overtime-rule / +search-adjustment-rule silently return empty. -func TestSearchRuleProjectEntityVOShape(t *testing.T) { +func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) { for _, tc := range []struct { name, key string }{ {"overtime", "atRuleList"}, {"adjustment", "adjustmentList"}, } { - raw := `{"result":{"` + tc.key + `":[ + raw := `{"success":true,"result":{"` + tc.key + `":[ {"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}}, {"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}} ]}}` @@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err) } - got := searchRuleProject(data) + got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key) + if err != nil { + t.Fatalf("[%s] project rule response: %v", tc.name, err) + } if len(got) != 2 { t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got)) } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go new file mode 100644 index 00000000..9a8b0f65 --- /dev/null +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_attendance.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "attendance" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "attendance" { + continue + } + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 35 || len(source.Shortcuts) != 35 { + t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts)) + } + public := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+get-adjustment-rule": true, + "+get-checkin-record": true, + "+get-leave-records": true, + "+get-self-setting": true, + "+get-schedule": true, + "+get-summary": true, + "+list-leave-types": true, + "+my-attendance": true, + "+query-report-data": true, + "+search-group": true, + "+this-month": true, + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil { + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || compatibilityVisible != 11 { + t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible) + } +} diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go index b83ec229..ef390ee2 100644 --- a/internal/shortcut/builtin/chat_semantic_catalog_test.go +++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go @@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct { Service string `json:"service"` Availability shortcut.Availability `json:"default_availability"` Shortcuts map[string]struct { - Disposition shortcut.SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk shortcut.Risk `json:"risk"` - Availability shortcut.Availability `json:"availability"` - Primary string `json:"primary"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition shortcut.SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk shortcut.Risk `json:"risk"` + Availability shortcut.Availability `json:"availability"` + Primary string `json:"primary"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible"` + Reviewed bool `json:"reviewed"` } `json:"shortcuts"` } diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go new file mode 100644 index 00000000..63e3dd03 --- /dev/null +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_mail.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "mail" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "mail" { + registered[item.Command] = item + } + } + if len(registered) != 18 || len(source.Shortcuts) != 18 { + t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + public := 0 + unavailable := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+contact-list": true, + "+recent-mail": true, + "+tag-list": true, + "+template-list": true, + "+thread-list": true, + "+unread-mail": true, + } + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || unavailable != 10 || compatibilityVisible != 6 { + t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible) + } +} diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 0bdbe1ab..2ea6714a 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T }{ { name: "user group mentions", - args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, + args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true}, }, { @@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T } } +func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) { + tests := []struct { + name string + body string + atIDs string + atAll bool + wantErr string + }{ + { + name: "declared member missing from body", + body: "请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, + { + name: "declared member only appears as a longer token prefix", + body: "@" + fixtureCurrentDOpenID + "_suffix 请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, + { + name: "body member missing from declaration", + body: "<@" + fixtureCurrentDOpenID + "> 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + { + name: "body at-all missing from declaration", + body: "<@all> 请查收", + wantErr: "必须同时指定 --at-all", + }, + { + name: "bare body member missing from declaration", + body: "@" + fixtureCurrentDOpenID + " 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--group", "cid", "--markdown", tt.body, "--yes", + } + if tt.atIDs != "" { + args = append(args, "--at-open-dingtalk-ids", tt.atIDs) + } + if tt.atAll { + args = append(args, "--at-all") + } + root.SetArgs(args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls) + } + }) + } +} + func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) { flags := append([]shortcut.Flag(nil), MessagesSend.Flags...) for i := range flags { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 2526af79..0946632c 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) } } +func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil { + t.Fatal(err) + } + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("upload method = %s", r.Method) + } + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(uploadServer.Close) + + tests := []struct { + name string + targetArgs []string + requestedType string + wantOpenID string + wantUserResolve bool + }{ + { + name: "explicit open id file", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID}, + requestedType: "file", + wantOpenID: fixtureCurrentDOpenID, + }, + { + name: "resolved user audio", + targetArgs: []string{"--user", "user-id"}, + requestedType: "audio", + wantOpenID: "D-resolved", + wantUserResolve: true, + }, + { + name: "explicit open id video", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2}, + requestedType: "video", + wantOpenID: fixtureCurrentDOpenID2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`, + "im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`, + "chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--msg-type", tt.requestedType, + "--file", "./direct.bin", + "--idempotency-key", "direct-" + tt.requestedType, + "--yes", + } + args = append(args, tt.targetArgs...) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + callOffset := 0 + if tt.wantUserResolve { + if len(fake.calls) == 0 || fake.calls[0].product != "contact" || + fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("resolution call = %#v", fake.calls) + } + callOffset = 1 + } + if len(fake.calls) != callOffset+3 { + t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls) + } + initCall := fake.calls[callOffset] + commitCall := fake.calls[callOffset+1] + sendCall := fake.calls[callOffset+2] + for _, call := range []larkAlignmentCall{initCall, commitCall} { + if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID { + t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID) + } + if _, exists := call.args["receiverOpenDingTalkId"]; exists { + t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args) + } + } + if initCall.tool != "init_conversation_file_upload" || + commitCall.tool != "commit_conversation_file_upload" { + t.Fatalf("upload calls = %#v, %#v", initCall, commitCall) + } + if sendCall.product != "chat" || sendCall.tool != "send_personal_message" || + sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID { + t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID) + } + if _, exists := sendCall.args["openDingTalkId"]; exists { + t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args) + } + + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["requestedMessageType"] != tt.requestedType || + payload["effectiveMessageType"] != "file" { + t.Fatalf("output = %#v", payload) + } + }) + } +} + func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) { t.Chdir(t.TempDir()) if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil { diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index cea41825..18ba4296 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te } } if tt.bodyKey == "content" { + rawContent := call.args[tt.bodyKey].(string) + if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) { + t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent) + } + if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") { + t.Errorf("content = %q; missing literal current-user mention token", rawContent) + } var content map[string]string - if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil { + if err := json.Unmarshal([]byte(rawContent), &content); err != nil { t.Fatal(err) } if content["text"] != tt.body { diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 1977d077..670ae6e8 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -11,6 +11,7 @@ import ( "os" "strings" "time" + "unicode" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -18,6 +19,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" @@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数") } + if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") { + if err := validateCurrentUserMentionConsistency( + messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil { + return err + } + } case "bot": if chatQuery != "" || userQuery != "" { return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID") @@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { return nil } +func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error { + declared := make(map[string]struct{}, len(atOpenIDs)) + for _, id := range atOpenIDs { + declared[id] = struct{}{} + } + + missingPlaceholders := make([]string, 0) + for _, id := range atOpenIDs { + if !containsCurrentUserMentionToken(body, id) { + missingPlaceholders = append(missingPlaceholders, id) + } + } + if len(missingPlaceholders) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s", + strings.Join(missingPlaceholders, ","), + )) + } + + undeclared := make([]string, 0) + for _, id := range currentUserMentionBodyIDs(body) { + if _, ok := declared[id]; !ok { + undeclared = append(undeclared, id) + } + } + if len(undeclared) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s", + strings.Join(undeclared, ","), + )) + } + if !atAll && containsCurrentUserMentionToken(body, "all") { + return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all") + } + return nil +} + +func containsCurrentUserMentionToken(body, id string) bool { + placeholder := "@" + id + for searchFrom := 0; ; { + offset := strings.Index(body[searchFrom:], placeholder) + if offset < 0 { + return false + } + end := searchFrom + offset + len(placeholder) + if end == len(body) { + return true + } + next, _ := utf8.DecodeRuneInString(body[end:]) + if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' { + return true + } + searchFrom = end + } +} + +func currentUserMentionBodyIDs(body string) []string { + ids := make([]string, 0) + for searchFrom := 0; ; { + offset := strings.IndexByte(body[searchFrom:], '@') + if offset < 0 { + break + } + start := searchFrom + offset + 1 + end := start + for end < len(body) { + current := body[end] + if (current < 'a' || current > 'z') && + (current < 'A' || current > 'Z') && + (current < '0' || current > '9') { + break + } + end++ + } + id := body[start:end] + if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) { + ids = appendUniqueShortcutString(ids, id) + } + searchFrom = end + } + return ids +} + func executeMessagesSend(rt *shortcut.RuntimeContext) error { identity := messagesSendIdentity(rt) body := messagesSendBody(rt) @@ -499,7 +590,7 @@ func resolvedUserMarkdownParams( if target.GroupID != "" { body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true) } - content, _ := json.Marshal(map[string]string{"title": title, "text": body}) + content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body}) params := rt.AddAIMessageTag(map[string]any{ "msgType": "markdown", "content": string(content), @@ -573,8 +664,10 @@ func executeMessagesSendUserFile( if err != nil { return err } - targetArgs := map[string]any{} - addMessagesSendUserTarget(targetArgs, group, openID) + uploadTargetArgs := map[string]any{} + addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID) + sendTargetArgs := map[string]any{} + addMessagesSendUserTarget(sendTargetArgs, group, openID) idempotencyKey := messagesSendIdempotencyKey(rt) if rt.DryRun() { return rt.Output(map[string]any{ @@ -587,6 +680,7 @@ func executeMessagesSendUserFile( { "identity": "user", "tool": "init/commit_conversation_file_upload", + "target": uploadTargetArgs, "file": map[string]any{ "path": rawPath, "name": meta.FileName, @@ -598,7 +692,7 @@ func executeMessagesSendUserFile( "tool": "send_personal_message", "requestedMessageType": requestedType, "effectiveMessageType": "file", - "target": targetArgs, + "target": sendTargetArgs, }, }, }) @@ -607,7 +701,7 @@ func executeMessagesSendUserFile( rt.Command().Context(), messagesSendFileUploadTimeout) defer cancelUpload() commitText, err := helpers.UploadConversationLocalFile( - uploadContext, targetArgs, meta, idempotencyKey) + uploadContext, uploadTargetArgs, meta, idempotencyKey) if err != nil { return err } @@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) { params["receiverOpenDingTalkId"] = openID } +func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) { + if group != "" { + params["openConversationId"] = group + return + } + params["openDingTalkId"] = openID +} + func nonEmptyStringCount(values ...string) int { count := 0 for _, value := range values { diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go new file mode 100644 index 00000000..6ac9c5f9 --- /dev/null +++ b/internal/shortcut/mail/common.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." + +const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed." + +func mailCollectionResult(collection, description string) *contract.ResultSpec { + identity := mailCollectionIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: mailCollectionSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection, + )), + } +} + +func mailCollectionSensitivePaths(collection string) []string { + switch collection { + case "threads": + return []string{"threads.subject", "threads.lastUpdated"} + case "folders": + return []string{"folders.name"} + case "tags": + return []string{"tags.name"} + case "users": + return []string{"users.name", "users.email", "users.employeeNo"} + case "templates": + return []string{"templates.name", "templates.subject"} + case "contacts": + return []string{"contacts.contactEmail", "contacts.displayName"} + case "messages": + return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"} + default: + panic("unsupported Mail collection sensitive paths: " + collection) + } +} + +func mailCollectionIdentitySchema(collection string) string { + switch collection { + case "threads": + return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]` + case "folders", "tags", "templates", "contacts", "messages": + return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]` + case "users": + return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]` + default: + panic("unsupported Mail collection Result identity: " + collection) + } +} + +func mailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func mailObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"}, + } +} + +func mailReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) { + if !required && !rt.Changed(flag) { + return "", nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + _, err := mailStringPageSize(rt, flag, required) + return err +} + +func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func mailValidateThreadList(rt *shortcut.RuntimeContext) error { + if err := mailValidatePageSize(rt, "limit", true); err != nil { + return err + } + parsed := make(map[string]time.Time, 2) + for _, flag := range []string{"start", "end"} { + if !rt.Changed(flag) { + continue + } + value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag)) + } + _, offset := value.Zone() + if offset != 0 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag)) + } + parsed[flag] = value + } + start, hasStart := parsed["start"] + end, hasEnd := parsed["end"] + if hasStart && hasEnd && end.Before(start) { + return apperrors.NewValidation("--end 不能早于 --start") + } + return nil +} + +func mailWriteSafety(idempotency string) contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency} +} + +func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "mail " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: name, CanonicalPath: "mail." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"}, + Examples: examples, + }, + Parameters: flags, + } +} + +func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...) + contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"} + return contractDecl +} + +func mailResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// mailRequireSuccess accepts only the two success encodings observed from the +// Mail backend: boolean true and the exact string "true". Missing, false, or +// malformed status values fail closed. +func mailRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态") + } + message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务未明确返回成功状态" + } + return mailResponseError(operation, "remote_failure", message) +} + +func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present { + return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present || value == nil { + return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := mailFirstString(object, keys...) + if actual == "" { + return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致") + } + return nil +} + +func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error { + allowNumericID := false + for _, key := range identityKeys { + if key == "email" { + allowNumericID = true + break + } + } + for index, item := range items { + valid := mailFirstString(item, identityKeys...) != "" + if !valid && allowNumericID { + valid = mailNumericIdentity(item["id"]) + } + if !valid { + return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return nil +} + +func mailNumericIdentity(value any) bool { + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number: + return true + default: + return false + } +} + +func mailProjectValue(operation, outputName string, value any) (any, bool, error) { + if value == nil { + return nil, false, nil + } + switch outputName { + case "email": + text, ok := value.(string) + if !ok { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + return text, text != "", nil + case "userId": + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + return text, text != "", nil + } + if !mailNumericIdentity(value) { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字") + } + return value, true, nil + default: + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return nil, false, nil + } + return text, true, nil + } + return value, true, nil + } +} + +func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) { + items, err := mailRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + if err := mailValidateRows(items, operation, identityKeys...); err != nil { + return nil, err + } + projected := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := make(map[string]any, len(fields)) + for outputName, candidates := range fields { + for _, candidate := range candidates { + if value, present := item[candidate]; present && value != nil { + projectedValue, include, err := mailProjectValue(operation, outputName, value) + if err != nil { + return nil, err + } + if !include { + continue + } + row[outputName] = projectedValue + break + } + } + } + projected = append(projected, row) + } + return projected, nil +} + +func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := mailLookup(data, prefix) + if !present { + return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象") + } + } + nextValue, nextPresent := container["nextCursor"] + next, nextOK := nextValue.(string) + if !nextPresent || !nextOK { + return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整") + } + if raw, present := container["hasMore"]; present { + hasMore, ok := mailBool(raw) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore && terminal { + return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空") + } + if !hasMore && !terminal { + return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func mailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func mailLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func mailFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any { + return map[string]any{"count": len(items), collection: items} +} + +// mailOutputPage preserves legacy payload compatibility while keeping cursor +// controls in the unified envelope's meta.pagination contract. +func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := mailCollectionPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return mailResponseError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func hardenPublicMailContracts() { + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + paginated bool + }{ + {&ThreadList, "threads", "严格校验的邮件会话列表", true}, + {&FolderList, "folders", "严格校验的邮箱文件夹列表", false}, + {&TagList, "tags", "严格校验的邮件标签列表", false}, + {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true}, + {&TemplateList, "templates", "严格校验的邮件模板列表", true}, + {&ContactList, "contacts", "严格校验的邮件联系人列表", true}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description) + if item.paginated { + item.declaration.Contract.Pagination = mailCursorPagination() + } + item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} + } + markMailCompatibilityOnly(&ThreadList) + markMailCompatibilityOnly(&TagList) + markMailCompatibilityOnly(&TemplateList) + markMailCompatibilityOnly(&ContactList) +} + +func markMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: mailCompatibilityInterfaceReason, + } +} + +func markMailUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 525505ce..2aeab198 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -18,8 +18,11 @@ package mail import ( + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{ PrimaryCLIPath: "mail +thread-list", }, Description: "列出指定邮箱文件夹下的邮件会话(thread)", + Parameters: []contract.ParamDecl{ + {Name: "folder", Property: "folder"}, + {Name: "limit", Property: "limit"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "ascending", Property: "ascending"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{ {Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"}, {Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateThreadList(rt) + }, Tips: []string{ `dws mail +thread-list --email user@company.com --folder 104 --limit 20`, }, @@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{ if err != nil { return err } - threads := threadListProject(data) - return rt.Output(map[string]any{"count": len(threads), "threads": threads}) + threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "threads", threads, complete, next) }, } -// threadListProject reshapes the raw list_mailbox_threads response into a clean -// {conversationId, subject, lastUpdated, isRead} thread list — -// clean output projection. Both the list container and per-item -// field names are probed defensively across candidate keys, so an empty/unknown -// shape yields an empty list rather than a crash or fabricated data. -func threadListProject(data map[string]any) []map[string]any { - raw := threadListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok { - row["conversationId"] = v - } - if v, ok := threadListFirst(m, "subject", "title", "topic"); ok { - row["subject"] = v - } - if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok { - row["lastUpdated"] = v - } - if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok { - row["isRead"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// threadListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func threadListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// threadListFirst returns the first present candidate key's value. -func threadListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // Thread 根据会话 ID 获取会话详情。 // ThreadUpdate 修改单个邮件会话的状态或标签。 // ThreadBatchUpdate 批量修改邮件会话的状态或标签。 @@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{ PrimaryCLIPath: "mail +folder-list", }, Description: "列出顶层文件夹或指定父文件夹下的子文件夹", + Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "email") + }, Tips: []string{ `dws mail +folder-list --email user@company.com`, }, @@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{ if err != nil { return err } - folders := folderListProject(data) - return rt.Output(map[string]any{"count": len(folders), "folders": folders}) + folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("folders", folders)) }, } -// folderListProject reshapes the raw list_folders response into a clean -// {id, name, parentId} folder list — clean output projection. Both -// the list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func folderListProject(data map[string]any) []map[string]any { - raw := folderListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok { - row["id"] = v - } - if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok { - row["name"] = v - } - if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// folderListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func folderListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "folders"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "folders", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// folderListFirst returns the first present candidate key's value. -func folderListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // FolderCreate 创建邮件文件夹。 // FolderUpdate 更新邮件文件夹名称。 // FolderDelete 删除邮件文件夹。 @@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{ if err != nil { return err } - tags := tagListProject(data) - return rt.Output(map[string]any{"count": len(tags), "tags": tags}) + tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "parentId": {"parentId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("tags", tags)) }, } -// tagListProject reshapes the raw list_tags response into a clean -// {id, name, parentId} tag list — clean output projection. Both the -// list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func tagListProject(data map[string]any) []map[string]any { - raw := tagListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok { - row["id"] = v - } - if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok { - row["name"] = v - } - if v, ok := tagListFirst(m, "parentId", "parent_id"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// tagListResolveList locates the list payload inside the response, tolerating a -// bare top-level array container or nesting one level deeper. -func tagListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "tags"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "tags", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// tagListFirst returns the first present candidate key's value. -func tagListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TagCreate 创建邮件标签。 // TagUpdate 更新邮件标签名称。 // TagDelete 删除邮件标签。 @@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{ PrimaryCLIPath: "mail +user-search", }, Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", + Parameters: []contract.ParamDecl{ + {Name: "employee-no", Property: "employeeNo"}, + {Name: "limit", Property: "limit"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"}, - {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"}, + {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := mailValidateStringPageSize(rt, "limit", false); err != nil { + return err + } + for _, name := range []string{"keyword", "employee-no"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation("--" + name + " 显式提供时不能为空") + } + } + if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" { + return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值") + } + return nil }, Tips: []string{ `dws mail +user-search --keyword "张三"`, `dws mail +user-search --email user@company.com --employee-no "E123456"`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", false) + if err != nil { + return err + } params := map[string]any{} if rt.Str("keyword") != "" { params["keyword"] = rt.Str("keyword") @@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{ params["cursor"] = rt.Str("cursor") } if rt.Changed("limit") { - params["size"] = rt.Str("limit") + params["size"] = size } data, err := rt.CallMCPData("mail", "search_mail_users", params) if err != nil { return err } - users := userSearchProject(data) - return rt.Output(map[string]any{"count": len(users), "users": users}) + users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{ + "name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "users", users, complete, next) }, } -// userSearchProject reshapes the raw search_mail_users response into a clean -// {name, email, employeeNo, userId} user list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func userSearchProject(data map[string]any) []map[string]any { - raw := userSearchResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok { - row["name"] = v - } - if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok { - row["email"] = v - } - if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok { - row["employeeNo"] = v - } - if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok { - row["userId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// userSearchResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func userSearchResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "users"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "users", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// userSearchFirst returns the first present candidate key's value. -func userSearchFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ── attachment ───────────────────────────────────────────── // AttachmentList 列举邮件附件。 @@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{ PrimaryCLIPath: "mail +template-list", }, Description: "列出指定邮箱的所有邮件模板", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +template-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{ if err != nil { return err } - templates := templateListProject(data) - return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "subject": {"subject"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "templates", templates, complete, next) }, } -// templateListProject reshapes the raw list_user_message_templates response into -// a clean {id, name, subject} template list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func templateListProject(data map[string]any) []map[string]any { - raw := templateListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok { - row["id"] = v - } - if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok { - row["name"] = v - } - if v, ok := templateListFirst(m, "subject", "title"); ok { - row["subject"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// templateListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func templateListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "templates"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "templates", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// templateListFirst returns the first present candidate key's value. -func templateListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TemplateGet 获取邮件模板详情。 // TemplateUpdate 更新邮件模板。 // TemplateDelete 删除邮件模板。 @@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{ PrimaryCLIPath: "mail +contact-list", }, Description: "列出指定邮箱的所有邮件联系人", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +contact-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{ if err != nil { return err } - contacts := contactListProject(data) - return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts}) + contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{ + "id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "contacts", contacts, complete, next) }, } -// contactListProject reshapes the raw list_user_mail_contacts response into a -// clean {id, contactEmail, displayName} contact list — output-projection -// clean output projection. Both the list container and per-item field names are probed -// defensively across candidate keys, so an empty/unknown shape yields an empty -// list rather than a crash or fabricated data. -func contactListProject(data map[string]any) []map[string]any { - raw := contactListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok { - row["id"] = v - } - if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok { - row["contactEmail"] = v - } - if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok { - row["displayName"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// contactListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func contactListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "contacts"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "contacts", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// contactListFirst returns the first present candidate key's value. -func contactListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ContactUpdate 更新邮件联系人。 // ContactBatchDelete 批量删除邮件联系人。 // ── auto-reply ───────────────────────────────────────────── @@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) { // RuleDelete 删除个人收信规则。 // RuleAdjust 调整收信规则排序。 func init() { + hardenPublicMailContracts() shortcut.Register( ThreadList, FolderList, diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..248ecd6c --- /dev/null +++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,422 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) { + for _, tc := range []struct { + name string + args []string + responses map[string][]string + errors map[string]error + wantErr bool + wantCalls int + }{ + {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1}, + {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1}, + {name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1}, + {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2}, + {name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2}, + {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1}, + {name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2}, + {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1}, + {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors} + if caller.responses == nil { + caller.responses = map[string][]string{} + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid read unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid read failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + if err := mailValidateMessageIDs(nil); err == nil { + t.Fatal("empty ids unexpectedly valid") + } + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "}) + if err := Messages.Execute(rt); err == nil { + t.Fatal("defensive empty-id guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls)) + } +} + +func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + { + name: "top string", + data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, + }, + { + name: "top object", + data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, + }, + { + name: "result string", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "result object", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + { + name: "data string", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "data object", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := mailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" { + t.Fatalf("addresses=%#v err=%v", got, err) + } + }) + } + + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing collection": {"success": true}, + "malformed collection": {"success": true, "emailAccounts": map[string]any{}}, + "blank string": {"success": true, "emailAccounts": []any{" "}}, + "missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}}, + "wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } +} + +func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) { + for _, tc := range []struct { + name string + args []string + tool string + response string + remoteErr bool + wantErr bool + }{ + {name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`}, + {name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true}, + {name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true}, + {name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`}, + {name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true}, + {name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true}, + {name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true}, + {name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true}, + {name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true}, + {name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true}, + {name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true}, + {name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true}, + {name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true}, + {name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if tc.remoteErr { + caller.errors[tc.tool] = fmt.Errorf("remote failure") + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid list response unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid list response failed: %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + deferred := false + defer func() { + if recover() == nil { + t.Fatal("unsupported declaration did not panic") + } + deferred = true + }() + fn() + _ = deferred + }) + } + assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") }) + assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") }) + + if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil { + t.Fatal("object accepted missing success") + } + if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" { + t.Fatalf("valid object rejected: %#v %v", object, err) + } + if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil { + t.Fatal("numeric email unexpectedly projected") + } + if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include { + t.Fatalf("blank user id include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include { + t.Fatalf("nil projection include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include { + t.Fatalf("blank projection include=%v err=%v", include, err) + } + if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 { + t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err) + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "malformed cursor": {"nextCursor": 7}, + "malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := mailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if _, ok := mailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } + if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object intermediate lookup unexpectedly succeeded") + } + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) { + for name, message := range map[string]map[string]any{ + "missing addresses": {}, + "wrong address collection": {"toRecipients": "bad"}, + "wrong address item": {"toRecipients": []any{7}}, + "blank address item": {"toRecipients": []any{map[string]any{"email": " "}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil { + t.Fatal("malformed recipients unexpectedly succeeded") + } + }) + } + addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients") + if err != nil || len(addresses) != 2 { + t.Fatalf("valid recipients rejected: %v %v", addresses, err) + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil { + t.Fatal("recipient length mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient value mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient parser error was not propagated") + } + if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil { + t.Fatal("failed draft receipt unexpectedly succeeded") + } + for name, message := range map[string]map[string]any{ + "missing sender": {}, + "wrong sender": {"from": []any{}}, + "blank sender": {"from": " "}, + } { + t.Run(name, func(t *testing.T) { + if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil { + t.Fatal("malformed sender unexpectedly accepted") + } + }) + } + if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil { + t.Fatalf("case-insensitive sender rejected: %v", err) + } + + for _, args := range [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"}, + } { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, args...); err != nil { + t.Fatalf("dry-run failed: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run made %d remote calls", len(caller.calls)) + } + } + + for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "sender@example.invalid", "id": "template-1"} + if declaration.Command == "+draft-edit" { + values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"} + } + rt := mailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatal("execute-level no-change guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("no-change guard made %d calls", len(caller.calls)) + } + } + + for _, tc := range []struct { + name string + readback string + }{ + {name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {tc.readback}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("draft verification mismatch unexpectedly succeeded") + } + }) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":"false"}`}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("failed template readback unexpectedly succeeded") + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + values map[string]string + }{ + {declaration: Message, values: map[string]string{"id": " "}}, + {declaration: FolderList, values: map[string]string{"email": " "}}, + } { + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Validate(rt); err == nil { + t.Fatal("direct required-text validator accepted whitespace") + } + } + rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{}) + if err := UserSearch.Validate(rt); err == nil { + t.Fatal("direct user-search validator accepted missing identities") + } +} + +func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "get_user_message_template": {`{"id":"template-1"}`}, + }, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{}) + if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil { + t.Fatal("failed template detail response unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d want=1", len(caller.calls)) + } +} diff --git a/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go new file mode 100644 index 00000000..99b7ea65 --- /dev/null +++ b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type mailConfirmationCaller struct{ calls int } + +func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil +} + +func (*mailConfirmationCaller) Format() string { return "json" } +func (*mailConfirmationCaller) DryRun() bool { return false } +func (*mailConfirmationCaller) Fields() string { return "" } +func (*mailConfirmationCaller) JQ() string { return "" } + +func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) { + cases := [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"}, + } + for _, args := range cases { + caller := &mailConfirmationCaller{} + if err := runUnconfirmedMailWrite(t, caller, args...); err == nil { + t.Fatalf("unconfirmed %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls) + } + } +} diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..4134fc99 --- /dev/null +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,310 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { + declarations := []*struct { + rollout output.RolloutState + result bool + }{ + {FolderList.OutputRollout, FolderList.Contract.Result != nil}, + {UserSearch.OutputRollout, UserSearch.Contract.Result != nil}, + {Message.OutputRollout, Message.Contract.Result != nil}, + {Messages.OutputRollout, Messages.Contract.Result != nil}, + {Thread.OutputRollout, Thread.Contract.Result != nil}, + } + for index, declaration := range declarations { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("mail declaration %d is not unified with Result", index) + } + } +} + +func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) { + want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false} + for _, constraint := range DraftEdit.Constraints { + if constraint.Kind != shortcut.ConstraintAtLeastOne { + continue + } + for _, flag := range constraint.Flags { + if _, ok := want[flag]; ok { + want[flag] = true + } + } + } + for flag, found := range want { + if !found { + t.Fatalf("draft edit missing at-least-one declaration for %s", flag) + } + } + if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil { + t.Fatal("messages must declare and execute the 1-100 nonempty ID contract") + } + userSearchNonempty := false + for _, constraint := range UserSearch.Constraints { + if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" { + userSearchNonempty = true + } + } + if !userSearchNonempty { + t.Fatal("user search must declare its explicit nonempty string contract") + } +} + +func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UserSearch} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor pagination declaration", declaration.Command) + } + } + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} { + if declaration.Contract.Pagination != nil { + t.Fatalf("%s must not declare pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) { + for _, tc := range []struct { + name string + complete bool + next string + exhausted bool + }{ + {name: "continuing", complete: false, next: "cursor-2", exhausted: false}, + {name: "terminal", complete: true, next: "", exhausted: true}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil { + t.Fatalf("store pagination: %v", err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + }) + } +} + +func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) { + collections := []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&FolderList, "folders", "id"}, + {&UserSearch, "users", "email"}, + {&Messages, "messages", "id"}, + } + for _, tc := range collections { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + count := properties["count"].(map[string]any) + if count["minimum"] != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"]) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if !ok || identity["description"] == "" { + t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity) + } + } + for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} { + if len(declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", declaration.Command, err) + } + value := schema["properties"].(map[string]any)["value"].(map[string]any) + required := value["required"].([]any) + if len(required) != 1 || required[0] != "id" { + t.Fatalf("%s value does not require id", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) { + for _, declaration := range []struct { + name string + confirmation string + }{ + {DraftCreate.Command, DraftCreate.Safety.Confirmation}, + {DraftEdit.Command, DraftEdit.Safety.Confirmation}, + {TemplateCreate.Command, TemplateCreate.Safety.Confirmation}, + {TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation}, + } { + if declaration.confirmation != "user_required" { + t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation) + } + } +} + +func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "folders": []any{}} + items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "missing success": {"folders": []any{}}, + "missing collection": {"success": "true"}, + "wrong collection": {"success": true, "folders": map[string]any{}}, + "bad item": {"success": "true", "folders": []any{"bad"}}, + } { + if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } + if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil { + t.Fatal("string false must not be success") + } + if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil { + t.Fatalf("boolean true must be accepted: %v", err) + } +} + +func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) { + complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err) + } + complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil { + t.Fatal("hasMore without a progressing cursor must fail") + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil { + t.Fatal("repeated cursor must fail closed") + } + if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil { + t.Fatal("identity mismatch must fail") + } +} + +func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"}, + {name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{tc.user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%v err=%v", rows, err) + } + if _, ok := rows[0][tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0]) + } + if _, ok := rows[0][tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0]) + } + }) + } + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("user without a stable identity unexpectedly projected") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go new file mode 100644 index 00000000..722ee756 --- /dev/null +++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go @@ -0,0 +1,504 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "maps" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type mailWriteCall struct { + tool string + args map[string]any +} + +type mailWriteContractCaller struct { + responses map[string][]string + errors map[string]error + calls []mailWriteCall +} + +func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, fmt.Errorf("unexpected Mail tool call: %s", tool) + } + text := queue[0] + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*mailWriteContractCaller) Format() string { return "json" } +func (*mailWriteContractCaller) DryRun() bool { return false } +func (*mailWriteContractCaller) Fields() string { return "" } +func (*mailWriteContractCaller) JQ() string { return "" } + +func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(append(args, "--yes", "--format", "json")) + return root.Execute() +} + +func mailDraftReadback(id, subject, body, sender, to, cc string) string { + return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc) +} + +func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantTools []string + }{ + { + name: "draft create", + args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"create_draft", "get_email_by_message_id"}, + }, + { + name: "draft edit", + args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"update_draft", "get_email_by_message_id"}, + }, + { + name: "template create core fields", + args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"create_user_message_template", "get_user_message_template"}, + }, + { + name: "template update success plus requested id readback", + args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_user_message_template": {`{"success":true}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"update_user_message_template", "get_user_message_template"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("write execution failed: %v", err) + } + if len(caller.calls) != len(tc.wantTools) { + t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools)) + } + for index, tool := range tc.wantTools { + if caller.calls[index].tool != tool { + t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool) + } + } + }) + } +} + +func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) { + tests := []struct { + name string + writeBody string + readBody string + writeErr error + wantCalls int + }{ + {name: "empty write body", writeBody: "", wantCalls: 1}, + {name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1}, + {name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1}, + {name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2}, + {name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: map[string]error{"create_draft": tc.writeErr}, + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if err == nil { + t.Fatal("invalid receipt/readback unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantCalls int + }{ + {name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1}, + {name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1}, + {name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1}, + {name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid write unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + args []string + tool string + response string + }{ + {name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + {name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor was not passed through: %#v", caller.calls) + } + }) + } + caller := &mailWriteContractCaller{ + responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}, + errors: map[string]error{}, + } + if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) { + tooManyIDs := make([]string, 101) + for index := range tooManyIDs { + tooManyIDs[index] = fmt.Sprintf("message-%d", index) + } + for _, tc := range []struct { + name string + args []string + }{ + {name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}}, + {name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}}, + {name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}}, + {name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}}, + {name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}}, + {name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}}, + {name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}}, + {name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}}, + {name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}}, + {name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}}, + {name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}}, + {name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}}, + {name: "message empty id", args: []string{"mail", "+message", "--id", " "}}, + {name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}}, + {name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input reached remote calls: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) { + if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil { + t.Fatalf("omitted optional integer limit should remain valid: %v", err) + } + + tests := []struct { + name string + declaration *shortcut.Shortcut + args []string + tool string + response string + wantSize string + wantPresent bool + }{ + {name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true}, + {name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`}, + {name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true}, + {name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, flag := range tc.declaration.Flags { + if flag.Name != "limit" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default) + } + } + if !found { + t.Fatal("declaration missing --limit") + } + cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration)) + if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want 1", len(caller.calls)) + } + gotSize, present := caller.calls[0].args["size"] + if present != tc.wantPresent || (present && gotSize != tc.wantSize) { + t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}}, + {name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + {name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Execute(rt); err == nil { + t.Fatal("direct Execute accepted malformed string limit") + } + if len(caller.calls) != 0 { + t.Fatalf("direct Execute reached %d remote calls", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) { + for _, leaf := range []struct { + name string + args []string + }{ + {name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}}, + {name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}}, + {name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}}, + } { + for _, invalid := range []struct { + name string + value string + }{ + {name: "blank", value: " "}, + {name: "nonnumeric", value: "bad"}, + {name: "zero", value: "0"}, + {name: "negative", value: "-1"}, + {name: "too large", value: "101"}, + } { + t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + args := append(append([]string{}, leaf.args...), "--limit", invalid.value) + if err := runConfirmedMailWriteContract(t, caller, args...); err == nil { + t.Fatal("invalid legacy string limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls)) + } + }) + } + } +} + +func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) { + goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}` + goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid") + for _, tc := range []struct { + name string + writeBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1}, + {name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1}, + {name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1}, + {name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2}, + {name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2}, + {name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: tc.errors, + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatalf("happy control: %v", err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + createBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1}, + {name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1}, + {name: "missing id", createBody: `{"success":true}`, wantCalls: 1}, + {name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + updateBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", updateBody: `{}`, wantCalls: 1}, + {name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1}, + {name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go new file mode 100644 index 00000000..21feb47c --- /dev/null +++ b/internal/shortcut/mail/reads.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) { + if email := strings.TrimSpace(rt.Str("email")); email != "" { + return email, nil + } + data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil) + if err != nil { + return "", err + } + mailboxes, err := mailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) == 0 { + return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定") + } + return mailboxes[0], nil +} + +// mailMailboxAddresses accepts the reviewed mailbox collection paths and item +// shapes used by the Mail gateway. The complete selected array is validated +// before the first address is returned so a malformed later row cannot be +// hidden behind an otherwise usable first mailbox. +func mailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := mailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + rawEmail, present := typed["email"] + if present && rawEmail != nil { + var valid bool + address, valid = rawEmail.(string) + if !valid { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index)) + } + address = strings.TrimSpace(address) + } + default: + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil +} + +func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id}) + if err != nil { + return nil, err + } + message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message") + if err != nil { + return nil, err + } + if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil { + return nil, err + } + return message, nil +} + +var Message = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+message", Product: "mail", + Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + message, err := mailReadMessage(rt, email, rt.Str("id")) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": message}) + }, +} + +var Messages = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+messages", Product: "mail", + Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`), + Flags: []shortcut.Flag{ + {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"ids"}, + Description: "1-100 个且不能为空", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateMessageIDs(rt.StrSlice("ids")) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := rt.StrSlice("ids") + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + messages := make([]map[string]any, 0, len(ids)) + for index, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + message, err := mailReadMessage(rt, email, id) + if err != nil { + return err + } + messages = append(messages, message) + } + return rt.Output(mailBusinessCollectionPayload("messages", messages)) + }, +} + +func mailValidateMessageIDs(ids []string) error { + if len(ids) == 0 || len(ids) > 100 { + return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") + } + for index, id := range ids { + if strings.TrimSpace(id) == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + } + return nil +} + +var Thread = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+thread", Product: "mail", + Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")}) + if err != nil { + return err + } + conversation, err := mailRequireObject(data, "mail/get_thread", "conversation") + if err != nil { + return err + } + if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil { + return err + } + return rt.Output(map[string]any{"value": conversation}) + }, +} + +func init() { + shortcut.Register(Message, Messages, Thread) +} diff --git a/internal/shortcut/mail/thread_list_projection_test.go b/internal/shortcut/mail/thread_list_projection_test.go index c2f178d5..d34f639e 100644 --- a/internal/shortcut/mail/thread_list_projection_test.go +++ b/internal/shortcut/mail/thread_list_projection_test.go @@ -21,18 +21,23 @@ import ( // TestThreadListProjectConversationsShape guards the exact // list_mailbox_threads response contract, including lastModifiedDateTime. func TestThreadListProjectConversationsShape(t *testing.T) { - const raw = `{"result":{"conversations":[{ + const raw = `{"success":true,"result":{"conversations":[{ "id":"thread-1", "subject":"Projection contract", "lastModifiedDateTime":"2026-07-26T10:00:00Z", "isRead":false - }]}}` + }],"hasMore":false,"nextCursor":""}}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := threadListProject(data) + got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + t.Fatalf("strict projection: %v", err) + } if len(got) != 1 { t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got) } diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go new file mode 100644 index 00000000..63989e89 --- /dev/null +++ b/internal/shortcut/mail/writes.go @@ -0,0 +1,397 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailRecipients(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +func mailNormalizedAddresses(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized != "" { + seen[normalized] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) { + raw, present := message[field] + if !present { + return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field)) + } + items, ok := raw.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field)) + } + addresses := make([]string, 0, len(items)) + for index, item := range items { + var address string + switch typed := item.(type) { + case string: + address = typed + case map[string]any: + address = mailFirstString(typed, "email", "address") + default: + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index)) + } + if strings.TrimSpace(address) == "" { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index)) + } + addresses = append(addresses, address) + } + return mailNormalizedAddresses(addresses), nil +} + +func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error { + actual, err := mailReadAddresses(message, operation, field) + if err != nil { + return err + } + expected := mailNormalizedAddresses(requested) + if len(actual) != len(expected) { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + for index := range expected { + if actual[index] != expected[index] { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + } + return nil +} + +func mailVerifySender(message map[string]any, operation, requested string) error { + raw, present := message["from"] + if !present { + return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from") + } + var actual string + switch typed := raw.(type) { + case string: + actual = typed + case map[string]any: + actual = mailFirstString(typed, "email", "address") + default: + return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效") + } + if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) { + return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致") + } + return nil +} + +func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, "result.message") + if !present { + return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message") + } + message, ok := value.(map[string]any) + if !ok || len(message) == 0 { + return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value)) + } + if err := mailRequireIdentity(message, operation, "", "id"); err != nil { + return nil, err + } + return message, nil +} + +func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id}) + if err != nil { + return nil, err + } + if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil { + return nil, err + } + template := make(map[string]any, len(data)) + for key, value := range data { + switch key { + case "success", "errorCode", "errorMsg", "arguments": + continue + default: + template[key] = value + } + } + if len(template) == 0 { + return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空") + } + if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil { + return nil, err + } + return template, nil +} + +func mailTemplateSubject(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "subject") +} + +func mailTemplateBody(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "markdownBody", "body") +} + +var DraftCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-create", Product: "mail", + Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")} + if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 { + params["toRecipients"] = recipients + } + if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 { + params["ccRecipients"] = recipients + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/create_draft") + if err != nil { + return err + } + id := mailFirstString(receipt, "id") + verified, err := mailReadMessage(rt, rt.Str("from"), id) + if err != nil { + return err + } + if mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var DraftEdit = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-edit", Product: "mail", + Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一") + } + params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")} + if rt.Changed("to") { + params["toRecipients"] = mailRecipients(rt.StrSlice("to")) + } + if rt.Changed("cc") { + params["ccRecipients"] = mailRecipients(rt.StrSlice("cc")) + } + if rt.Changed("subject") { + params["subject"] = rt.Str("subject") + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/update_draft") + if err != nil { + return err + } + if mailFirstString(receipt, "id") != rt.Str("id") { + return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致") + } + verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-create", Product: "mail", + Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"}, + {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")} + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil { + return err + } + id := mailFirstString(written, "id") + if id == "" { + return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID") + } + verified, err := mailReadTemplate(rt, rt.Str("email"), id) + if err != nil { + return err + } + if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateUpdate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-update", Product: "mail", + Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --name、--subject、--body 之一") + } + params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")} + for _, flag := range []string{"name", "subject", "body"} { + if rt.Changed(flag) { + params[flag] = rt.Str(flag) + } + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil { + return err + } + verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致") + } + if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致") + } + if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +func init() { + markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate) +} diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go new file mode 100644 index 00000000..afd3a2a1 --- /dev/null +++ b/internal/shortcut/public_catalog_consistency_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package shortcut + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate public catalog test file") + } + catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json") + raw, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("read public catalog: %v", err) + } + var catalog struct { + Count int `json:"count"` + Results []struct { + Service string `json:"service"` + Command string `json:"command"` + } `json:"results"` + } + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatalf("decode public catalog: %v", err) + } + if catalog.Count != len(catalog.Results) { + t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results)) + } + if catalog.Count != len(publicShortcutCatalog) { + t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog)) + } + seen := make(map[string]struct{}, len(catalog.Results)) + for i, row := range catalog.Results { + key := publicCatalogKey(row.Service, row.Command) + if row.Service == "" || row.Command == "" { + t.Fatalf("public catalog result %d has an empty identity", i) + } + if _, duplicate := seen[key]; duplicate { + t.Fatalf("public catalog result %d duplicates %q", i, key) + } + seen[key] = struct{}{} + if _, generated := publicShortcutCatalog[key]; !generated { + t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key) + } + } + for key := range publicShortcutCatalog { + if _, documented := seen[key]; !documented { + t.Fatalf("generated Go key is missing from public catalog JSON: %q", key) + } + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 0aba731a..51dd4a94 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -101,23 +101,12 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+workflow-list": {}, "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, - "attendance\u0000+get-adjustment-rule": {}, "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-checkin-record": {}, - "attendance\u0000+get-leave-records": {}, "attendance\u0000+get-overtime-rule": {}, - "attendance\u0000+get-schedule": {}, - "attendance\u0000+get-self-setting": {}, - "attendance\u0000+get-summary": {}, "attendance\u0000+list-approve": {}, - "attendance\u0000+list-leave-types": {}, - "attendance\u0000+my-attendance": {}, - "attendance\u0000+query-report-data": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, - "attendance\u0000+search-group": {}, "attendance\u0000+search-overtime-rule": {}, - "attendance\u0000+this-month": {}, "calendar\u0000+agenda": {}, "calendar\u0000+attendee-list": {}, "calendar\u0000+book": {}, @@ -353,15 +342,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "drive\u0000+version-get": {}, "drive\u0000+version-history": {}, "drive\u0000+version-revert": {}, - "mail\u0000+contact-list": {}, "mail\u0000+find-mail-user": {}, "mail\u0000+folder-list": {}, - "mail\u0000+recent-mail": {}, + "mail\u0000+message": {}, + "mail\u0000+messages": {}, "mail\u0000+search-mail": {}, - "mail\u0000+tag-list": {}, - "mail\u0000+template-list": {}, - "mail\u0000+thread-list": {}, - "mail\u0000+unread-mail": {}, + "mail\u0000+thread": {}, + "mail\u0000+triage": {}, "mail\u0000+user-search": {}, "minutes\u0000+action-items": {}, "minutes\u0000+apply-permission": {}, diff --git a/internal/shortcut/responsecheck/responsecheck.go b/internal/shortcut/responsecheck/responsecheck.go new file mode 100644 index 00000000..1b175933 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck.go @@ -0,0 +1,181 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package responsecheck contains fail-closed response validators shared by +// built-in shortcuts. It deliberately does not guess that a missing collection +// means an empty result: callers must name the exact reviewed wire paths. +package responsecheck + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Error returns a non-retryable response-validation error. The remote request +// has completed, but the response is not sufficient evidence of success. +func Error(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// RequireSuccess validates the common service envelope and returns it. A +// missing success field is rejected because the products using this helper +// have been observed to publish an explicit boolean success field. +func RequireSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + envelope := data + if content, ok := data["content"].(map[string]any); ok { + envelope = content + } + raw, present := envelope["success"] + if !present { + return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功") + } + success, ok := raw.(bool) + if !ok { + return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstString(envelope, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, Error(operation, "remote_failure", message) + } + return envelope, nil +} + +// RequireResult returns the explicit non-null result value from a successful +// response. Empty arrays and empty objects remain valid values; absence/null do +// not prove an empty business result. +func RequireResult(data map[string]any, operation string) (any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + result, present := envelope["result"] + if !present || result == nil { + return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段") + } + return result, nil +} + +// RequireObjectResult returns an explicit object result. +func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + object, ok := result.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result)) + } + return object, nil +} + +// RequireSingleObjectResult returns one non-empty business object. It accepts +// the two reviewed detail shapes used by product services: result={...} and +// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values +// cannot prove that the requested unique resource was read. +func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + var object map[string]any + switch typed := result.(type) { + case map[string]any: + object = typed + case []any: + if len(typed) != 1 { + return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed))) + } + var ok bool + object, ok = typed[0].(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0])) + } + default: + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result)) + } + if len(object) == 0 { + return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功") + } + return object, nil +} + +// RequireObjectCollection resolves the first present reviewed path, requires +// an array at that exact path, and rejects malformed elements instead of +// silently dropping them. A present empty array is a valid empty result. +func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, path := range paths { + value, present := lookup(envelope, path) + if !present { + continue + } + raw, ok := value.([]any) + if !ok { + return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil + } + return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果") +} + +// LookupObject returns the object at a reviewed dotted path. +func LookupObject(data map[string]any, operation, path string) (map[string]any, error) { + value, present := lookup(data, path) + if !present { + return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value)) + } + return object, nil +} + +func lookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func firstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go new file mode 100644 index 00000000..41a5c3c2 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package responsecheck + +import "testing" + +func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantLen int + wantErr bool + }{ + {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1}, + {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1}, + {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0}, + {name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true}, + {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true}, + {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true}, + {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true}, + {name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true}, + {name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true}, + {name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true}, + {name: "empty response", data: map[string]any{}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RequireObjectCollection(tc.data, "test/read", "result") + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && len(got) != tc.wantLen { + t.Fatalf("len = %d, want %d", len(got), tc.wantLen) + } + }) + } +} + +func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) { + data := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"id": "one"}}, + }, + } + items, err := RequireObjectCollection(data, "test/nested", "result.items") + if err != nil { + t.Fatalf("RequireObjectCollection: %v", err) + } + if len(items) != 1 { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) { + if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil { + t.Fatal("expected null result to fail closed") + } + if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil { + t.Fatal("expected array result to fail object validation") + } +} + +func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"id": "one"}}, + {"success": true, "result": []any{map[string]any{"id": "one"}}}, + } { + object, err := RequireSingleObjectResult(data, "test/detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err) + } + } + for _, data := range []map[string]any{ + {}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{}}, + {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": "bad"}, + } { + if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil { + t.Fatalf("ambiguous/malformed detail returned success: %v", object) + } + } +} + +func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing success", data: map[string]any{"result": map[string]any{}}}, + {name: "malformed success", data: map[string]any{"success": "true"}}, + {name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}}, + {name: "failure without message", data: map[string]any{"success": false, "error": 42}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil { + t.Fatal("expected fail-closed envelope validation") + } + }) + } + + if _, err := RequireResult(map[string]any{}, "test/result"); err == nil { + t.Fatal("expected upstream envelope failure") + } + if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil { + t.Fatal("expected missing result failure") + } + if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" { + t.Fatalf("valid object result rejected: object=%v err=%v", object, err) + } + if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil { + t.Fatal("expected upstream object envelope failure") + } +} + +func TestCrossPlatformCoverageLookupObject(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "detail": map[string]any{"id": "one"}, + "scalar": "bad", + }, + } + object, err := LookupObject(data, "test/lookup", "result.detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid nested object rejected: object=%v err=%v", object, err) + } + if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil { + t.Fatal("expected missing nested object failure") + } + if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil { + t.Fatal("expected malformed nested object failure") + } + if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present { + t.Fatal("scalar parent must not satisfy a nested lookup") + } +} + +func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) { + items, err := RequireObjectCollection( + map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}}, + "test/fallback", + "result.records", + "result.items", + ) + if err != nil || len(items) != 1 { + t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err) + } + if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil { + t.Fatal("expected collection envelope failure") + } + if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" { + t.Fatalf("firstString = %q, want value", got) + } + if got := firstString(map[string]any{"only": " "}, "only"); got != "" { + t.Fatalf("firstString whitespace = %q, want empty", got) + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index fbb5b312..12a00442 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -34,6 +34,12 @@ var calendarSemanticCatalogJSON []byte //go:embed semantic_catalog_todo.json var todoSemanticCatalogJSON []byte +//go:embed semantic_catalog_attendance.json +var attendanceSemanticCatalogJSON []byte + +//go:embed semantic_catalog_mail.json +var mailSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -42,13 +48,14 @@ type semanticCatalogFile struct { } type semanticCatalogRecord struct { - Disposition SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk Risk `json:"risk"` - Availability Availability `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk Risk `json:"risk"` + Availability Availability `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` + Reviewed bool `json:"reviewed"` } var reviewedSemanticCatalog = mustLoadSemanticCatalogs( @@ -60,6 +67,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( wikiSemanticCatalogJSON, calendarSemanticCatalogJSON, todoSemanticCatalogJSON, + attendanceSemanticCatalogJSON, + mailSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { @@ -120,6 +129,13 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", command, record.Availability)) } + // A command that was already part of the visible CLI contract cannot be + // hidden in the same feature change merely because Agent publication is + // withdrawn. This narrow fact preserves historical discovery/argv while + // the unavailable Interface and public=false keep it out of Agent routes. + if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { + panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command)) @@ -138,6 +154,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { s.Availability = record.Availability s.PrimaryCommand = record.Primary s.SemanticReviewed = record.Reviewed - s.Hidden = !record.Public + s.CompatibilityVisible = record.CompatibilityVisible + s.Hidden = !record.Public && !record.CompatibilityVisible return s, true } diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json new file mode 100644 index 00000000..ab478fcf --- /dev/null +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "service": "attendance", + "default_availability": "unavailable", + "shortcuts": { + "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + + "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + + "+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index 61d00569..d555aced 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -121,6 +121,36 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, + "compatibility-visible public": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "compatibility_visible": true, + "reviewed": true + } + } + }`, + "compatibility-visible available": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": false, + "compatibility_visible": true, + "reviewed": true + } + } + }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -172,6 +202,16 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin } } +func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { + item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"}) + if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable { + t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok) + } + if InPublicCatalog(item.Service, item.Command) { + t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") + } +} + func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { caller := &runtimeReadCoverageCaller{} old := helpers.GetCaller() diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json new file mode 100644 index 00000000..8e3b260a --- /dev/null +++ b/internal/shortcut/semantic_catalog_mail.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "service": "mail", + "default_availability": "unavailable", + "shortcuts": { + "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go new file mode 100644 index 00000000..12ebddf8 --- /dev/null +++ b/internal/shortcut/smart/attendance_strict.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +func attendanceRecordsResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`), + } +} + +func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error { + records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result") + if err != nil { + return err + } + seen := make(map[int64]struct{}, len(records)) + for index, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return rt.Output(map[string]any{ + "count": len(records), + "records": records, + "complete": true, + }) +} + +func smartAttendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..cb50b30c --- /dev/null +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + hasResult bool + availability string + }{ + {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability}, + {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability}, + } { + if declaration.rollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout) + } + if declaration.hasResult || declaration.availability != contract.InterfaceAvailable { + t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}} + if got := strictAttendanceCurrentUserID(valid); got != "u1" { + t.Fatalf("valid profile identity = %q", got) + } + invalid := []map[string]any{ + nil, + {"result": map[string]any{"userId": "stale"}}, + {"success": false, "result": map[string]any{"userId": "stale"}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +type smartAttendanceProfileCaller struct { + profile string + attendance string + profileErr error + attendanceErr error + calls []string + arguments []map[string]any +} + +func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, server+"/"+tool) + c.arguments = append(c.arguments, arguments) + text := c.profile + callErr := c.profileErr + if len(c.calls) > 1 { + text = c.attendance + callErr = c.attendanceErr + } + if callErr != nil { + return nil, callErr + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} +func (*smartAttendanceProfileCaller) Format() string { return "json" } +func (*smartAttendanceProfileCaller) DryRun() bool { return false } +func (*smartAttendanceProfileCaller) Fields() string { return "" } +func (*smartAttendanceProfileCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) { + caller := &smartAttendanceProfileCaller{} + helpers.InitDepsForTest(t, caller) + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + caller.calls = nil + caller.arguments = nil + caller.profile = `{"success":false,"result":{"userId":"stale"}}` + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil { + t.Errorf("%s accepted success=false profile", declaration.Command) + } + if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") { + t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) { + valid := []map[string]any{ + {"success": true, "result": map[string]any{"userId": " u1 "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}, + } + for index, profile := range valid { + if got := strictAttendanceCurrentUserID(profile); got != "u1" { + t.Errorf("valid profile %d resolved %q", index, got) + } + } + invalid := []map[string]any{ + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"userId": 1}}, + {"success": true, "result": map[string]any{"userId": " "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}}, + {"success": true, "result": true}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": ""}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } { + if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil { + seen := map[int64]struct{}{} + valid := true + for _, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + valid = false + break + } + if _, duplicate := seen[identity]; duplicate { + valid = false + break + } + seen[identity] = struct{}{} + } + if valid { + t.Fatalf("invalid records accepted: %#v", data) + } + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) { + result := attendanceRecordsResult() + if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) { + t.Fatalf("invalid strict attendance result declaration: %#v", result) + } + + for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} { + if _, ok := smartAttendancePositiveInteger(value); !ok { + t.Errorf("valid record identity rejected: %#v", value) + } + } + for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} { + if _, ok := smartAttendancePositiveInteger(value); ok { + t.Errorf("invalid record identity accepted: %#v", value) + } + } + + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{}) + invalid := []map[string]any{ + nil, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } + for index, data := range invalid { + if err := outputStrictAttendanceRecords(rt, data); err == nil { + t.Errorf("invalid strict output %d accepted", index) + } + } + if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil { + t.Fatalf("valid strict output rejected: %v", err) + } +} + +func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) { + validProfile := `{"success":true,"result":{"userId":"u1"}}` + validAttendance := `{"success":true,"result":[{"id":1}]}` + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + t.Run(declaration.Command+" success", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") { + t.Fatalf("calls=%#v", caller.calls) + } + request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any) + users, usersOK := request["userIds"].([]string) + if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" { + t.Fatalf("request=%#v", caller.arguments[1]) + } + }) + t.Run(declaration.Command+" profile call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" attendance call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" malformed attendance", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + } +} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 98d2be5f..db2fe0d2 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -15,11 +15,11 @@ package smart import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -71,6 +71,13 @@ var FindMailUser = shortcut.Shortcut{ PrimaryCLIPath: "mail +find-mail-user", }, Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", + Parameters: []contract.ParamDecl{ + // Keep the published composite Shortcut property stable. Execute owns + // the explicit query -> keyword adapter for search_mail_users. + {Name: "query", Property: "query"}, + {Name: "limit", Property: "limit"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -87,8 +94,19 @@ var FindMailUser = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +find-mail-user --query "张三"`, @@ -101,69 +119,109 @@ var FindMailUser = shortcut.Shortcut{ if rt.Changed("limit") { args["size"] = strconv.Itoa(rt.Int("limit")) } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } data, err := rt.CallMCPData("mail", "search_mail_users", args) if err != nil { return err } // Step 2 — project matched users. - users := findMailUserUnwrap(data) + users, err := smartMailCollection(data, "mail/search_mail_users", "users") + if err != nil { + return err + } results := make([]map[string]any, 0, len(users)) for _, u := range users { - results = append(results, map[string]any{ - "name": findMailUserString(u, "name", "displayName", "userName", "nick"), - "nickname": findMailUserString(u, "nickname", "nick", "displayName"), - "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"), - "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"), - "jobTitle": findMailUserString(u, "jobTitle", "title", "position"), - "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"), - "id": findMailUserAny(u, "id", "userId", "userid"), - }) + projected, err := findMailUserProjection(u) + if err != nil { + return err + } + results = append(results, projected) } - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没搜到邮箱联系人") + complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"users": results, "count": len(results)}) + return smartMailOutputPage(rt, "users", results, complete, next) }, } -// findMailUserUnwrap extracts the user list from a search_mail_users response. -// The helper documents the list under "users"; we probe several container keys -// at the top level and one level deep, defensively. -func findMailUserUnwrap(data map[string]any) []map[string]any { - keys := []string{"users", "contacts", "result", "data", "list", "items", "records"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return findMailUserToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return findMailUserToMaps(arr) - } - } - } +func findMailUserProjection(user map[string]any) (map[string]any, error) { + result := map[string]any{ + "name": findMailUserString(user, "name", "displayName", "userName", "nick"), + "nickname": findMailUserString(user, "nickname", "nick", "displayName"), + "employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"), + "jobTitle": findMailUserString(user, "jobTitle", "title", "position"), + "workLocation": findMailUserString(user, "workLocation", "location", "workPlace"), } - return nil + email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account") + if err != nil { + return nil, err + } + if emailPresent { + result["email"] = email + } + id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid") + if err != nil { + return nil, err + } + if idPresent { + result["id"] = id + } + if !emailPresent && !idPresent { + return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email") + } + return result, nil } -func findMailUserToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) +func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + text, ok := value.(string) + if !ok { + return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil } } - return out + return "", false, nil +} + +func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil + } + continue + } + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return value, true, nil + default: + return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字") + } + } + return nil, false, nil } func findMailUserString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -172,8 +230,11 @@ func findMailUserString(m map[string]any, keys ...string) string { func findMailUserAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -182,5 +243,6 @@ func findMailUserAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果") shortcut.Register(FindMailUser) } diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..bd62a5f7 --- /dev/null +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,389 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "fmt" + "io" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} { + rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "}) + if err := declaration.Validate(rt); err == nil { + t.Fatal("direct required query validator accepted whitespace") + } + } + if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil { + t.Fatal("empty response unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil { + t.Fatal("explicit failure unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil { + t.Fatalf("string success rejected: %v", err) + } + if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object lookup unexpectedly succeeded") + } + if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted invalid response") + } + if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted missing identity") + } + + items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty search rejected: %v %v", items, err) + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("negative total unexpectedly succeeded") + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("search row without id unexpectedly succeeded") + } + if smartMailEmptySearchSentinel(map[string]any{}) { + t.Fatal("empty object treated as reviewed sentinel") + } + if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) { + t.Fatal("reviewed null-only sentinel rejected") + } + + for _, tc := range []struct { + value any + want int + ok bool + }{ + {value: 7, want: 7, ok: true}, + {value: float64(8), want: 8, ok: true}, + {value: 1.5, ok: false}, + {value: "9", want: 9, ok: true}, + {value: []any{}, ok: false}, + } { + got, ok := smartMailInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "wrong cursor": {"nextCursor": 7}, + "wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting true": {"nextCursor": "", "hasMore": true}, + "conflicting false": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" { + t.Fatalf("terminal page rejected: %v %q %v", complete, next, err) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing page rejected: %v %q %v", complete, next, err) + } + if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := smartMailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if value, ok := smartMailBool("false"); !ok || value { + t.Fatal("string false not normalized") + } + if _, ok := smartMailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } +} + +func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unsupported schema did not panic") + } + }() + fn() + }) + } + assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") }) + assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") }) + + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + want string + }{ + {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"}, + {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + } { + t.Run("mailbox "+tc.name, func(t *testing.T) { + got, err := searchMailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != tc.want { + t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err) + } + }) + } + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing": {"success": true}, + "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run("mailbox "+name, func(t *testing.T) { + if _, err := searchMailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } + + for _, tc := range []struct { + message map[string]any + want any + }{ + {message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender "}, + {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, + {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + got, err := searchMailFrom(tc.message) + if err != nil || got != tc.want { + t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) + } + } + for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + if _, err := searchMailFrom(message); err == nil { + t.Fatal("malformed sender unexpectedly succeeded") + } + } + if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" { + t.Fatalf("string normalization=%q", got) + } + if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" { + t.Fatalf("any string normalization=%#v", got) + } + if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 { + t.Fatalf("numeric projection=%#v", got) + } + + projected, err := findMailUserProjection(map[string]any{ + "id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ", + "employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ", + }) + if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" { + t.Fatalf("normalized user projection=%#v err=%v", projected, err) + } + if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 { + t.Fatalf("find user any=%#v", got) + } + if got := findMailUserAny(map[string]any{}, "missing"); got != nil { + t.Fatalf("missing optional value=%#v", got) + } +} + +func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + errors map[string]error + wantCalls int + }{ + {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1}, + {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1}, + {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, + {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1}, + {name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1}, + {name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1}, + {name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "triage sender malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1}, + {name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1}, + {name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1}, + {name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1}, + {name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid leaf response unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { + for _, tc := range []struct { + name string + message map[string]any + want any + wantErr bool + }{ + {name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender "}}, + {name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}}, + {name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}}, + {name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true}, + {name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := recentMailSenders(tc.message) + if tc.wantErr && err == nil { + t.Fatal("malformed senders unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid senders failed: %v", err) + } + if !tc.wantErr && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("senders=%#v want=%#v", got, tc.want) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + wantErr bool + wantCalls int + }{ + { + name: "triage resolves mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"}, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 2, + }, + { + name: "triage resolves string mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{ + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`, + }, wantCalls: 2, + }, + { + name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`}, + wantErr: true, wantCalls: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses} + err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid inbox resolution unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid inbox resolution failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"}, + {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"}, + } { + t.Run(tc.name+" typed reason", func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}} + err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture") + typed, ok := err.(*apperrors.Error) + if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 { + reason := "" + if ok { + reason = typed.Reason + } + t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls)) + } + }) + } +} diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go index ba41a90c..fd18632e 100644 --- a/internal/shortcut/smart/mail_space_todo_projection_test.go +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -31,34 +31,6 @@ import ( var errStubTool = errors.New("stub tool error") -// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with -// an email field and bare string arrays, each at the top level or one level -// under a result/data wrapper. The nested bare-string case regressed silently -// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly -// demand an explicit --email. -func TestSearchMailPickMailbox(t *testing.T) { - cases := []struct { - name, raw, want string - }{ - {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"}, - {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"}, - {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"}, - {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"}, - {"empty", `{"result":{"emailAccounts":[]}}`, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var data map[string]any - if err := json.Unmarshal([]byte(tc.raw), &data); err != nil { - t.Fatal(err) - } - if got := searchMailPickMailbox(data); got != tc.want { - t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want) - } - }) - } -} - // TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces // container is probed — otherwise +resolve-space silently finds no candidates. func TestResolveSpaceItemsWikiSpaces(t *testing.T) { @@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error // "provide --email" validation error rather than silently proceeding. func TestSmartSearchMailNoMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[]}`, }} err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json") if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") { @@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) { // the no-mailbox validation error because emailAccounts was not probed. func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`, - "search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`, + "search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`, }} out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json") if strings.Contains(out, "未找到可用邮箱") || out == "" { diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go new file mode 100644 index 00000000..0240bbff --- /dev/null +++ b/internal/shortcut/smart/mail_strict.go @@ -0,0 +1,340 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) { + if !rt.Changed(flag) { + return fallback, nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error { + _, err := smartMailStringPageSize(rt, flag, "") + return err +} + +func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func smartMailSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return smartMailError(operation, "missing_success", "响应缺少 success 业务状态") + } + return smartMailError(operation, "remote_failure", "服务未明确返回成功状态") +} + +func smartMailLookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + value, present := smartMailLookup(data, path) + if !present { + return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, path) + if err != nil { + return nil, err + } + for index, item := range items { + if searchMailFirstString(item, identityKeys...) == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire +// shape observed from search_emails: total="0", terminal nextCursor="$", and +// exactly one placeholder whose only fields are null recipient metadata. It is +// deliberately narrower than a general "drop bad item" rule. +func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, "messages") + if err != nil { + return nil, err + } + total, totalOK := smartMailInt(data["total"]) + next, nextOK := data["nextCursor"].(string) + if totalOK && total == 0 && nextOK && (next == "" || next == "$") { + if len(items) == 0 { + return items, nil + } + if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) { + return []map[string]any{}, nil + } + return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象") + } + if totalOK && total < 0 { + return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数") + } + for index, item := range items { + if searchMailFirstString(item, "id") == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +func smartMailEmptySearchSentinel(item map[string]any) bool { + if len(item) == 0 { + return false + } + allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true} + for key, value := range item { + if !allowed[key] || value != nil { + return false + } + } + return true +} + +func smartMailInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != float64(int(typed)) { + return 0, false + } + return int(typed), true + case string: + parsed, err := strconv.Atoi(typed) + return parsed, err == nil + default: + return 0, false + } +} + +func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := smartMailLookup(data, prefix) + if !present { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误") + } + } + raw, present := container["nextCursor"] + next, ok := raw.(string) + if !present || !ok { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor") + } + if rawMore, present := container["hasMore"]; present { + hasMore, ok := smartMailBool(rawMore) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore == terminal { + return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func smartMailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func smartMailResult(collection, description string) *contract.ResultSpec { + identity := smartMailIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: smartMailSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection)), + } +} + +func smartMailSensitivePaths(collection string) []string { + switch collection { + case "messages": + return []string{"messages.subject", "messages.from", "messages.date"} + case "mails": + return []string{"mails.subject", "mails.from", "mails.date"} + case "users": + return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"} + default: + panic("unsupported smart Mail sensitive paths: " + collection) + } +} + +func smartMailIdentitySchema(collection string) string { + switch collection { + case "messages": + return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]` + case "mails": + return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]` + case "users": + return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]` + default: + panic("unsupported smart Mail Result identity: " + collection) + } +} + +func smartMailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) { + declaration.OutputRollout = output.RolloutUnifiedActive + declaration.Contract.Result = smartMailResult(collection, description) + declaration.Contract.Pagination = smartMailCursorPagination() + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.", + } +} + +func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.", + } +} + +func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := smartMailPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return smartMailError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..14022542 --- /dev/null +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,405 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type smartMailCursorCall struct { + tool string + args map[string]any +} + +type smartMailCursorCaller struct { + responses map[string]string + errors map[string]error + calls []smartMailCursorCall +} + +func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + text, ok := caller.responses[tool] + if !ok { + return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool) + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*smartMailCursorCaller) Format() string { return "json" } +func (*smartMailCursorCaller) DryRun() bool { return false } +func (*smartMailCursorCaller) Fields() string { return "" } +func (*smartMailCursorCaller) JQ() string { return "" } + +func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + result bool + }{ + {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil}, + {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil}, + {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil}, + } { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("%s is not unified with Result", declaration.name) + } + } + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} { + found := false + for _, flag := range declaration.Flags { + if flag.Name != "size" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default) + } + } + if !found { + t.Fatalf("%s missing --size", declaration.Command) + } + cmd := corecmd.New(shortcut.FromShortcut(*declaration)) + if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag) + } + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + wantSize string + }{ + {name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"}, + {name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"}, + {name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"}, + {name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize { + t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize) + } + }) + } + + for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} { + caller := &smartMailCursorCaller{responses: map[string]string{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "mail@example.invalid", "size": "bad"} + if declaration.Command == "+search-mail" { + values["query"] = "fixture" + } + rt := smartMailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command) + } + if len(caller.calls) != 0 { + t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls)) + } + } +} + +func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) { + fixture := map[string]any{ + "success": "true", "total": "0", "nextCursor": "$", + "messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}}, + } + items, err := smartMailSearchRows(fixture, "mail/search_emails") + if err != nil || len(items) != 0 { + t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err) + } + for name, bad := range map[string]map[string]any{ + "unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}}, + "non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}}, + "multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}}, + } { + if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) { + for name, fixture := range map[string]map[string]any{ + "missing success": {"messages": []any{}}, + "missing collection": {"success": "true", "total": "0", "nextCursor": "$"}, + "wrong type": {"success": "true", "messages": map[string]any{}}, + "bad item": {"success": "true", "messages": []any{"bad"}}, + } { + if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) { + if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil { + t.Fatal("malformed message from field must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil { + t.Fatal("non-object conversation sender must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil { + t.Fatal("sender without identity must fail") + } +} + +func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) { + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil { + t.Fatal(err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into business data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + + for _, tc := range []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&SearchMail, "messages", "messageId"}, + {&TriageMail, "messages", "messageId"}, + {&FindMailUser, "users", "email"}, + } { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if identity["description"] == "" { + t.Fatalf("%s missing stable identity", tc.declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor not passed through: %#v", caller.calls) + } + }) + } + caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}}, + {name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}}, + {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}}, + {name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}}, + {name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}}, + {name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}}, + {name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}}, + {name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}}, + {name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}}, + {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}}, + {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}}, + {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}}, + {name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}}, + {name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}}, + {name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid limit reached remote: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"}, + {name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + projected, err := findMailUserProjection(tc.user) + if err != nil { + t.Fatal(err) + } + if _, ok := projected[tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, projected) + } + if _, ok := projected[tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected) + } + }) + } + + caller := &smartMailCursorCaller{responses: map[string]string{ + "search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`, + }} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil { + t.Fatal("identity-free user unexpectedly satisfied runtime Result contract") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := findMailUserProjection(user); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("whitespace stable identity unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want one response validation call", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) { + for _, parameter := range FindMailUser.Contract.Parameters { + if parameter.Name == "query" { + if parameter.Property != "query" { + t.Fatalf("query property = %q, want stable composite property query", parameter.Property) + } + return + } + } + t.Fatal("FindMailUser query parameter declaration is missing") +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 1c445d14..1cbc99a5 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -14,12 +14,14 @@ package smart import ( + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +43,11 @@ import ( // // dws attendance +my-attendance var MyAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+my-attendance", - Product: "attendance", - Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+my-attendance", + Product: "attendance", + Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," + "最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。", @@ -63,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{ }, Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,15 +113,89 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } -// myAttendanceCurrentUserID extracts the current user's userId from a +// strictAttendanceCurrentUserID extracts the current user's userId from a // get_current_user_profile response. The gateway returns one of several shapes, // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} // - {"result": {"userId": ...}} +func strictAttendanceCurrentUserID(data map[string]any) string { + if data == nil { + return "" + } + success, ok := data["success"].(bool) + if !ok || !success { + return "" + } + identities := map[string]struct{}{} + collect := func(item map[string]any) bool { + var direct, nested string + if value, present := item["userId"]; present { + text, valid := value.(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + direct = strings.TrimSpace(text) + } + if value, present := item["orgEmployeeModel"]; present { + object, valid := value.(map[string]any) + if !valid || object == nil { + return false + } + text, valid := object["userId"].(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + nested = strings.TrimSpace(text) + } + if direct == "" && nested == "" { + return false + } + if direct != "" && nested != "" && direct != nested { + return false + } + if direct != "" { + identities[direct] = struct{}{} + } else { + identities[nested] = struct{}{} + } + return true + } + switch result := data["result"].(type) { + case []any: + if len(result) == 0 { + return "" + } + for _, item := range result { + m, ok := item.(map[string]any) + if !ok || !collect(m) { + return "" + } + } + case map[string]any: + if !collect(result) { + return "" + } + default: + return "" + } + if len(identities) != 1 { + return "" + } + var identity string + for candidate := range identities { + identity = candidate + } + return identity +} + +// myAttendanceCurrentUserID preserves the compatibility resolver used by +// older non-Attendance smart workflows. New Attendance shortcuts must use the +// strict resolver above so a missing business success flag cannot consume a +// stale result. func myAttendanceCurrentUserID(data map[string]any) string { if data == nil { return "" diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index c5b920ed..5651fdc8 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -71,6 +74,12 @@ var RecentMail = shortcut.Shortcut{ PrimaryCLIPath: "mail +recent-mail", }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", + Parameters: []contract.ParamDecl{ + {Name: "limit", Property: "limit"}, + {Name: "email", Property: "email"}, + {Name: "folder", Property: "folder"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -90,6 +99,11 @@ var RecentMail = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +recent-mail`, @@ -119,40 +133,47 @@ var RecentMail = shortcut.Shortcut{ // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. - size := rt.Int("limit") - if size <= 0 { - size = 20 + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") } - if size > 100 { - size = 100 - } - data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{ + args := map[string]any{ "email": email, "folderId": folderID, - "size": size, - }) + "size": limit, + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", args) if err != nil { return err } // Step 4 — project each conversation to a compact record. - threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records") + threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id") + if err != nil { + return err + } results := make([]map[string]any, 0, len(threads)) for _, t := range threads { + from, err := recentMailSenders(t) + if err != nil { + return err + } results = append(results, map[string]any{ "subject": searchMailFirstString(t, "subject", "title", "topic"), - "from": recentMailSenders(t), + "from": from, "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), }) } - // Empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("最近没有邮件") + complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"count": len(results), "mails": results}) + return smartMailOutputPage(rt, "mails", results, complete, next) }, } @@ -166,12 +187,15 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e if err != nil { return "", err } - folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records") + folders, err := smartMailRows(data, "mail/list_folders", "folders", "id") + if err != nil { + return "", err + } inboxNames := map[string]bool{ "收件箱": true, "inbox": true, } for _, f := range folders { - name := searchMailFirstString(f, "displayName", "name", "folderName") + name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName"))) if inboxNames[name] { if id := searchMailFirstString(f, "id", "folderId"); id != "" { return id, nil @@ -184,16 +208,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e // recentMailSenders reads a conversation's sender list. The helper documents a // "senders" array of {email, name}; we tolerate a plain string, a single object, // and fall back to the shared searchMailFrom logic. -func recentMailSenders(t map[string]any) any { - if arr, ok := t["senders"].([]any); ok && len(arr) > 0 { +func recentMailSenders(t map[string]any) (any, error) { + raw, present := t["senders"] + if arr, ok := raw.([]any); ok && len(arr) > 0 { names := make([]string, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) - if !ok { - if s, ok := it.(string); ok && s != "" { - names = append(names, s) - } - continue + if !ok || len(m) == 0 { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index)) } name := searchMailFirstString(m, "name", "displayName") addr := searchMailFirstString(m, "email", "emailAddress", "address") @@ -204,15 +226,23 @@ func recentMailSenders(t map[string]any) any { names = append(names, addr) case name != "": names = append(names, name) + default: + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index)) } } if len(names) > 0 { - return names + return names, nil + } + } else if present { + if _, ok := raw.([]any); !ok { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组") } } return searchMailFrom(t) } func init() { + hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") + markSmartMailCompatibilityOnly(&RecentMail) shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index a6d824e2..d0d2a22b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -79,9 +82,20 @@ var SearchMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, @@ -92,6 +106,10 @@ var SearchMail = shortcut.Shortcut{ if err := rt.RequireAll("query"); err != nil { return err } + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") @@ -105,31 +123,42 @@ var SearchMail = shortcut.Shortcut{ // Step 2 — search that mailbox. email/query/size mirror the search_emails // call in helpers.messageSearch; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": rt.Str("query"), "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record. - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } @@ -142,115 +171,106 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - if addr := searchMailPickMailbox(data); addr != "" { - return addr, nil + mailboxes, err := searchMailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) > 0 { + return mailboxes[0], nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } -// searchMailPickMailbox resolves the first usable mailbox address from a -// list_user_mailboxes response. It tolerates every observed shape: objects with -// an email field (result.emailAccounts[].email) and bare string arrays -// (result.emailAccounts[]), each at the top level or one level under a -// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string -// case is scanned separately — otherwise all three smart mail shortcuts wrongly -// report that no mailbox exists. -func searchMailPickMailbox(data map[string]any) string { - containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"} - for _, m := range searchMailUnwrapList(data, containers...) { - if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { - return addr - } +// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes: +// non-empty address strings and non-empty objects carrying an email field. +// The collection itself and every item remain fail-closed, so malformed rows +// cannot be skipped and misreported as an empty mailbox list. +func searchMailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := smartMailSuccess(data, operation); err != nil { + return nil, err } - scanStrings := func(m map[string]any) string { - for _, key := range containers { - if arr, ok := m[key].([]any); ok { - for _, it := range arr { - if s, ok := it.(string); ok && s != "" { - return s - } - } - } + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := smartMailLookup(data, path) + if !present { + continue } - return "" - } - if s := scanStrings(data); s != "" { - return s - } - for _, wrap := range []string{"result", "data"} { - if inner, ok := data[wrap].(map[string]any); ok { - if s := scanStrings(inner); s != "" { - return s - } + if selectedPath != "" { + return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) } + value = candidate + selectedPath = path } - return "" -} - -// searchMailMessages extracts the message list from a search_emails response. -// The helper documents the list under "messages". -func searchMailMessages(data map[string]any) []map[string]any { - return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records") -} - -// searchMailUnwrapList probes the given container keys at the top level, and one -// level deep, returning the first list found as []map[string]any. -func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any { - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMailToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return searchMailToMaps(arr) - } - } - } + if selectedPath == "" { + return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") } - return nil -} - -func searchMailToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) } - return out + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + address = searchMailFirstString(typed, "email") + default: + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil } // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). -func searchMailFrom(m map[string]any) any { - switch v := m["from"].(type) { +func searchMailFrom(m map[string]any) (any, error) { + raw, present := m["from"] + switch v := raw.(type) { case string: - if v != "" { - return v + if normalized := strings.TrimSpace(v); normalized != "" { + return normalized, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") addr := searchMailFirstString(v, "email", "emailAddress", "address") switch { case name != "" && addr != "": - return name + " <" + addr + ">" + return name + " <" + addr + ">", nil case addr != "": - return addr + return addr, nil case name != "": - return name + return name, nil } + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱") + case nil: + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null") + } + default: + return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw)) } if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { - return s + return s, nil } - return "" + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空") + } + return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段") } func searchMailFirstString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -259,8 +279,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string { func searchMailFirstAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -269,5 +292,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要") shortcut.Register(SearchMail) } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index 3363e6f8..641c5576 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -20,6 +20,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +42,11 @@ import ( // // dws attendance +this-month var ThisMonthAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+this-month", - Product: "attendance", - Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+this-month", + Product: "attendance", + Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," + "最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。", @@ -63,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{ }, Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,7 +112,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go new file mode 100644 index 00000000..7f32ee13 --- /dev/null +++ b/internal/shortcut/smart/triage_mail.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// TriageMail aligns the Lark task-level triage entry while preserving DWS's +// easier mailbox and inbox resolution. It returns summaries only; callers use +// +message or +messages for full bodies. +var TriageMail = shortcut.Shortcut{ + Service: "mail", Command: "+triage", Product: "mail", + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage", + CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage", + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."}, + Selection: contract.SelectionSpec{ + AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"}, + AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"}, + Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`}, + }, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}}, + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", true) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + query := rt.Str("query") + if query == "" { + folderID, err := recentMailInboxFolder(rt, email) + if err != nil { + return err + } + query = "folderId:" + folderID + } + args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))} + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) + if err != nil { + return err + } + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + from, err := searchMailFrom(message) + if err != nil { + return err + } + rows = append(rows, map[string]any{ + "messageId": searchMailFirstString(message, "id"), + "subject": searchMailFirstString(message, "subject"), + "from": from, + "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"), + }) + } + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", rows, complete, next) + }, +} + +func init() { + hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据") + shortcut.Register(TriageMail) +} diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index e72a91fa..8299663d 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -80,7 +80,12 @@ var UnreadMail = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +unread-mail`, @@ -88,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{ `dws mail +unread-mail --size 50`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") if email == "" { @@ -101,34 +110,47 @@ var UnreadMail = shortcut.Shortcut{ // Step 2 — search unread mail. email/query/size mirror the search_emails // call in helpers.messageSearch; the isRead:false KQL filter matches the // isRead field documented there; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": "isRead:false", "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record (shared helpers). - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } func init() { + hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") + markSmartMailCompatibilityOnly(&UnreadMail) shortcut.Register(UnreadMail) } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index b3545f36..9d9f5836 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -195,6 +195,10 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // CompatibilityVisible preserves a historically visible CLI command while + // keeping it out of the Agent/public Shortcut catalog. Such a command is + // shown only by `dws shortcut list --all` and must remain unavailable. + CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live // fixture evidence. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index cd59dd51..f4677bcf 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -56,7 +56,7 @@ func newListCommand() *cobra.Command { if svc != "" && s.Service != svc { continue } - if s.Hidden && !includeHidden { + if (s.Hidden || s.CompatibilityVisible) && !includeHidden { continue } rows = append(rows, newShortcutListRow(s)) @@ -75,23 +75,24 @@ func newListCommand() *cobra.Command { } type shortcutListRow struct { - Service string `json:"service"` - Command string `json:"command"` - CLIPath string `json:"cli_path"` - Product string `json:"product"` - Risk string `json:"risk"` - Confirmation string `json:"confirmation"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - Flags []shortcut.Flag `json:"flags"` - Constraints []shortcut.Constraint `json:"constraints"` - Examples []string `json:"examples"` - Public bool `json:"public"` - Disposition string `json:"disposition,omitempty"` - SemanticDelta string `json:"semantic_delta,omitempty"` - Availability string `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Reviewed bool `json:"reviewed"` + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` + Public bool `json:"public"` + Disposition string `json:"disposition,omitempty"` + SemanticDelta string `json:"semantic_delta,omitempty"` + Availability string `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Reviewed bool `json:"reviewed"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` } func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { @@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { constraints := append([]shortcut.Constraint{}, s.Constraints...) examples := append([]string{}, s.Tips...) return shortcutListRow{ - Service: s.Service, - Command: s.Command, - CLIPath: s.Service + " " + s.Command, - Product: product, - Risk: risk, - Confirmation: confirmation, - Description: s.Description, - Intent: s.Intent, - Flags: flags, - Constraints: constraints, - Examples: examples, - Public: !s.Hidden, - Disposition: string(s.Disposition), - SemanticDelta: s.SemanticDelta, - Availability: string(s.Availability), - Primary: s.PrimaryCommand, - Reviewed: s.SemanticReviewed, + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + Public: !s.Hidden && !s.CompatibilityVisible, + Disposition: string(s.Disposition), + SemanticDelta: s.SemanticDelta, + Availability: string(s.Availability), + Primary: s.PrimaryCommand, + Reviewed: s.SemanticReviewed, + CompatibilityVisible: s.CompatibilityVisible, } } diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index 3d1a103c..45040408 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -47,6 +47,11 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) Service: "coverage-usage", Command: "+hidden", }) + shortcut.Register(shortcut.Shortcut{ + Service: "coverage-usage", + Command: "+compatibility-visible", + CompatibilityVisible: true, + }) execute := func(args ...string) map[string]any { t.Helper() @@ -66,9 +71,20 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) publicRows := execute("--service", "coverage-usage") allRows := execute("--service", "coverage-usage", "--all") - if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 { + if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 2 { t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"]) } + rows := allRows["shortcuts"].([]any) + foundCompatibilityVisible := false + for _, value := range rows { + row := value.(map[string]any) + if row["command"] == "+compatibility-visible" { + foundCompatibilityVisible = row["compatibility_visible"] == true && row["public"] == false + } + } + if !foundCompatibilityVisible { + t.Fatalf("compatibility-visible shortcut row lost its non-public marker: %#v", rows) + } missing := execute("--service", "__missing__") if missing["count"].(float64) != 0 { t.Fatalf("missing service returned shortcuts: %#v", missing) diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go new file mode 100644 index 00000000..9b019a89 --- /dev/null +++ b/internal/upgrade/canonical_skills_test.go @@ -0,0 +1,521 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + // beta.6 wrote a physical copy into Codex. The canonical migration must + // remove it without treating a matching user/market name as DWS-owned. + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + + canonical := filepath.Join(home, ".agents", "skills") + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) { + t.Fatalf("universal Codex duplicate remains for %s: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s link = %#v, %v", link, info, err) + } + if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil { + t.Fatalf("%s does not resolve canonical content: %v", link, err) + } + } + } + result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("idempotent upgrade = %#v, %v", result, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) { + home := withFakeHome(t) + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + externalClaude := filepath.Join(t.TempDir(), "claude") + for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { + switch name { + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeBase := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeBase, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("custom CODEX_HOME duplicate remains: %v", err) + } + for _, base := range []string{ + claudeBase, + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".clawdbot", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + path := filepath.Join(base, name) + info, statErr := os.Lstat(path) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr) + } + body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), name) { + t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr) + } + } + } +} + +func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") }) + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + info, err := os.Lstat(dest) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } + if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil { + t.Fatalf("copy fallback content missing: %v", err) + } +} + +func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. + home := t.TempDir() + testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") + originalStat := upgradeStat + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + if path == zcodeApp || path == minimaxApp { + return appInfo, nil + } + return originalStat(path) + }) + want := map[string]bool{ + ".config/kimchi/harness/skills": false, + ".tabnine/agent/skills": false, + ".zcode/skills": false, + ".minimax/skills": false, + } + for _, root := range configuredSkillRoots(home) { + label := filepath.ToSlash(root.label) + if _, ok := want[label]; ok { + want[label] = skillRootDetectedBase(root) + } + } + for label, detected := range want { + if !detected { + t.Errorf("root %s was not detected", label) + } + } +} + +func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { + home := t.TempDir() + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "custom-autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"), + "CODEX_HOME": filepath.Join(home, "custom-codex"), + "GROK_HOME": filepath.Join(home, "custom-grok"), + "HERMES_HOME": filepath.Join(home, "custom-hermes"), + "VIBE_HOME": filepath.Join(home, "custom-vibe"), + "XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"), + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] }) + roots := configuredSkillRoots(home) + if len(roots) != len(knownSkillDirs) { + t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs)) + } + byLabel := make(map[string]resolvedSkillRoot, len(roots)) + for _, root := range roots { + if _, duplicate := byLabel[root.label]; duplicate { + t.Fatalf("duplicate configured label %s", root.label) + } + byLabel[root.label] = root + } + wants := map[string]string{ + ".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + ".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + ".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"), + ".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"), + ".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"), + ".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"), + ".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + ".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + ".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + ".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + ".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + ".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for label, want := range wants { + if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) { + t.Errorf("configured %s = %q, want %q", label, got, want) + } + } + for _, label := range []string{ + ".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills", + ".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills", + ".gemini/skills", ".copilot/skills", ".config/opencode/skills", + } { + if !byLabel[label].universal { + t.Errorf("%s not classified universal", label) + } + } + if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal { + t.Fatal("canonical/non-universal classification drift") + } +} + +func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + withFakeHome(t) + // Canonical-only configuration (no non-universal dependents): a failed + // canonical publish must still fail the whole upgrade. Universal agents + // read canonical directly, so silently succeeding here would leave the + // machine without any usable Skill while reporting success. + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) + testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) + + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + } else { + src = writeMultiBundle(t, src, "dingtalk-chat") + } + result, err := UpgradeSkillLocations(src) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("canonical publish failure must propagate an error, got (%v)", err) + } + if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("result = %#v", result) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + dest := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("mkdir", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") { + t.Fatalf("mkdir failure = %v", err) + } + }) + t.Run("temp", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") { + t.Fatalf("temp failure = %v", err) + } + }) + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == dest { + return "", errors.New("physical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical failure = %v", err) + } + }) + t.Run("canonical-target", func(t *testing.T) { + target := filepath.Join(canonical, "dingtalk-chat") + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == target { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical failure = %v", err) + } + }) + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + makeSource := func(t *testing.T) string { + t.Helper() + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + return src + } + return writeMultiBundle(t, src, "dingtalk-chat") + } + + t.Run("undetected-and-physical-alias", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped { + t.Fatalf("skip branches = %#v", result.Results) + } + }) + + t.Run("universal-retire-failure", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if mode == "multi" { + useUpgradeManagedNames(t, "dingtalk-chat") + } + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { + if src == victim { + return "", errors.New("retire denied") + } + return origRename(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatalf("retire failure must not fail the upgrade: %v", err) + } + if len(result.Failed()) != 0 { + t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed()) + } + warnings := result.RetireWarnings() + if len(warnings) != 1 || warnings[0].Err == nil { + t.Fatalf("retire warnings = %#v", warnings) + } + if len(result.Succeeded()) == 0 { + t.Fatalf("canonical must still be published: %#v", result) + } + if _, statErr := os.Lstat(victim); statErr != nil { + t.Fatalf("unretired copy must be preserved: %v", statErr) + } + }) + + t.Run("universal-retire-success", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + useUpgradeManagedNames(t, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("retire success = %#v, %v", result, err) + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Fatalf("universal victim remains: %v", err) + } + }) + + t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) { + t.Run("victim-scan", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + origReadDir := upgradeReadDir + testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) { + if path == base { + return nil, errors.New("scan denied") + } + return origReadDir(path) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("victim scan = %#v, %v", result, err) + } + }) + + t.Run("fallback-copy", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") }) + origCopy := upgradeCopyDir + testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { + if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) { + return errors.New("copy denied") + } + return origCopy(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("fallback copy = %#v, %v", result, err) + } + }) + }) + }) + } +} + +func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) { + testseam.Swap(t, &upgradeFoldPathCase, true) + if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) { + t.Fatalf("case-folded path key = %q", got) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"}) + if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 { + t.Fatalf("deduplicated roots = %#v", roots) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 960d81f7..061d7d68 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) { } knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"} result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 { + if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 { t.Fatalf("skill upgrade = %#v, %v", result, err) } knownSkillDirs = []string{".real/skills"} @@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { }) home := t.TempDir() upgradeUserHomeDir = func() (string, error) { return home, nil } - knownSkillDirs = []string{".agents/skills"} + knownSkillDirs = []string{".agents/skills", ".claude/skills"} + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } calls := 0 upgradeCopyDir = func(src, dst string) error { calls++ @@ -593,8 +596,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { t.Fatal(err) } result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 { - t.Fatalf("replaced primary fallback = %#v, %v", result, err) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical failure = %#v, %v", result, err) } failure := errors.New("ensure failed") upgradeEnsureDir = func(string, os.FileMode) error { return failure } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index a83309d3..3261cde1 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "sort" "strings" @@ -35,46 +36,133 @@ const ( // - test/scripts/package_script_test.go expectedPackagedSkillTargets // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // -// The first entry (.agents/skills) is a generic fallback. It is used only -// when no concrete Agent home is detected; otherwise publishing there would -// duplicate every Skill for Agents (including Codex) that scan both roots. -// Subsequent entries are updated when their parent directory already exists. +// The first entry (.agents/skills) is the canonical global Skill directory, +// following the universal .agents/skills convention. +// Agents that understand the universal .agents convention read it directly; +// detected non-universal Agents receive relative links to this canonical +// copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", + // DWS-only target retained for product compatibility. ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only, never publish new copies. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +} + +// universalSkillDirs are concrete Agent directories whose current clients +// natively scan ~/.agents/skills. Publishing another copy or link below these +// roots makes the same Skill appear twice. Keep this classification aligned +// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally +// non-universal because it is a DWS-specific integration absent upstream. +var universalSkillDirs = map[string]bool{ + ".config/agents/skills": true, + ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, + ".codex/skills": true, + ".cursor/skills": true, + ".deepagents/agent/skills": true, + ".firebender/skills": true, + ".gemini/skills": true, + ".copilot/skills": true, + ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only as well. + ".github/skills": true, + ".windsurf/skills": true, + ".cline/skills": true, + ".amp/skills": true, } var ( - upgradeUserHomeDir = os.UserHomeDir - upgradeExecutable = os.Executable - upgradeEvalSymlinks = filepath.EvalSymlinks - upgradeCopyDir = copyDir - upgradeEnsureDir = ensureDir - upgradeRemoveAll = os.RemoveAll - upgradeMkdirAll = os.MkdirAll - upgradeMkdirTemp = os.MkdirTemp - upgradeReadDir = os.ReadDir - upgradeStat = os.Stat - upgradeBuildProvenance = skillprovenance.Build - upgradeReadSkillState = skillstate.Read - upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } - upgradeWriteSkillState = skillstate.Write - upgradeNow = time.Now + upgradeUserHomeDir = os.UserHomeDir + upgradeExecutable = os.Executable + upgradeEvalSymlinks = filepath.EvalSymlinks + upgradeCopyDir = copyDir + upgradeEnsureDir = ensureDir + upgradeRemoveAll = os.RemoveAll + upgradeMkdirAll = os.MkdirAll + upgradeMkdirTemp = os.MkdirTemp + upgradeReadDir = os.ReadDir + upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv + upgradeBuildProvenance = skillprovenance.Build + upgradeReadSkillState = skillstate.Read + upgradePublishSkillPath = PublishSkillPathNoReplace + upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications + upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } + upgradeWriteSkillState = skillstate.Write + upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -85,43 +173,64 @@ const skillBackupSubdir = ".dws/skill-backups" // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it, and returns the backup path. It is -// fail-safe: a directory that cannot be backed up is NOT removed and the +// fail-safe: a path that cannot be backed up is NOT removed and the // error is returned so the caller can surface it (and never install the -// opposite layout next to it silently). Missing paths and regular files are -// no-ops ("", nil). +// opposite layout next to it silently). Missing paths are no-ops. Directories, +// links (including dangling links), and ordinary files are all preserved: +// an unexpected file at a managed destination must never be overwritten. func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeStat(dir) + _, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() { - return "", nil - } rel, relErr := filepath.Rel(homeDir, dir) if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { rel = filepath.Base(dir) } name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel) stamp := upgradeBackupStamp() - backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) - target := filepath.Join(backupRoot, name) - for i := 1; ; i++ { - if _, err := os.Stat(target); os.IsNotExist(err) { - break + if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil { + return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err) + } + var backupRoot, target string + for i := 0; ; i++ { + candidate := stamp + if i > 0 { + candidate = fmt.Sprintf("%s-%d", stamp, i) } - backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) - target = filepath.Join(backupRoot, name) if i > 1000 { - return "", fmt.Errorf("备份目录冲突无法解决: %s", target) + return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate)) } + backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate) + target = filepath.Join(backupRoot, name) + if _, err := upgradeLstat(target); err == nil { + continue // payload name already taken in this stamp root + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) + } + if err := skillPathMkdir(backupRoot, dirPermShared); err == nil { + // We created the root: stamp ownership before any payload moves + // in, so an interrupted backup never leaves an unmarked + // (never-prunable) stamp behind. + if err := skillBackupWriteMarker(backupRoot); err != nil { + // Bounded cleanup: the root is empty and ours alone. + _ = skillPathRemove(backupRoot) + return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err) + } + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) + } else if skillBackupMarkerValid(backupRoot) { + break // proven ours: same-stamp payloads from this run reuse it + } + // A stamp-shaped directory we cannot prove ours must never be + // adopted — writing the marker or the payload into it would turn + // its foreign contents into prunable DWS backups. } - if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { - return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) - } - if err := upgradeRename(dir, target); err != nil { + if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } // Keep the backup directory bounded; a prune failure must not fail the @@ -151,13 +260,17 @@ const ( SkillDirSkipped // agent not detected, directory skipped SkillDirBlacklisted // blacklisted, never touched SkillDirFailed // installation attempted but failed + // SkillDirRetireWarning marks a universal Agent whose obsolete private copy + // could not be retired. Nothing is installed below such a root, so the + // leftover is reported without counting as an install failure. + SkillDirRetireWarning ) // SkillDirResult holds the per-directory install result. type SkillDirResult struct { Dir string // destination directory (e.g. ~/.claude/skills/dws) Status SkillDirStatus // outcome - Err error // non-nil when Status == SkillDirFailed + Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning } // SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call. @@ -187,6 +300,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { return out } +// RetireWarnings returns universal Agent roots that still hold an obsolete +// private copy because retiring it failed. +func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult { + var out []SkillDirResult + for _, d := range r.Results { + if d.Status == SkillDirRetireWarning { + out = append(out, d) + } + } + return out +} + // UpgradeSkillLocations refreshes skills from extractedDir into agent homes. // extractedDir may be a multi-skill bundle root (subdirectories each containing // SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that @@ -204,11 +329,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { // `dws skill setup --mode mono` flow. // // Strategy (matches npm install.js installSkillsToHomes): -// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their -// parent directory exists (e.g. ~/.codex/ exists => user has Codex) -// - ~/.agents/skills/ is used only when no concrete Agent is detected +// - ~/.agents/skills/ is always the canonical global store +// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the +// canonical store directly, so old agent-specific copies are retired +// - detected non-universal Agents receive relative links to canonical; +// filesystems that reject links receive a direct-copy fallback // - ~/.real/ and other blacklisted paths are NEVER touched -// - If no location was updated at all, fall back to ~/.agents/skills/ +// - canonical publication is mandatory and fails the upgrade loudly // // Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then // removed so mono and multi never co-exist after an upgrade; a leftover that @@ -277,9 +404,54 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// The ownership marker inside a stamp root. Every DWS surface writes the +// exact same bytes (install.sh, install-skills.sh, install-event.sh, +// install-devapp.sh, the PowerShell installers, and build/npm/install.js): +// a stamp-shaped directory name alone is not ownership proof, because users +// and unrelated tools can create 20260819-120000-shaped names. Counting or +// deleting a backup root requires the marker to verify, and adopting an +// existing root for a new backup requires it first. +const ( + skillBackupMarkerName = ".dws-skill-backup" + skillBackupMarkerContent = "dws skill backup v1\n" +) + +var ( + skillBackupReadMarker = func(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName)) + return string(data), err + } + skillBackupWriteMarker = func(root string) error { + return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644) + } +) + +// skillBackupMarkerValid reports whether root carries the ownership marker +// with exactly the expected bytes; a missing, unreadable, or differently +// worded marker means the directory is foreign data. +func skillBackupMarkerValid(root string) bool { + body, err := skillBackupReadMarker(root) + return err == nil && body == skillBackupMarkerContent +} + +// skillBackupStampRegexp matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`) + +// isSkillBackupStamp reports whether name is a DWS-created backup stamp. +func isSkillBackupStamp(name string) bool { + return skillBackupStampRegexp.MatchString(name) +} + // pruneSkillBackups removes the oldest backup directories when more than -// skillBackupKeep remain. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// skillBackupKeep remain. Only directories whose names match the DWS backup +// stamp format AND whose ownership marker verifies are candidates; unknown +// or unmarked directories — including stamp roots created before the +// ownership marker existed — are foreign data and preserved. Best-effort: +// a removal failure never aborts, but pruning failures are reported so +// callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -291,7 +463,12 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { + if !e.IsDir() || !isSkillBackupStamp(e.Name()) { + continue + } + // A stamp-shaped name alone is not ownership proof: only roots + // whose marker verifies are counted or deleted. + if skillBackupMarkerValid(filepath.Join(root, e.Name())) { names = append(names, e.Name()) } } @@ -404,20 +581,15 @@ func uniqueSkillDirs(paths []string) []string { // restoreSkillSet removes any newly published directories, then restores all // original directories in reverse backup order. -func restoreSkillSet(published []string, backups []backedUpSkillDir) error { - var restoreErr error - 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)) - } - } +func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error { + restoreErr := upgradeRollbackPublishedSkillPaths(published) for i := len(backups) - 1; i >= 0; i-- { backup := backups[i] if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err)) continue } - if err := upgradeRename(backup.backup, backup.original); err != nil { + if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err)) } } @@ -450,16 +622,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st if err != nil { return err } - published := make([]string, 0, len(staged)) + published := make([]SkillPathPublication, 0, len(staged)) for _, skill := range staged { - if err := upgradeRename(skill.staged, skill.dest); err != nil { - publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err) + publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest) + if publishErr != nil { + publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr) if restoreErr := restoreSkillSet(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr)) } return publishErr } - published = append(published, skill.dest) + published = append(published, publication) } return nil } @@ -517,100 +690,274 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str return publishStagedSkillSet(homeDir, staged, victims) } -func hasDetectedSpecificSkillRoot(homeDir string) bool { - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) { - continue - } - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if info, err := upgradeStat(parentGate); err == nil && info.IsDir() { - return true - } - } - return false -} - func isGenericSkillRoot(agentDir string) bool { return filepath.Clean(agentDir) == filepath.Clean(".agents/skills") } -func retireGenericSkillRoot(homeDir string, managed map[string]bool) error { - base := filepath.Join(homeDir, ".agents", "skills") +func isUniversalSkillRoot(agentDir string) bool { + return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] +} + +type resolvedSkillRoot struct { + base string + label string + universal bool + canonical bool +} + +func resolveOpenClawSkillBase(homeDir string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(homeDir, name) + if info, err := upgradeStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(homeDir, ".openclaw", "skills") +} + +func configuredSkillRoots(homeDir string) []resolvedSkillRoot { + var roots []resolvedSkillRoot + seen := map[string]bool{} + configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(homeDir, ".config") + } + add := func(base, label string, universal, canonical bool) { + key := skillRootPathKey(base) + if seen[key] { + return + } + seen[key] = true + roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical}) + } + for _, agentDir := range knownSkillDirs { + base := filepath.Join(homeDir, agentDir) + switch filepath.ToSlash(filepath.Clean(agentDir)) { + case ".claude/skills": + if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".codex/skills": + if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".hermes/skills": + if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".autohand/skills": + if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".grok/skills": + if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".vibe/skills": + if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".openclaw/skills": + base = resolveOpenClawSkillBase(homeDir) + case ".config/agents/skills": + base = filepath.Join(configHome, "agents", "skills") + case ".config/opencode/skills": + base = filepath.Join(configHome, "opencode", "skills") + case ".config/crush/skills": + base = filepath.Join(configHome, "crush", "skills") + case ".config/devin/skills": + base = filepath.Join(configHome, "devin", "skills") + case ".config/goose/skills": + base = filepath.Join(configHome, "goose", "skills") + case ".config/kimchi/harness/skills": + base = filepath.Join(configHome, "kimchi", "harness", "skills") + } + add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir)) + } + return roots +} + +func skillRootDetectedBase(root resolvedSkillRoot) bool { + detectedDir := filepath.Dir(root.base) + switch filepath.ToSlash(filepath.Clean(root.label)) { + case ".config/kimchi/harness/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".zcode/skills": + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so upgrade matches npm, Shell, and PowerShell. + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true + } + case ".minimax/skills": + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true + } + } + info, err := upgradeStat(detectedDir) + return err == nil && info.IsDir() +} + +func samePhysicalSkillRoot(left, right string) bool { + leftReal, leftErr := upgradeEvalSymlinks(left) + rightReal, rightErr := upgradeEvalSymlinks(right) + if leftErr != nil || rightErr != nil { + return false + } + return skillRootPathKey(leftReal) == skillRootPathKey(rightReal) +} + +func skillRootPathKey(path string) string { + clean := filepath.Clean(path) + if upgradeFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + +func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { return err } victims = append(victims, filepath.Join(base, "dws")) if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err) + return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err) } return nil } +func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) { + if err := upgradeMkdirAll(destBase, dirPermShared); err != nil { + return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err) + } + stageRoot, err = upgradeMkdirTemp(destBase, prefix) + if err != nil { + return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err) + } + defer func() { + if err != nil { + _ = upgradeRemoveAll(stageRoot) + } + }() + realDestBase, realErr := upgradeEvalSymlinks(destBase) + if realErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr) + } + for _, name := range names { + target := filepath.Join(canonicalBase, name) + realTarget, targetErr := upgradeEvalSymlinks(target) + if targetErr != nil { + return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr) + } + relTarget, relErr := upgradeRel(realDestBase, realTarget) + if relErr != nil { + return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) + } + stagedPath := filepath.Join(stageRoot, name) + if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil { + return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr) + } + staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)}) + } + return stageRoot, staged, nil +} + +func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error { + needed := make([]string, 0, len(names)) + correct := make(map[string]bool, len(names)) + for _, name := range names { + dest := filepath.Join(destBase, name) + canonical := filepath.Join(canonicalBase, name) + if samePhysicalSkillRoot(dest, canonical) { + correct[filepath.Clean(dest)] = true + continue + } + needed = append(needed, name) + } + filteredVictims := make([]string, 0, len(victims)) + for _, victim := range victims { + if !correct[filepath.Clean(victim)] { + filteredVictims = append(filteredVictims, victim) + } + } + stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed) + if err != nil { + return err + } + defer func() { _ = upgradeRemoveAll(stageRoot) }() + return publishStagedSkillSet(homeDir, staged, filteredVictims) +} + // upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory // per agent home. func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) { result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + canonicalDest := filepath.Join(canonicalBase, "dws") + if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) + // Canonical is the mandatory store: universal agents read it directly, so a + // failed canonical publish means nobody received the upgrade. Always fail + // loud — including universal-only machines — instead of reporting success + // with nothing installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - destDir := filepath.Join(homeDir, agentDir, "dws") - - if isBlacklisted(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { + continue + } + destBase := root.base + destDir := filepath.Join(destBase, "dws") + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetectedBase(root) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + continue + } + if root.universal { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil { + // An uncertain destination may belong to a concurrent writer. + // The copy fallback below would back that object aside and + // replace it — exactly what the sentinel forbids — so surface + // the uncertain state instead of retrying over it. + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + continue + } + // Match the upstream installer: platforms/filesystems that reject + // links receive a direct copy rather than losing Agent support. + if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location. The multi - // leftovers under the primary base are the usual reason the primary - // install failed, so clean them first — failing loud like the multi - // fallback — instead of letting mono and multi co-exist marked OK. - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - dest := filepath.Join(destBase, "dws") - if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == dest { - result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level mono cache so that // `dws skill setup --mode mono` fallbacks stay on the upgraded version @@ -633,61 +980,62 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) + // Canonical is the mandatory store (see the mono branch): fail loud even + // on universal-only machines instead of reporting success with nothing + // installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - destBase := filepath.Join(homeDir, agentDir) - - if isBlacklisted(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { + continue + } + destBase := root.base + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetectedBase(root) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(destBase) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + continue + } + if root.universal { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil { + // An uncertain destination may belong to a concurrent writer; + // the copy fallback must not displace it (see the mono branch). + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + continue + } + if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == destBase { - result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level caches so that `dws skill setup` // fallbacks stay on the upgraded version. The release zip ships both @@ -762,23 +1110,6 @@ func skillTreeHasRoot(dir string) bool { return err == nil && !info.IsDir() } -// cleanupMultiLeftovers backs up + removes every proven DWS-managed -// multi-mode skill directory inside one agent home before mono is installed. -// A missing base directory simply means no leftovers; any other read failure -// is reported so mono never silently co-exists with multi. Removal is -// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a -// backup failure aborts the removal for that home. -func cleanupMultiLeftovers(homeDir, baseDir string) error { - victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理 multi 残留失败: %w", err) - } - return nil -} - func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) { entries, err := upgradeReadDir(baseDir) if err != nil { @@ -789,7 +1120,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str } victims := make([]string, 0) for _, e := range entries { - if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(baseDir, e.Name())) @@ -797,24 +1128,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str return victims, nil } -// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the -// legacy mono directory (dws/) and every proven DWS-managed multi skill -// directory that is not part of the new bundle. A dingtalk-* prefix alone is -// not proof of ownership because market/user skills may use the same prefix. -// Removal is reversible: each -// directory is preserved under ~/.dws/skill-backups/ and a backup failure -// aborts the removal for that home. -func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error { - victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理对面模式残留失败: %w", err) - } - return nil -} - func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) { victims := []string{filepath.Join(destBase, "dws")} entries, err := upgradeReadDir(destBase) @@ -825,7 +1138,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err) } for _, e := range entries { - if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(destBase, e.Name())) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ec55c21d..a6f2f7a9 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -22,6 +23,16 @@ func withFakeHome(t *testing.T) string { t.Helper() home := t.TempDir() testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil }) + // Host-installed macOS apps must not make a fake HOME appear to have extra + // detected Agents. Tests that exercise application-based detection can + // override this seam explicitly. + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + applications := filepath.Join(string(filepath.Separator), "Applications") + if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") { + return nil, os.ErrNotExist + } + return os.Stat(path) + }) return home } @@ -124,8 +135,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { home := withFakeHome(t) - // A concrete Agent root wins over the generic .agents fallback; .claude - // installs and .cursor is skipped. + // .agents is canonical; detected Claude receives links and Cursor is skipped. agentsBase := filepath.Join(home, ".agents", "skills") claudeBase := filepath.Join(home, ".claude", "skills") for _, base := range []string{agentsBase, claudeBase} { @@ -193,17 +203,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { // Succeeded entries report the agent home base in multi mode. succeeded := result.Succeeded() - if len(succeeded) != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results) + if len(succeeded) != 2 { + t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results) } - wantDirs := map[string]bool{claudeBase: true} + wantDirs := map[string]bool{agentsBase: true, claudeBase: true} for _, d := range succeeded { if !wantDirs[d.Dir] { t.Errorf("unexpected succeeded dir %q", d.Dir) } } - if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { - t.Fatalf("generic mono duplicate still visible: %v", err) + for _, name := range []string{"dingtalk-chat", "dws-shared"} { + if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } } // Multi cache refreshed under the fake home. @@ -216,7 +228,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { } } -func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) { +func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"}) @@ -241,12 +253,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err) } - want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md") + want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md") if _, err := os.Stat(want); err != nil { - t.Fatalf("canonical Codex Skill missing at %s: %v", want, err) + t.Fatalf("canonical Skill missing at %s: %v", want, err) } for _, duplicate := range []string{ - filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"), + filepath.Join(codexBase, "dingtalk-chat"), filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -502,6 +514,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) { // mono-only package path). func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { t.Fatal(err) @@ -526,21 +541,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure( } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical copy failure = %#v, %v", result, err) } data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md")) if err != nil { t.Fatalf("mono fallback install missing: %v", err) } - if string(data) != "# mono" { - t.Errorf("mono content = %q, want refreshed package", data) + if string(data) != "old" { + t.Errorf("mono content = %q, want original preserved", data) } } @@ -574,22 +583,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err) + if err == nil { + t.Fatal("canonical read failure must fail the upgrade") } failed := result.Failed() if len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) } - wantDir := agentsBase + wantDir := filepath.Join(agentsBase, "dws") if failed[0].Dir != wantDir || failed[0].Err == nil { t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir) } if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") { t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err) } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) + if got := len(result.Succeeded()); got != 0 { + t.Fatalf("Succeeded() len = %d, want 0", got) } // Mono must NOT have been laid down next to the unreadable multi state. if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { @@ -603,6 +612,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * // installing multi next to the stale skills. func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") staleDir := filepath.Join(agentsBase, "dingtalk-stale") if err := os.MkdirAll(staleDir, 0o755); err != nil { @@ -613,10 +625,10 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } useUpgradeManagedNames(t, filepath.Base(staleDir)) - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, "dingtalk-stale") { - return errors.New("injected backup failure") + return "", errors.New("injected backup failure") } return origRename(src, dst) }) @@ -628,8 +640,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t if err == nil { t.Fatal("multi fallback cleanup failure must return an error") } - if !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("error should mention the fallback cleanup failure, got %v", err) + if !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("error should mention the canonical cleanup failure, got %v", err) } if failed := result.Failed(); len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) @@ -643,8 +655,17 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } } +// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every +// install surface does before any payload moves in. +func seedSkillBackupMarker(t *testing.T, root string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil { + t.Fatal(err) + } +} + // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe -// contract of the backup helper: non-dir paths are no-ops, a colliding backup +// contract of the backup helper: all lexical paths are preserved, a colliding backup // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable // collision) leaves the original directory untouched with an error. func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { @@ -654,8 +675,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { // backup. Inject it through the path seam so this branch is portable to // Windows, where chmod-based permission failures are not reliable. statFailurePath := filepath.Join(home, "stat-failure") - origStat := upgradeStat - testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + origStat := upgradeLstat + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { if path == statFailurePath { return nil, errors.New("injected stat failure") } @@ -665,16 +686,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err) } - // Regular file: no-op, no backup. + // Regular file: preserve it instead of allowing a later rename to overwrite it. filePath := filepath.Join(home, "not-a-dir") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatal(err) } - if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { - t.Fatalf("regular file = (%q, %v), want no-op", got, err) + if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil { + t.Fatalf("regular file = (%q, %v), want backup", got, err) } - if _, err := os.Stat(filePath); err != nil { - t.Fatalf("regular file must survive: %v", err) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("regular file must move into backup: %v", err) } // A skill directory outside home falls back to its basename instead of @@ -722,6 +743,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("victim must be gone after backup, stat err=%v", err) } + // A dangling link at the first backup target is still an occupied lexical + // path and must select a numbered target rather than being overwritten. + danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision") + if err := os.MkdirAll(danglingVictim, 0o755); err != nil { + t.Fatal(err) + } + danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision") + if err := os.Symlink("missing-backup", danglingTaken); err == nil { + got, err = backupAndRemoveSkillDir(home, danglingVictim) + if err != nil { + t.Fatalf("backup with dangling collision error = %v", err) + } + want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision") + if got != want { + t.Fatalf("dangling collision backup path = %q, want %q", got, want) + } + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + // An unreadable target probe is not equivalent to a free path. + probeVictim := filepath.Join(home, "probe-victim") + if err := os.MkdirAll(probeVictim, 0o755); err != nil { + t.Fatal(err) + } + probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim") + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { + if path == probeTarget { + return nil, errors.New("target probe denied") + } + return os.Lstat(path) + }) + if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") { + t.Fatalf("target probe error = %v", err) + } + if _, err := os.Stat(probeVictim); err != nil { + t.Fatalf("probe victim must survive target check failure: %v", err) + } + testseam.Swap(t, &upgradeLstat, os.Lstat) + // Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir. victim2 := filepath.Join(home, "victim2") if err := os.MkdirAll(victim2, 0o755); err != nil { @@ -749,7 +810,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatal(err) } testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) - if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") { + if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") { t.Fatalf("mkdir error = %v", err) } if _, err := os.Stat(victim3); err != nil { @@ -762,14 +823,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { if err := os.MkdirAll(victim4, 0o755); err != nil { t.Fatal(err) } - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") }) if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { t.Fatalf("rename error = %v", err) } if _, err := os.Stat(victim4); err != nil { 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: @@ -779,9 +840,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } if err := pruneSkillBackups(home); err != nil { t.Fatalf("pruneSkillBackups() error = %v", err) @@ -814,9 +877,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { // Removal failure is reported as the first error. Seed more than // skillBackupKeep dirs again so the prune loop actually runs. for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") }) if err := pruneSkillBackups(home); err == nil { @@ -825,6 +890,86 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll) } +// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that +// pruneSkillBackups only retires directories whose names match the DWS backup +// stamp format. Any foreign directory in the backup root must survive even when +// more than skillBackupKeep stamped backups exist. +func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+2; i++ { + stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { + t.Fatal(err) + } + seedSkillBackupMarker(t, stampDir) + } + // A collision-suffixed stamp is still a valid DWS backup and is eligible. + suffixed := filepath.Join(root, "20260910-000000-7") + if err := os.MkdirAll(suffixed, 0o755); err != nil { + t.Fatal(err) + } + seedSkillBackupMarker(t, suffixed) + // Stamp-shaped names without a verifying marker are foreign data too: + // the name format alone proves nothing. + unmarked := filepath.Join(root, "20260901-000000") + if err := os.MkdirAll(unmarked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + wrongMarker := filepath.Join(root, "20260902-000000") + if err := os.MkdirAll(wrongMarker, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil { + t.Fatal(err) + } + unknown := []string{ + "user-personal-backup", + "20260910-000000-abc", // stamp-shaped but non-numeric suffix + "20260910-00000", // too short + "notes", + "20260901-000000", // stamp-shaped but unmarked + "20260902-000000", // stamp-shaped but wrong marker content + } + for _, name := range unknown { + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + } + + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("pruneSkillBackups() error = %v", err) + } + + for _, name := range unknown { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) + } + } + // Only marker-proven backups beyond skillBackupKeep are pruned; foreign + // entries (including unmarked stamp-shaped ones) are excluded from the + // count, so the proven total stays at skillBackupKeep. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + proven := 0 + for _, e := range entries { + if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) { + proven++ + } + } + if proven != skillBackupKeep { + t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep) + } + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" { + t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err) + } +} + // TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of // the mono-source resolver and the multi-bundle child lookup. func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { @@ -888,88 +1033,27 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { // TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono // path's fail-loud backup semantics and every primary-fallback outcome. func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - mono := t.TempDir() if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil { t.Fatal(err) } - - // Per-home backup failure marks that home failed; another home still wins. home := withFakeHome(t) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { 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) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical backup failure = %#v, %v", result, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("failed home must keep its original dws/: %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback cleanup failure: only a blacklisted home in the main loop, then - // the primary cleanup hits a stale dir whose backup fails. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".real/skills"} - stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale") - os.MkdirAll(stale, 0o755) - useUpgradeManagedNames(t, filepath.Base(stale)) - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback cleanup error = %v", err) - } - if _, err := os.Stat(stale); err != nil { - t.Fatalf("stale dir must survive failed cleanup: %v", err) - } - - // Fallback backup failure of the primary dws/ itself. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure: everything fails loud. - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append (no prior entry for the primary dir): a blacklisted-only - // main loop leaves no primary entry, so the fallback appends a fresh OK. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - result, err = UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary", got) - } - if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) + t.Fatalf("failed canonical home must keep its original dws/: %v", err) } } @@ -977,15 +1061,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { // path's blacklisted branch, per-skill backup/copy failures, and the primary // fallback outcomes (cleanup OK + backup/copy failure, fresh append). func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - extract := t.TempDir() multiRoot := writeMultiBundle(t, extract, "dingtalk-chat") - - // Blacklisted entry is reported, non-blacklisted installs. home := withFakeHome(t) - knownSkillDirs = []string{".real/skills", ".agents/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"}) os.MkdirAll(filepath.Join(home, ".agents"), 0o755) result, err := UpgradeSkillLocations(multiRoot) if err != nil { @@ -1003,119 +1082,33 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) { t.Fatalf("blacklisted home must never be touched, stat err=%v", err) } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("canonical install missing: %v", err) + } +} - // A detected concrete Agent root wins over .agents. A failure while retiring - // the old generic copy is surfaced after the concrete root succeeds. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} - os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755) - os.MkdirAll(filepath.Join(home2, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, ".agents") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) +// The production wrappers that used to bundle these two steps were removed as +// dead code; the test keeps exercising the live building blocks they composed. +func cleanupMultiLeftoversForTest(homeDir, baseDir string) error { + victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) + return err } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 backup-failed home", failed) + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理 multi 残留失败: %w", err) } - if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil { - t.Fatalf("failed home must keep its original skill: %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) + return nil +} - // Per-skill copy failure on the concrete home fails that home; with no - // successful concrete target, the generic copy is left untouched. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".claude"), 0o755) - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - if strings.Contains(dst, ".claude") { - return errors.New("copy denied") - } - return copyDir(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) +func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error { + victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) + return err } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 copy-failed home", failed) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback backup failure: the same-name bundle skill refresh is what - // fails (cleanup succeeds, the per-skill backup does not). - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - knownSkillDirs = []string{".real/skills"} - os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, "dingtalk-chat") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append: blacklisted-only main loop → fresh OK entry appended. - home6 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results) - } - if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) - } - - // Fallback replace: the main loop fails the primary home on a transient - // copy error, the fallback retry succeeds and replaces the failed entry. - home7 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil }) - knownSkillDirs = []string{".agents/skills"} - origCopy := upgradeCopyDir - attempts := 0 - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - attempts++ - if attempts == 1 { - return errors.New("transient copy failure") - } - return origCopy(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback replace error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("failed entry must be replaced by OK, got %v", failed) - } - if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback replace install missing: %v", err) + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理对面模式残留失败: %w", err) } + return nil } // TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers: @@ -1127,40 +1120,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Read failure (non-ENOENT) surfaces from both cleanups. testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupMultiLeftovers read error = %v", err) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err) } - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err) + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err) } testseam.Swap(t, &upgradeReadDir, os.ReadDir) // Backup failure of a multi leftover aborts cleanupMultiLeftovers. os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } // Backup failure of the mono leftover aborts the opposite-mode cleanup. os.MkdirAll(filepath.Join(base, "dws"), 0o755) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { 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. - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { 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 := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { 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 // skill and regular file preserved. @@ -1168,7 +1161,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { skillSet := map[string]bool{"dingtalk-keep": true} os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755) os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644) - if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil { + if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil { t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err) } for _, gone := range []string{"dws", "dingtalk-stale"} { @@ -1188,7 +1181,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } // cleanupMultiLeftovers on a missing base is a no-op. - if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil { + if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil { t.Fatalf("missing base must be a no-op, got %v", err) } } diff --git a/internal/upgrade/skill_backup_ownership_test.go b/internal/upgrade/skill_backup_ownership_test.go new file mode 100644 index 00000000..a9098806 --- /dev/null +++ b/internal/upgrade/skill_backup_ownership_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership +// contract: a stamp-shaped directory name alone proves nothing, so a backup +// root this transaction writes into is either created fresh (mkdir claim, +// marker stamped before any payload moves in) or an existing root whose +// marker already verifies. A same-stamp foreign directory is never adopted — +// writing the marker or the payload into it would turn its contents into +// prunable DWS backups. +func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) { + + t.Run("unproven stamp root is never adopted", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000") + if err := os.MkdirAll(foreign, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + backup, err := backupAndRemoveSkillDir(home, victim) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want { + t.Fatalf("backup = %s, want suffixed %s", backup, want) + } + assertUpgradeSkillContent(t, backup, "payload") + // The foreign root keeps its data and never receives our marker. + if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" { + t.Fatalf("foreign data = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) { + t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr) + } + if !skillBackupMarkerValid(filepath.Dir(backup)) { + t.Fatal("the freshly claimed root must carry the ownership marker") + } + }) + + t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + first := filepath.Join(home, "skills", "dws") + second := filepath.Join(home, "agents", "claude", "dws") + seedUpgradeSkill(t, first, "first", false) + seedUpgradeSkill(t, second, "second", false) + backupA, err := backupAndRemoveSkillDir(home, first) + if err != nil { + t.Fatal(err) + } + backupB, err := backupAndRemoveSkillDir(home, second) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(backupA) != filepath.Dir(backupB) { + t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB) + } + root := filepath.Dir(backupA) + if !skillBackupMarkerValid(root) { + t.Fatal("reused root must keep a verifying marker") + } + assertUpgradeSkillContent(t, backupA, "first") + assertUpgradeSkillContent(t, backupB, "second") + }) + + t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") }) + _, err := backupAndRemoveSkillDir(home, victim) + if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") { + t.Fatalf("marker write failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir)) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries) + } + }) + + t.Run("backup root creation failure surfaces", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := backupAndRemoveSkillDir(home, victim) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("root creation failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + }) +} diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go new file mode 100644 index 00000000..3f41e6c2 --- /dev/null +++ b/internal/upgrade/skill_path_move.go @@ -0,0 +1,363 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +var ( + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove + skillPathReadDir = os.ReadDir + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathSameFileIdentity = skillPathSameFileIdentityImpl + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir +) + +// moveSkillPathRecoverably moves one managed Skill path without weakening the +// backup contract when source and destination are on different filesystems. +// The cross-device path publishes a fully copied and verified sibling staging +// path before removing the source. Any failure before source removal leaves the +// source intact; a removal failure deliberately leaves both verified copies. +func moveSkillPathRecoverably(src, dst string) (err error) { + if _, statErr := skillPathLstat(dst); statErr == nil { + return fmt.Errorf("目标已存在: %s", dst) + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) + } + if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { + return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) + } + // moveSkillPathRecoverably is not a publication path; ignore any + // claim identity returned by the rename — no ownership check follows + // on this destination. + if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { + return nil + } else if !isCrossDeviceError(renameErr) { + return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) + } + + stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-") + if err != nil { + return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err) + } + stage := filepath.Join(stageRoot, "payload") + stageCleaned := false + defer func() { + if stageCleaned { + return + } + makeSkillPathTreeWritable(stageRoot) + if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + }() + + if err := skillPathCopy(src, stage); err != nil { + return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err) + } + if err := skillPathVerify(src, stage); err != nil { + return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) + } + stageInfo, err := skillPathLstat(stage) + if err != nil { + return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) + } + if stageInfo.IsDir() { + // Darwin refuses to rename a read-only directory even when both parent + // directories are writable. The exact source mode was verified above; + // temporarily add owner access for publication, then restore it before + // the final verification and before the source is removed. + if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil { + return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) + } + } + // Cross-device staging publishes via a self-owned staging directory, + // so the claim identity is not consulted; discard it. + if _, err := skillPathRenameNoReplace(stage, dst); err != nil { + return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) + } + if stageInfo.IsDir() { + if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + } + } + if err := skillPathVerify(src, dst); err != nil { + return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + } + if err := skillPathRemoveAll(stageRoot); err != nil { + return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + } + stageCleaned = true + if err := removePublishedSkillSource(src); err != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err) + } + return nil +} + +type skillPathDirMode struct { + path string + mode os.FileMode +} + +func removePublishedSkillSource(src string) error { + dirModes, err := prepareSkillPathTreeRemoval(src) + if err != nil { + return err + } + removeErr := skillPathRemoveAll(src) + if removeErr == nil { + if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) { + return nil + } else if statErr == nil { + removeErr = errors.New("源路径仍存在") + } else { + removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr) + } + } + return errors.Join(removeErr, restoreSkillPathDirModes(dirModes)) +} + +func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { + var modes []skillPathDirMode + err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0o700 == 0o700 { + return nil + } + if err := skillPathChmod(path, mode|0o700); err != nil { + return err + } + modes = append(modes, skillPathDirMode{path: path, mode: mode}) + return nil + }) + if err != nil { + return nil, errors.Join(err, restoreSkillPathDirModes(modes)) + } + return modes, nil +} + +func restoreSkillPathDirModes(modes []skillPathDirMode) error { + var restoreErr error + for i := len(modes) - 1; i >= 0; i-- { + item := modes[i] + if _, err := skillPathLstat(item.path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := skillPathChmod(item.path, item.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err)) + } + } + return restoreErr +} + +func makeSkillPathTreeWritable(root string) { + // Best effort only: RemoveAll below remains the source of truth and its + // error is returned. This preparation lets it traverse read-only staging + // directories left by a copy or verification failure. + _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr == nil && entry.IsDir() { + _ = skillPathChmod(path, 0o700) + } + return nil + }) +} + +// RestoreSkillPath restores a previously recorded backup using the same +// rename-first, verified cross-device fallback as backup publication. +func RestoreSkillPath(backup, original string) error { + return moveSkillPathRecoverably(backup, original) +} + +func copySkillPathLexically(src, dst string) error { + info, err := skillPathLstat(src) + if err != nil { + return err + } + switch { + case info.IsDir(): + // Staging must remain writable while children are copied. Restore the + // source mode only after the directory is complete so read-only Skill + // directories (for example 0555) can still be migrated lexically. + if err := skillPathMkdir(dst, 0o700); err != nil { + return err + } + entries, err := skillPathReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + case info.Mode()&os.ModeSymlink != 0: + target, err := skillPathReadlink(src) + if err != nil { + return err + } + return skillPathSymlink(target, dst) + case info.Mode().IsRegular(): + return copyRegularSkillFile(src, dst, info.Mode().Perm()) + default: + return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode()) + } +} + +func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { + in, err := skillPathOpen(src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, in.Close()) }() + out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { err = errors.Join(err, out.Close()) }() + if _, err := skillPathCopyBytes(out, in); err != nil { + return err + } + if err := skillPathSync(out); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func verifySkillPathCopy(src, dst string) error { + srcInfo, err := skillPathLstat(src) + if err != nil { + return err + } + dstInfo, err := skillPathLstat(dst) + if err != nil { + return err + } + if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType { + return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode()) + } + switch { + case srcInfo.IsDir(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + srcNames, err := skillPathEntryNames(src) + if err != nil { + return err + } + dstNames, err := skillPathEntryNames(dst) + if err != nil { + return err + } + if len(srcNames) != len(dstNames) { + return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames)) + } + for i, name := range srcNames { + if name != dstNames[i] { + return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i]) + } + if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil { + return err + } + } + return nil + case srcInfo.Mode()&os.ModeSymlink != 0: + srcTarget, err := skillPathReadlink(src) + if err != nil { + return err + } + dstTarget, err := skillPathReadlink(dst) + if err != nil { + return err + } + if srcTarget != dstTarget { + return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget) + } + return nil + case srcInfo.Mode().IsRegular(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size()) + } + srcDigest, err := skillPathFileDigest(src) + if err != nil { + return err + } + dstDigest, err := skillPathFileDigest(dst) + if err != nil { + return err + } + if srcDigest != dstDigest { + return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst) + } + return nil + default: + return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode()) + } +} + +func skillPathEntryNames(dir string) ([]string, error) { + entries, err := skillPathReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func skillPathFileDigest(path string) ([sha256.Size]byte, error) { + f, err := skillPathOpen(path) + if err != nil { + return [sha256.Size]byte{}, err + } + defer f.Close() + hash := sha256.New() + if _, err := skillPathCopyBytes(hash, f); err != nil { + return [sha256.Size]byte{}, err + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go new file mode 100644 index 00000000..b9d00850 --- /dev/null +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -0,0 +1,529 @@ +package upgrade + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { + t.Run("destination probe", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst { + return nil, os.ErrPermission + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination parent", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("staging creation", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("failed staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") }) + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing copied staging", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil }) + testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") { + t.Fatalf("error = %v", err) + } + }) + + for _, tc := range []struct { + name string + path func(src, dst string) string + want string + }{ + {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"}, + {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") { + return errors.New("chmod failed") + } + if tc.name == "restore published mode" && path == dst { + return errors.New("chmod failed") + } + return original(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v", err) + } + }) + } + + t.Run("published staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".cross-device-") { + return errors.New("cleanup failed") + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source deletion cannot be confirmed", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + removalAttempted := false + originalLstat := skillPathLstat + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + removalAttempted = true + return nil + } + return originalRemove(path) + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && removalAttempted { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("source preparation error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error { + return errors.New("prepare failed") + }) + if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("walk error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, nil, errors.New("walk failed")) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("writable mode preparation", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + var chmodMode os.FileMode + testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error { + chmodMode = mode + return nil + }) + modes, err := prepareSkillPathTreeRemoval("source") + if err != nil { + t.Fatal(err) + } + if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 { + t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes) + } + }) + + t.Run("writable mode preparation chmod error", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { + return errors.New("chmod failed") + }) + if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry info error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, skillPathErrorDirEntry{}, nil) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing restored directory", func(t *testing.T) { + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("restore probe error", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("restore chmod error", func(t *testing.T) { + path := t.TempDir() + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) { + t.Run("source lstat", func(t *testing.T) { + if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination mkdir", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copySkillPathLexically(src, dst); err == nil { + t.Fatal("expected mkdir failure") + } + }) + + t.Run("source readdir", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("nested copy", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "child" { + return nil, os.ErrPermission + } + return original(path) + }) + if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("readlink", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "link") + if err := os.Symlink("target", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("special path", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular open", func(t *testing.T) { + if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular destination open", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyRegularSkillFile(src, dst, 0o600); err == nil { + t.Fatal("expected exclusive create failure") + } + }) + + for _, tc := range []struct { + name string + swap func(*testing.T) + }{ + {name: "copy", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") }) + }}, + {name: "sync", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tc.swap(t) + if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { + root := t.TempDir() + + t.Run("source and destination lstat", func(t *testing.T) { + if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) { + t.Fatalf("source error = %v", err) + } + src := filepath.Join(root, "source") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) { + t.Fatalf("destination error = %v", err) + } + }) + + t.Run("directory and file modes", func(t *testing.T) { + for _, tc := range []struct { + name string + src os.FileMode + dst os.FileMode + }{ + {name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755}, + {name: "file", src: 0o600, dst: 0o644}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := "src", "dst" + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src { + return skillPathFakeInfo{mode: tc.src}, nil + } + return skillPathFakeInfo{mode: tc.dst}, nil + }) + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("directory read errors", func(t *testing.T) { + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("nested mismatch", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("symlink reads", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Symlink("one", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := os.Symlink("one", dst); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathReadlink + testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) { + if (path == dst) == failDest { + return "", os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("file digest opens", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathOpen + testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("special path", func(t *testing.T) { + fake := skillPathFakeInfo{mode: os.ModeNamedPipe} + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil }) + if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry names", func(t *testing.T) { + if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("digest open and read", func(t *testing.T) { + if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) { + t.Fatalf("open error = %v", err) + } + file := filepath.Join(root, "digest-file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") }) + if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + }) +} + +type skillPathFakeInfo struct { + mode os.FileMode +} + +func (skillPathFakeInfo) Name() string { return "fake" } +func (skillPathFakeInfo) Size() int64 { return 0 } +func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode } +func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} } +func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() } +func (skillPathFakeInfo) Sys() any { return nil } + +type skillPathErrorDirEntry struct{} + +func (skillPathErrorDirEntry) Name() string { return "broken" } +func (skillPathErrorDirEntry) IsDir() bool { return true } +func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } +func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } + +type skillPathModeDirEntry struct { + mode os.FileMode +} + +func (skillPathModeDirEntry) Name() string { return "directory" } +func (skillPathModeDirEntry) IsDir() bool { return true } +func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() } +func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) { + return skillPathFakeInfo{mode: entry.mode}, nil +} diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go new file mode 100644 index 00000000..1963a257 --- /dev/null +++ b/internal/upgrade/skill_path_move_test.go @@ -0,0 +1,451 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { + t.Run("directory file and lexical links", func(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + linksCreated := true + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + linksCreated = false + } + if linksCreated { + if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil { + t.Fatal(err) + } + } + + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { + t.Fatalf("copied file = %q, %v", got, err) + } + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil { + t.Fatalf("copied file stat = %v, %v", info, err) + } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm()) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(dst, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("regular file root", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source-file") + dst := filepath.Join(root, "backup", "source-file") + if err := os.WriteFile(src, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "state" { + t.Fatalf("backup file = %q, %v", got, err) + } + }) + + t.Run("read-only directory mode is restored after copying children", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "backup", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("copied directory mode = %v, %v", info, err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" { + t.Fatalf("copied read-only file = %q, %v", got, err) + } + }) + + t.Run("rollback uses the same cross-device contract", func(t *testing.T) { + root := t.TempDir() + backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, backup, original) + if err := RestoreSkillPath(backup, original); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed after verified restore: %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { + t.Run("non cross-device rename never copies", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + copied := false + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission }) + testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + if copied { + t.Fatal("permission failure must not enter cross-device copy fallback") + } + assertSkillSourcePreserved(t, src) + }) + + for _, tc := range []struct { + name string + copy func(string, string) error + verify func(string, string) error + }{ + {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }}, + {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + if tc.copy != nil { + testseam.Swap(t, &skillPathCopy, tc.copy) + } + if tc.verify != nil { + testseam.Swap(t, &skillPathVerify, tc.verify) + } + if err := moveSkillPathRecoverably(src, dst); err == nil { + t.Fatal("expected failure") + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("formal backup must not publish: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + } + + t.Run("staging publish failure", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { + if from == src && to == dst { + return "", testCrossDeviceError() + } + if to == dst { + return "", errors.New("publish failed") + } + return originalRename(from, to) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("published verification failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("verified backup candidate must remain: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("source removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("formal backup must remain: %v", err) + } + }) + + t.Run("read-only source mode is restored after removal failure", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("source directory mode after failed removal = %v, %v", info, err) + } + if err := os.Chmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("source permission preparation failure preserves both", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == src { + return errors.New("chmod failed") + } + return originalChmod(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + if err := originalChmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("lying remover is detected", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return nil + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) + + t.Run("same-filesystem fast path", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source still exists: %v", err) + } + }) + + t.Run("existing destination is never overwritten", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) +} + +func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { + t.Run("type", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") { + t.Fatalf("error = %v", err) + } + }) + t.Run("file size and digest", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") { + t.Fatalf("error = %v", err) + } + }) + t.Run("symlink target", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Symlink("one", src); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation unavailable") + } + t.Fatal(err) + } + if err := os.Symlink("two", dst); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") { + t.Fatalf("error = %v", err) + } + }) +} + +func forceCrossDeviceRename(t *testing.T, src, dst string) { + t.Helper() + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { + if from == src && to == dst { + return "", testCrossDeviceError() + } + return originalRename(from, to) + }) +} + +func makeSkillMoveFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + return src, dst +} + +func assertSkillSourcePreserved(t *testing.T, src string) { + t.Helper() + if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("source content = %q, %v", got, err) + } +} + +func assertNoCrossDeviceStage(t *testing.T, dst string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(dst)) + if err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } + prefix := "." + filepath.Base(dst) + ".cross-device-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("cross-device staging leaked: %s", entry.Name()) + } + } +} diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go new file mode 100644 index 00000000..587c8217 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go new file mode 100644 index 00000000..288847ae --- /dev/null +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package upgrade + +import ( + "os" + "syscall" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} +} + +func testNoReplaceUnsupportedErrors() []error { + return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} +} diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go new file mode 100644 index 00000000..f43fe688 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package upgrade + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go new file mode 100644 index 00000000..2da189d8 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -0,0 +1,24 @@ +//go:build windows + +package upgrade + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} +} + +func testNoReplaceUnsupportedErrors() []error { + return []error{errNoReplaceRenameUnsupported} +} + +func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { + if !isCrossDeviceError(testCrossDeviceError()) { + t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") + } +} diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go new file mode 100644 index 00000000..59a3af89 --- /dev/null +++ b/internal/upgrade/skill_publication.go @@ -0,0 +1,241 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "hash" + "os" + "path/filepath" + "sort" +) + +// ErrSkillPathPublicationUncertain marks a publication whose transactional +// outcome cannot be determined safely: this transaction did occupy the +// destination at some point, but a stronger witness — the fresh mkdir-claim +// identity captured by the child-move fallback — now indicates that the +// object currently at the destination may belong to a concurrent writer. +// Callers must not automatically retract, replace, or retry-copy over the +// destination; instead they should surface the state so the user or a +// higher layer can inspect it. Errors wrapping this sentinel are safe to +// test with errors.Is. +var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定") + +// 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 + incarnation string + fileID string +} + +// PublishSkillPathNoReplace atomically publishes a staged path without ever +// replacing a destination created after the backup phase. +// +// The rename returns a mkdir-claim identity when (and only when) the +// child-move fallback published dest. That identity is the tamperproof +// witness that dest is still the mkdir claim this transaction created +// (unlike the staged shell, whose presence alone is compatible with a +// wholesale replacement of dest by a concurrent writer). When the claim +// identity no longer matches dest, publication reports uncertain state +// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or +// overwriting would delete the concurrent writer's data. +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) + } + stagedFileID := skillPathFileIdentity(staged) + claimIdentity, err := skillPathRenameNoReplace(staged, destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) + } + // If the child-move fallback published dest, the claim identity must + // still describe the object at destination. A wholesale replacement of + // dest between the mkdir claim and this check produces a different + // identity — dest is a foreign object and must not be treated as this + // transaction's publication. An unreadable live identity (dest already + // vanished, or the platform cannot report one) defers to the standard + // confirmation checks below instead of guessing. + if claimIdentity != "" { + if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity { + return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination) + } + } + publishedIdentity, err := skillPathLstat(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + } + publishedFingerprint, err := fingerprintSkillPath(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) + } + publishedFileID := skillPathFileIdentity(destination) + // When claim identity is available (child-move path), it is the + // authoritative witness of "dest is our object"; the staged inode + // necessarily differs from the mkdir-claim inode. Only enforce the + // staged-inode identity proof when there is no claim identity — that + // is, when the atomic primitive or the fast-path rename consumed the + // staged inode into dest. + if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + if publishedFingerprint != fingerprint { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination) + } + return SkillPathPublication{ + Destination: destination, + fingerprint: fingerprint, + identity: publishedIdentity, + incarnation: skillPathFileIncarnation(publishedIdentity), + fileID: publishedFileID, + }, 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 + } + + liveIdentity, liveIdentityErr := skillPathLstat(destination) + if os.IsNotExist(liveIdentityErr) { + return cleanupRoot() + } + liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination) + liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr) + if liveVerificationErr != nil { + return errors.Join( + fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr), + cleanupRoot(), + ) + } + if publication.identity == nil || + !skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) || + publication.incarnation != skillPathFileIncarnation(liveIdentity) || + liveFingerprint != publication.fingerprint { + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination), + cleanupRoot(), + ) + } + + 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 && + skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) && + 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("发布对象身份已变化") + } + // Rollback restore is not a publication path; ignore any claim + // identity returned by the rename — no ownership check follows. + 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)) +} diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go new file mode 100644 index 00000000..0a9989ec --- /dev/null +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -0,0 +1,50 @@ +//go:build darwin + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, + stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems, which the +// publication fingerprint backstop still catches. An unreadable or +// unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := skillPathLstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) +} + +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go new file mode 100644 index 00000000..ff20caf2 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -0,0 +1,49 @@ +//go:build linux + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems (notably tmpfs), +// which the publication fingerprint backstop still catches. An unreadable +// or unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := skillPathLstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) +} + +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go new file mode 100644 index 00000000..59bfd256 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_other.go @@ -0,0 +1,27 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "os" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports no identity on platforms whose stat +// layout we do not decode; the child-move claim witness degrades gracefully +// to the publication fingerprint backstop. +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go new file mode 100644 index 00000000..d6740de1 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package upgrade + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) +} + +// skillPathSameFileIdentity returns false because os.FileInfo on Windows does +// not expose the stable file ID (VolumeSerialNumber / FileIndex from +// GetFileInformationByHandle), which is the only primitive that distinguishes +// a recreated file from the original after NTFS file tunneling restores the +// creation time. Callers must use skillPathIdentityProven, which compares file +// IDs obtained via GetFileInformationByHandle. +func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { + return false +} + +// skillPathFileIdentityImpl queries the stable volume serial number and file +// index via GetFileInformationByHandle. Unlike creation time (which NTFS file +// tunneling can restore for a recreated same-named object), the file index +// uniquely identifies the file on the volume for its lifetime. +// +// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following +// them to the target. This matters at publish time because a staged symlink +// carries a relative target computed for the final destination, which may not +// resolve from the staging directory. Opening the reparse point yields a file +// ID that is stable across the rename and is the correct identity to compare. +func skillPathFileIdentityImpl(path string) (id string) { + if p, err := windows.UTF16PtrFromString(path); err == nil { + if handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ); err == nil { + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err == nil { + id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + } + } + } + return +} + +// skillPathIdentityProven is the real identity proof on Windows. An empty +// expected value means the file ID could not be obtained at publish time, so +// identity cannot be proven and the caller must refuse the auto-delete. +func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { + return expected != "" && expected == actual +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go new file mode 100644 index 00000000..aacf8706 --- /dev/null +++ b/internal/upgrade/skill_publication_test.go @@ -0,0 +1,532 @@ +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") + }) + + t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + // NTFS file tunneling can restore the original creation time for a + // same-named recreation, defeating the incarnation check. The file ID + // (or inode on Unix) must still differ and block the rollback. + testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" }) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + // Simulate the identity change that a concurrent replacement causes. + // On Windows, skillPathIdentityProven compares the recorded file ID + // from GetFileInformationByHandle with the live one and ignores the + // SameFile seam; NTFS can reuse the freed MFT record for the + // recreation, so the live probe is perturbed through the file-ID + // seam as well. On Unix, where the file ID is advisory, this swap + // replaces the os.SameFile check (which can return true for a + // recreated file on tmpfs due to inode reuse) so the test is + // deterministic across filesystems. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + return originalFileIdentity(path) + ":replaced" + }) + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("tunneled replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) +} + +func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { + failure := errors.New("injected publication failure") + + t.Run("platform no-replace path encoding", func(t *testing.T) { + if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected invalid source path failure") + } + source := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(source, []byte("value"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { + t.Fatal("expected invalid destination path 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("publish confirmation missing", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + return "", os.RemoveAll(target) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { + t.Fatalf("publish confirmation error = %v", err) + } + }) + + t.Run("publish confirmation identity changed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + // A real rename consumed the staged path (fast path), but the object + // at the destination must be proven to be the staged one. Whether a + // same-content swap is observable through inode/file-ID comparison + // depends on the filesystem — CI runners' ext4/overlayfs recycle + // inodes eagerly and NTFS can immediately reuse the freed MFT record, + // so reproducing the swap physically is not portable. Force the + // failure through both underlying primitives the platform-specific + // proof consults: os.SameFile (Unix) and the stable file ID + // (Windows), pinning the confirmation's rejection on every platform. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + stagedID := "" + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + id := originalFileIdentity(path) + if stagedID == "" { + stagedID = id + return id + } + // The published probe must report a different object than the + // staged one so the proof fails on both platforms. + return id + ":swapped" + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { + t.Fatalf("publish identity error = %v", err) + } + assertUpgradeSkillContent(t, destination, "value") + }) + + t.Run("publish confirmation fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + published := false + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + published = true + return "", nil + }) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if published && path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") { + t.Fatalf("publish fingerprint confirmation error = %v", err) + } + }) + + 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) { + 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) + } + testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("quarantine error = %v", err) + } + }) + + t.Run("live verification", 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) + } + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("live verification error = %v", err) + } + }) + + t.Run("destination disappears before quarantine", 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) + } + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if err := os.RemoveAll(source); err != nil { + return err + } + return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist} + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(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) + } + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) + 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) + } + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, 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 ordering", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"b", "a"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := fingerprintSkillPath(dir); err != nil { + t.Fatal(err) + } + }) + 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("symlink success", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := fingerprintSkillPath(link); err != nil { + t.Fatal(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") + } + }) + t.Run("unknown platform identity shape", func(t *testing.T) { + if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" { + t.Fatal("empty incarnation") + } + }) + t.Run("synthetic info does not match same-file identity", func(t *testing.T) { + fake := skillPathFakeInfo{} + if skillPathSameFileIdentity(fake, fake) { + t.Fatal("synthetic info should not match") + } + }) + }) +} + +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) + } +} diff --git a/internal/upgrade/skill_publish_childmove_edges_test.go b/internal/upgrade/skill_publish_childmove_edges_test.go new file mode 100644 index 00000000..0072228a --- /dev/null +++ b/internal/upgrade/skill_publish_childmove_edges_test.go @@ -0,0 +1,451 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// childMoveDirEntry fakes one directory entry for seam-driven claim scans. +type childMoveDirEntry struct { + name string + isDir bool +} + +func (entry childMoveDirEntry) Name() string { return entry.name } +func (entry childMoveDirEntry) IsDir() bool { return entry.isDir } +func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() } +func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil } +func (entry childMoveDirEntry) info() os.FileInfo { + if entry.isDir { + return skillPathFakeInfo{mode: os.ModeDir | 0o755} + } + return skillPathFakeInfo{mode: 0o644} +} + +// forceChildMove routes every directory publication through the child-move +// slow path: the atomic primitive reports the flag unsupported and the +// fast-path rename fails the way platforms that refuse renaming over any +// existing directory do. +func forceChildMove(t *testing.T) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) +} + +// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch +// branches of the child-move directory fallback on every platform: the +// platform coverage gates only execute TestCrossPlatformCoverage-named +// tests, so these branches must be driven through seams rather than the +// platform-specific filesystem behaviors that cover them incidentally +// elsewhere. +func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) { + + t.Run("directory source stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == source { + calls++ + if calls > 1 { + return nil, os.ErrPermission + } + } + return original(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("directory stat failure = %v", err) + } + }) + + t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, os.ErrPermission + } + return os.ReadDir(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("claim read failure = %v", err) + } + }) + + t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 1 { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("vanished claim cleanup = %v", err) + } + }) + + t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") { + t.Fatalf("link failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("file child collision = %v, want ErrExist", err) + } + }) + + t.Run("later child failure rolls earlier children back", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The second child's link fails; the forced rename failure then also + // breaks the rollback rename of the already relocated first child. + testseam.Swap(t, &skillPathLink, func(source, _ string) error { + if filepath.Base(source) == "second" { + return os.ErrPermission + } + return os.Link(source, filepath.Join(destination, filepath.Base(source))) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") { + t.Fatalf("child failure rollback = %v", err) + } + // The forced rename failure also breaks the rollback restore, so the + // relocated child stays at the destination — retained, never deleted. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("live claim read failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return nil, os.ErrPermission + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "确认降级发布目标") { + t.Fatalf("live claim read failure = %v", err) + } + }) + + t.Run("foreign claim entry aborts the move", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return []os.DirEntry{ + childMoveDirEntry{name: "SKILL.md"}, + childMoveDirEntry{name: "foreign"}, + }, nil + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") { + t.Fatalf("foreign claim entry = %v", err) + } + }) + + t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") { + t.Fatalf("mode restore failure = %v", err) + } + }) + + t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") { + t.Fatalf("shell removal failure = %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("nested directories and mixed children publish", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The "link" child is presented to the dispatcher as a symlink; its + // recreation is simulated through seams so the test needs no + // symlink privileges (Windows runners refuse unprivileged symlinks). + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "link") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the claim identity") + } + assertUpgradeSkillContent(t, destination, "payload") + if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" { + t.Fatalf("nested child = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) { + t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr) + } + if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) { + t.Fatalf("source must be gone, stat err=%v", statErr) + } + }) + + t.Run("special-type child is refused", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return mockSpecialInfo{}, nil + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("special child = %v, want unsupported", err) + } + }) + + t.Run("symlink child readlink failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink readlink failure = %v", err) + } + }) + + t.Run("symlink child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("symlink collision = %v, want ErrExist", err) + } + }) + + t.Run("symlink child publish failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink publish failure = %v", err) + } + }) + + t.Run("child stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("child stat failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == source { + return nil, os.ErrPermission + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "读取源 Skill 目录失败") { + t.Fatalf("source read failure = %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("empty claim must be cleaned, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if id := skillPathFileIdentityImpl("unreadable"); id != "" { + t.Fatalf("unreadable identity = %q, want empty", id) + } + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil }) + if id := skillPathFileIdentityImpl("synthetic"); id != "" { + t.Fatalf("unrecognizable identity = %q, want empty", id) + } + }) + + t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) { + dir := t.TempDir() + staged := filepath.Join(dir, "staged") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + // The published bytes drift after the rename while the object + // keeps its identity, so only the fingerprint check can refuse. + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil { + return "", err + } + return "", nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") { + t.Fatalf("content drift = %v", err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go new file mode 100644 index 00000000..ae1742c5 --- /dev/null +++ b/internal/upgrade/skill_publish_rename.go @@ -0,0 +1,290 @@ +package upgrade + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace +// rename primitive at all. +var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename") + +var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic + +// renameSkillPathNoReplace moves source onto destination and never replaces an +// object that already occupies destination. On success it also returns the +// fresh mkdir-claim identity captured by the child-move directory fallback +// (empty when the atomic primitive or the empty-target fast path published +// dest, or when the platform cannot report a stable file identity). The +// publication path uses that identity as a tamperproof witness that dest is +// still the mkdir claim this transaction created; every other caller ignores +// it. +// +// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require +// support from the underlying filesystem and report EINVAL/ENOTSUP when it is +// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and +// overlayfs homes reject the flag. Failing there would abort the whole install +// on those filesystems, so the fallback uses truly atomic no-clobber primitives +// that work on every POSIX filesystem: os.Mkdir for directories (fails with +// EEXIST if the path is occupied), os.Link for regular files (fails with +// EEXIST if the path is occupied), and os.Symlink for symbolic links (same +// EEXIST guarantee). Neither primitive has a TOCTOU window. +func renameSkillPathNoReplace(source, destination string) (string, error) { + err := skillPathRenameNoReplaceAtomic(source, destination) + if err == nil { + return "", nil + } + if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { + return "", err + } + return renameSkillPathNoReplaceFallback(source, destination) +} + +// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename +// flag is unavailable. It dispatches on the source type to pick an atomic +// no-clobber primitive that does not require filesystem support. When the +// directory slow path publishes via child-move, the returned claim identity +// is the destination file identity captured immediately after mkdir; every +// other outcome returns an empty claim identity. +func renameSkillPathNoReplaceFallback(source, destination string) (string, error) { + info, err := skillPathLstat(source) + if err != nil { + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + return renameSkillDirNoReplaceFallback(source, destination) + case info.Mode().IsRegular(): + return "", renameSkillFileNoReplaceFallback(source, destination) + default: + return "", &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillDirNoReplaceFallback claims the destination once with mkdir and +// never unlinks it for the remainder of the transaction. The fast path +// renames the source over the claim; on platforms that permit replacing an +// empty directory (Linux) that rename only ever touches the claim we own. +// Platforms that refuse to rename over any directory (macOS returns EEXIST +// even for an empty target) take the slow path instead — but only after +// proving the claim is still empty: a rename that failed because a +// concurrent writer landed inside the claim must abort with the destination +// retained, never fall through to child moves whose per-entry primitives +// would collide with (and rollback would delete) the foreign data. The slow +// path moves the source children into the claim one by one, each through an +// atomic no-clobber primitive (mkdir claim for directories, os.Link for +// files, os.Symlink for links), so a same-name concurrent entry aborts the +// move and a foreign entry of any name — detected by re-reading the claim — +// retains the destination instead of letting the rollback delete it. The +// destination claim is removed only while still empty, so a foreign entry +// always survives a failed publication. On success the source is fully +// consumed either way: the fast-path rename moves it onto the claim, and +// the child-move slow path removes the emptied source shell after the last +// child relocates. +// +// The returned claim identity is non-empty only when the child-move slow +// path published dest and the platform can report a stable file identity; +// the fast-path rename consumes the mkdir claim (dest inherits the source +// inode) and returns "", as does an unsuccessful publish. Callers that need +// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare +// the returned identity against skillPathFileIdentity(destination) at the +// moment of the ownership check — a wholesale replacement of dest after +// the claim produces a different identity. +// +// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window +// between the second remove and the second rename during which a concurrent +// writer could create a file or empty directory at dest that the second +// os.Rename would silently overwrite. This implementation removes that +// window: the claim is never unlinked between the fast-path rename attempt +// and publication, and child-move relies exclusively on atomic no-clobber +// primitives that fail with EEXIST rather than replace. +func renameSkillDirNoReplaceFallback(source, destination string) (string, error) { + sourceInfo, err := skillPathLstat(source) + if err != nil { + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + if err := skillPathMkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + } + // Capture the claim identity immediately after mkdir but before any + // children move in. A concurrent replacement of destination (unlink + + // recreate) after this point produces a different identity. + claimID := skillPathFileIdentity(destination) + if err := skillPathRename(source, destination); err == nil { + // Fast path consumed the source: destination inode is now the + // staged inode, not the mkdir claim. Return no claim identity — + // the caller falls back to the standard staged-identity proof. + return "", nil + } + claimEntries, claimErr := skillPathReadDir(destination) + if claimErr != nil { + return "", errors.Join( + fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr), + removeClaimIfEmpty(destination), + ) + } + if len(claimEntries) > 0 { + // A concurrent writer populated the empty claim before we could + // take the slow path. Falling through to child-move would let + // its per-entry no-clobber primitives collide with the foreign + // data; abort with the destination retained instead. + return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist) + } + if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil { + return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err) + } + // Child-move consumed every source entry, so the emptied source shell is + // ours by construction. os.Remove refuses a non-empty directory, so a + // concurrent writer inside the staging tree fails the publication loudly + // instead of being silently discarded with the shell. + if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err) + } + // Child-move succeeded: dest is still the mkdir claim we created. + return claimID, nil +} + +// moveSkillDirChildNoReplace moves one child of a staged directory into the +// claimed destination through an atomic no-clobber primitive: directories +// recursively claim with mkdir, regular files publish via os.Link, and +// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST +// when the destination entry was taken concurrently, so no step can replace +// a foreign object. +func moveSkillDirChildNoReplace(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + // The nested fallback consumes the nested source on success — the + // fast-path rename moves it away and the child-move slow path + // removes the emptied shell. The nested claim identity is not a + // witness for this transaction (only the top-level destination's + // identity is), so it is discarded here. + _, err := renameSkillDirNoReplaceFallback(source, destination) + return err + case info.Mode().IsRegular(): + return renameSkillFileNoReplaceFallback(source, destination) + case info.Mode()&os.ModeSymlink != 0: + return renameSkillSymlinkNoReplaceFallback(source, destination) + default: + return &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// removeClaimIfEmpty deletes the claimed destination only while it still +// holds no entries. A foreign entry that appeared after the claim must never +// be removed: the rollback has no ownership proof for it. +func removeClaimIfEmpty(destination string) error { + entries, err := skillPathReadDir(destination) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err) + } + if len(entries) > 0 { + return nil + } + if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err) + } + return nil +} + +// moveSkillDirChildrenIntoClaim moves every top-level child of source into +// the claimed destination — each through moveSkillDirChildNoReplace, so a +// concurrently created same-name entry fails atomically — and restores the +// source mode on the claim. The claim is re-read after the moves: an entry +// count above the source's means a different-named foreign object landed +// mid-move, and the publication aborts with the destination retained. Any +// failure moves the already relocated children back and removes the claim +// only while it is empty, leaving the source intact and foreign entries in +// place. +func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error { + entries, err := skillPathReadDir(source) + if err != nil { + return errors.Join( + fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err), + removeClaimIfEmpty(destination), + ) + } + moved := make([]string, 0, len(entries)) + rollback := func() error { + var rollbackErr error + for i := len(moved) - 1; i >= 0; i-- { + if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination)) + return rollbackErr + } + for _, entry := range entries { + name := entry.Name() + if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback()) + } + moved = append(moved, name) + } + live, liveErr := skillPathReadDir(destination) + if liveErr != nil { + return errors.Join( + fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr), + rollback(), + ) + } + if len(live) > len(entries) { + return errors.Join( + fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist), + rollback(), + ) + } + if err := skillPathChmod(destination, sourceMode); err != nil { + return errors.Join( + fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err), + rollback(), + ) + } + return nil +} + +// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the +// destination exists, then removes the source to complete the move. +func renameSkillFileNoReplaceFallback(source, destination string) error { + if err := skillPathLink(source, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err) + } + return skillPathRemove(source) +} + +// renameSkillSymlinkNoReplaceFallback recreates the link at the destination +// with os.Symlink — atomic, EEXIST when taken — and then removes the source. +func renameSkillSymlinkNoReplaceFallback(source, destination string) error { + target, err := skillPathReadlink(source) + if err != nil { + return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err) + } + if err := skillPathSymlink(target, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err) + } + return skillPathRemove(source) +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go new file mode 100644 index 00000000..94df0fb2 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -0,0 +1,22 @@ +//go:build darwin + +package upgrade + +import ( + "errors" + "syscall" + + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { + return unix.RenamexNp(source, destination, unix.RENAME_EXCL) +} + +// Volumes whose filesystem does not implement renamex_np (several network and +// FUSE mounts) reject RENAME_EXCL instead of performing the rename. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go new file mode 100644 index 00000000..20d1be77 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package upgrade + +import ( + "errors" + "syscall" + + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) +} + +// rename(2) documents EINVAL as "the filesystem does not support one of the +// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels +// without renameat2 at all. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) || + errors.Is(err, syscall.EOPNOTSUPP) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go new file mode 100644 index 00000000..c9695771 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +func renameSkillPathNoReplaceAtomic(_, _ string) error { + return errNoReplaceRenameUnsupported +} + +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go new file mode 100644 index 00000000..6e068eaa --- /dev/null +++ b/internal/upgrade/skill_publish_rename_test.go @@ -0,0 +1,300 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +type mockSpecialInfo struct{} + +func (mockSpecialInfo) Name() string { return "source" } +func (mockSpecialInfo) Size() int64 { return 0 } +func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe } +func (mockSpecialInfo) ModTime() time.Time { return time.Time{} } +func (mockSpecialInfo) IsDir() bool { return false } +func (mockSpecialInfo) Sys() interface{} { return nil } + +// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for +// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). +// Those installs must still publish, and must still refuse to clobber an +// occupied destination. The fallback uses atomic no-clobber primitives +// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone +// check-then-rename. +func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { + unsupported := testNoReplaceUnsupportedErrors() + + t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" { + t.Fatalf("destination = %q, %v", data, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination for files", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + for _, path := range []string{source, destination} { + if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" { + t.Fatalf("destination must be untouched, got %q, %v", data, readErr) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("link error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + seedUpgradeSkill(t, destination, "existing", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + assertUpgradeSkillContent(t, destination, "existing") + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(_, dst string) error { + // A concurrent writer lands inside the empty claim before the + // slow path can take it; the fast-path rename then fails the + // way it would against a populated target. + if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil { + return err + } + return os.ErrExist + }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") { + t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" { + t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("a genuine error is not retried", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + calls := 0 + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { + calls++ + return os.ErrPermission + }) + if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("permission error must surface, got %v", err) + } + if calls != 1 { + t.Fatalf("atomic attempts = %d, want 1", calls) + } + }) + + t.Run("source stat failure surfaces without rename", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "destination") + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("stat error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { + crossDevice := testCrossDeviceError() + if isNoReplaceRenameUnsupported(crossDevice) { + t.Fatal("cross-device error must not be treated as an unsupported flag") + } + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "backup", "destination") + original := skillPathRenameNoReplaceAtomic + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { + if from == source { + return crossDevice + } + return original(from, to) + }) + if err := moveSkillPathRecoverably(source, destination); err != nil { + t.Fatalf("cross-device move = %v", err) + } + if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" { + t.Fatalf("cross-device destination = %q, %v", data, err) + } + }) + + t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("mkdir error must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("remove failure after rename failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) + removeErr := errors.New("simulated remove failure") + testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, removeErr) { + t.Fatalf("remove error must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must still exist after remove failure: %v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + _ = os.RemoveAll(destination) + }) + + t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // macOS refuses to rename over any directory, empty or not. The + // claim's verified emptiness — not the rename error — decides + // whether the child-move slow path may proceed. + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("child-move must publish despite rename failure, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + }) + + t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + _ = os.Remove(dst) + return originalRename(src, dst) + }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("non-regular non-directory source fails safely", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("unsupported error must surface for special files, got %v", err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go new file mode 100644 index 00000000..18927afe --- /dev/null +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package upgrade + +import "golang.org/x/sys/windows" + +func renameSkillPathNoReplaceAtomic(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) +} + +// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied +// destination on every supported Windows version, so there is nothing to +// degrade to. +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go index 02afa793..dcf1792e 100644 --- a/internal/upgrade/skill_publish_transaction_test.go +++ b/internal/upgrade/skill_publish_transaction_test.go @@ -92,10 +92,10 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("backup failure restores earlier victims", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { if src == filepath.Join(base, "dingtalk-b") { - return failure + return "", failure } return originalRename(src, dest) }) @@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("publish failure rolls back complete set", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath 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" { 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) { 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) { home, base, monoRoot := seed(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath 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" { 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) { 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) { - 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 }) 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")}}, ) 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) { - testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", restoreFailure }) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) if !errors.Is(err, restoreFailure) { t.Fatalf("restore rename error = %v", err) @@ -236,13 +236,13 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) second := filepath.Join(home, "second") seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, second, "second", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { switch { case src == second: - return failure + return "", failure case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): - return restoreFailure + return "", restoreFailure default: return originalRename(src, dest) } @@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) staged := filepath.Join(home, "skills", ".stage", "dws") seedUpgradeSkill(t, old, "old", false) seedUpgradeSkill(t, staged, "new", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { - switch { - case src == staged: - return failure - case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): - return restoreFailure - default: - return originalRename(src, dest) + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + return SkillPathPublication{}, failure + }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { + if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) { + return "", restoreFailure } + return originalRename(src, dest) }) err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { diff --git a/internal/upgrade/skill_publish_uncertain_test.go b/internal/upgrade/skill_publish_uncertain_test.go new file mode 100644 index 00000000..f32c2707 --- /dev/null +++ b/internal/upgrade/skill_publish_uncertain_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageUncertainPublicationState pins the contract behind +// ErrSkillPathPublicationUncertain: a child-move publication whose mkdir +// claim no longer describes the destination reports the uncertain sentinel +// instead of guessing, keeps the destination in place, and stops upstream +// retry paths (the linked-copy fallback) from displacing the concurrent +// writer's object. +func TestCrossPlatformCoverageUncertainPublicationState(t *testing.T) { + + t.Run("destination replaced after child-move reports uncertain and is retained", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // Force the child-move slow path on every platform (Linux would + // otherwise publish through the fast-path rename over the claim). + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + // The identity seam simulates the wholesale replacement: the mkdir + // claim captures "claim", every later read of the destination + // reports the replacement's "foreign" identity. This keeps the + // test deterministic on platforms without a stable file identity. + destinationReads := 0 + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + if path != destination { + return "" + } + destinationReads++ + if destinationReads == 1 { + return "claim" + } + return "foreign" + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("replacement after child-move = %v, want uncertain sentinel", err) + } + if destinationReads != 2 { + t.Fatalf("destination identity reads = %d, want 2 (claim capture + ownership check)", destinationReads) + } + // Nothing was rolled back over the retained destination. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("child-move reports the real claim identity where the platform supports it", func(t *testing.T) { + dir := t.TempDir() + if id := skillPathFileIdentity(dir); id == "" { + t.Skip("platform reports no stable file identity; the claim witness degrades to the fingerprint backstop") + } + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the mkdir-claim identity on platforms that report one") + } + if live := skillPathFileIdentity(destination); live == "" || live != claim { + t.Fatalf("destination identity = %q, want the published claim %q", live, claim) + } + }) + + t.Run("uncertain publication passes through the staged-set transaction", func(t *testing.T) { + home := t.TempDir() + old := filepath.Join(home, "skills", "dws") + staged := filepath.Join(home, "skills", ".stage", "dws") + seedUpgradeSkill(t, old, "old", false) + seedUpgradeSkill(t, staged, "new", false) + testseam.Swap(t, &upgradePublishSkillPath, func(string, string) (SkillPathPublication, error) { + return SkillPathPublication{}, fmt.Errorf("并发写入: %w", ErrSkillPathPublicationUncertain) + }) + err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("staged-set publish = %v, want uncertain passthrough", err) + } + // The pre-publish victim is restored; the uncertain destination is + // never retried or overwritten. + assertUpgradeSkillContent(t, old, "old") + }) + + t.Run("mono copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + monoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil { + t.Fatal(err) + } + linkedDest := filepath.Join(home, ".claude", "skills", "dws") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if dest == linkedDest { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMonoSkillLocations(home, monoRoot) + if err != nil { + t.Fatalf("mono upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedDest && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedDest) + } + }) + + t.Run("multi copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-a") + linkedBase := filepath.Join(home, ".claude", "skills") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if strings.HasPrefix(dest, linkedBase) { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMultiSkillLocations(home, multiRoot, []string{"dingtalk-a"}) + if err != nil { + t.Fatalf("multi upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedBase && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedBase) + } + }) +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 5e0310b6..04e92642 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -43,11 +43,11 @@ func defaultHooks() *Hooks { } } -// openSupplementServers returns helper-only MCP endpoints owned by the open -// CLI. They are callable by explicit server ID but are deliberately excluded -// from VisibleProducts, so no top-level product command is generated. They -// stay separate from syncdata.StaticServers because only explicitly wired CLI -// helpers may call them; they are not public MCP product surfaces. +// openSupplementServers returns explicitly wired MCP endpoints owned by the +// open CLI. They are callable by explicit server ID but are deliberately +// excluded from VisibleProducts, so this hook never generates a top-level +// product command. A public command using one of these endpoints must register +// its Cobra tree explicitly via helpers.RegisterPublic. func openSupplementServers() []ServerInfo { return []ServerInfo{ { @@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo { Name: "钉钉白板", Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard", }, + { + ID: "recruit", + Name: "钉钉招聘", + Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff", + Prefixes: []string{"recruit", "job"}, + }, } } diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index c581c714..7418836e 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { servers := openSupplementServers() foundMCPMeta := false foundWhiteboard := false + foundRecruit := false for _, server := range servers { + if server.ID == "recruit" { + foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" && + len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job" + } if server.ID == "whiteboard" { foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard" } @@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { if !foundWhiteboard { t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint") } + if !foundRecruit { + t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") + } } diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh index 50cba792..d24371b7 100755 --- a/scripts/dev/test-multi-profile-e2e.sh +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -638,13 +638,13 @@ MONO_HOME="$WORKDIR/home-mono" MULTI_HOME="$WORKDIR/home-multi" mkdir -p "$MONO_HOME" "$MULTI_HOME" capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId" -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName" capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" log "verifying empty profile list" capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 0b37827d..5c1e6546 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -27,6 +27,8 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json", ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ] diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 894e4f5e..686db8e1 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -22,9 +22,529 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat $NoSkills = $env:DWS_NO_SKILLS -eq "1" $SkillName = "dingtalk-misc" +# vercel-labs/skills agents.ts (c6f69c6). Each row is +# id|universal|effective global directory; '-' means no global directory. +$AgentRegistryRows = @( + "aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills", + "antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills", + "astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills", + "bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills", + "cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills", + "codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills", + "command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills", + "crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills", + "devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-", + "firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills", + "github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills", + "hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills", + "junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills", + "kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills", + "kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills", + "minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills", + "mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills", + "ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills", + "qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills", + "rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills", + "terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills", + "trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills", + "zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills", + "zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills", + "promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills" +) +$LegacyAgentCleanupRows = @( + "dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills", + "dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills", + "dws-legacy-windsurf|1|.windsurf\skills" +) + function Say($m) { Write-Host " $m" } function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } +function Resolve-AgentBase([string]$row) { + $parts = $row.Split('|') + $id = $parts[0] + if ($parts[2] -eq "-") { return $null } + switch ($id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } } + "codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } } + "grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } } + "hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } } + "mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } } + "openclaw" { + foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $HOME $legacy + if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" } + } + } + { $_ -in @("amp", "replit", "universal") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + return Join-Path $xdg "agents\skills" + } + { $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + $child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } } + return Join-Path $xdg $child + } + } + return Join-Path $HOME $parts[2] +} + +function Test-AgentBaseDetected([string]$id, [string]$base) { + $parent = Split-Path $base -Parent + switch ($id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + +function Test-PathLexically([string]$path) { + try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} + $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf + if (!(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) +} + +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-DevSkillPathLexically $Source + } catch { + $removeErr = $_ + try { + Remove-DevSkillPathLexically $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } + } +} + +function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { + $exception = $Record.Exception + while ($null -ne $exception) { + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-DevSkillMetadata($SourceItem, [string]$Destination) { + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-DevSkillPermissionFingerprint([string]$Path) { + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) { + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name) + } + Copy-DevSkillMetadata $item $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-DevSkillMetadata $item $Destination +} + +function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) { + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill path type mismatch: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill link target mismatch: $Source != $Destination" + } + return + } + if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) { + throw "Skill path permissions mismatch: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" } + Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" } + if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne + (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) { + throw "Skill file digest mismatch: $Source != $Destination" + } +} + +function Remove-DevSkillPathLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Removes a staging directory that may hold junction/symlink children. Link +# children must be removed non-recursively first: Windows PowerShell 5.1 can +# follow reparse points during Remove-Item -Recurse and would delete the +# canonical store's contents through the staged link. +function Remove-DevLinkStageRoot([string]$StageRoot) { + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false } + } + try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false } + return $ok +} + +function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-DevSkillPath $Source $Destination + return + } catch { + if (!(Test-DevCrossDeviceMoveError $_)) { throw } + } + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Move-DevSkillPath $stage $Destination + $published = $true + try { + Assert-DevSkillPathCopy $Source $Destination + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "Skill staging cleanup failed: $stageRoot" + } + } catch { + $postErr = $_ + try { + # Retract only after proving the destination is still this + # transaction's copy (mirrors install.ps1's + # Remove-PublishedSkillPathSafely retract): a path-blind + # lexical removal would delete a concurrent replacement. + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $Source "" + } + } catch { + throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained" + } + throw "Skill move failed, dest retracted, source retained ${Source}: $postErr" + } + try { Remove-DevSkillPathLexically $Source } catch { + throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" + } + if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + if (!(Remove-DevLinkStageRoot $stageRoot)) { + if (-not $published) { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" + } + } + } + throw $failure + } +} + +# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so +# copies retired from different Agent roots stay distinguishable. Mirrors +# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall +# back to the bare leaf. +function Get-DevSkillBackupName([string]$Dir) { + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { return $leaf } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching +# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this +# run created are never pruned: a rollback still needs them. +$DevSkillBackupKeep = 5 +$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$DevSkillBackupMarkerFile = ".dws-skill-backup" +$DevSkillBackupMarkerBody = "dws skill backup v1" + +# Write-DevSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-DevSkillBackupMarker([string]$Root) { + [System.IO.File]::WriteAllText( + (Join-Path $Root $DevSkillBackupMarkerFile), + "$DevSkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-DevSkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-DevSkillBackupMarker([string]$Dir) { + try { + $marker = Join-Path $Dir $DevSkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $DevSkillBackupMarkerBody) + } catch { + return $false + } +} + +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-DevLinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-DevSkillBackupTreeLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-DevSkillBackupTreeLexically $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + +function Remove-OldDevSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $DevSkillBackupKeep. + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-DevSkillBackupMarker $_.FullName) } | + Sort-Object -Property Name) + $excess = $dirs.Count - $DevSkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + try { Remove-DevSkillBackupTreeLexically $dir.FullName } catch { } + $excess-- + } +} + +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + if ($null -ne $BackupPath) { $BackupPath.Value = "" } + if (!(Test-PathLexically $path)) { return } + $backupBase = Join-Path $HOME ".dws\skill-backups" + $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $name = Get-DevSkillBackupName $path + $backupRoot = Join-Path $backupBase $stamp + $target = Join-Path $backupRoot $name + $i = 1 + while (Test-PathLexically $target) { + $backupRoot = Join-Path $backupBase "$stamp-$i" + $target = Join-Path $backupRoot $name + $i++ + if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" } + } + New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null + # Stamp ownership immediately after creating the stamp root and before + # any skill directory moves into it, so an interrupted backup can never + # leave an unmarked (never-prunable) stamp behind. + try { + Write-DevSkillBackupMarker $backupRoot + } catch { + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $backupRoot -Force -ErrorAction SilentlyContinue + throw + } + Move-DevSkillPathRecoverably $path $target + if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null + Remove-OldDevSkillBackups + } catch { + Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)" + } +} + +function Get-DevSkillLinkSignature([string]$Path) { + try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null } + if (!$item.LinkType) { return $null } + return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0")) +} + +function New-DevRollbackFailure([string]$Message) { + $failure = [System.InvalidOperationException]::new($Message) + $failure.Data["DWSRollbackFailed"] = $true + return $failure +} + +function Remove-VerifiedDevSkillPublication( + [string]$Destination, + [string]$PublishedSource, + [string]$PublishedLinkSignature +) { + $parent = Split-Path $Destination -Parent + $quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N")) + Move-DevSkillPath $Destination $quarantine + try { + if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) { + if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) { + throw "published Skill link was replaced by another process: $Destination" + } + } elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) { + Assert-DevSkillPathCopy $PublishedSource $quarantine + } else { + throw "Skill restore destination is occupied by another process: $Destination" + } + Remove-DevSkillPathLexically $quarantine + } catch { + $failure = $_ + if (Test-PathLexically $quarantine) { + try { + if (Test-PathLexically $Destination) { throw "original path is occupied" } + Move-DevSkillPath $quarantine $Destination + } catch { + throw "$failure; concurrent object retained at $quarantine`: $_" + } + } + throw $failure + } +} + +function Restore-DevSkillBackup( + [string]$Destination, + [string]$Backup, + [string]$PublishedSource = "", + [string]$PublishedLinkSignature = "" +) { + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature + } + if (![string]::IsNullOrWhiteSpace($Backup)) { + Move-DevSkillPathRecoverably $Backup $Destination + } +} + +function Publish-DevSkillCopy([string]$Source, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + $publishedSource = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $publishedSource = $Source + Assert-DevSkillPathCopy $Source $Destination + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") + } + throw $publishFailure + } + } finally { + Remove-DevLinkStageRoot $stageRoot | Out-Null + } +} + +function Publish-DevSkillJunction([string]$Target, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + $publishedSignature = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + $publishedSignature = Get-DevSkillLinkSignature $Destination + if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" } + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") + } + throw $publishFailure + } + } finally { + # The staged payload is a junction to the canonical store; it must be + # removed lexically or Windows PowerShell 5.1 recursion could delete + # canonical content through the reparse point. + Remove-DevLinkStageRoot $stageRoot | Out-Null + } +} + function Get-Arch { if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } try { @@ -40,6 +560,26 @@ function Get-Arch { } } +function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) { + $checksums = Join-Path $TempDir "checksums.txt" + if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) { + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" ` + -OutFile $checksums -UseBasicParsing -ErrorAction Stop + } catch { + Die "Could not download checksums.txt; refusing unverified release assets." + } + } + $line = Get-Content -LiteralPath $checksums | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $line) { Die "$AssetName is missing from checksums.txt." } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." } + Say "SHA256 checksum verified: $AssetName" +} + # Read the releases list (newest first) and take the top tag, so this also works # if a release is ever published as a prerelease (which /releases/latest skips). if (-not $Version) { @@ -70,6 +610,7 @@ try { Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` -OutFile $zip -UseBasicParsing } catch { Die "Binary download failed - does release $Version have $asset?" } +Assert-DevReleaseAssetChecksum $zip $asset $tmp Expand-Archive -Path $zip -DestinationPath $tmp -Force $exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 @@ -84,6 +625,7 @@ if (-not $NoSkills) { $skzip = Join-Path $tmp "dws-skills.zip" Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` -OutFile $skzip -UseBasicParsing + Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp $skdir = Join-Path $tmp "sk" Expand-Archive -Path $skzip -DestinationPath $skdir -Force @@ -95,33 +637,63 @@ if (-not $NoSkills) { if ($src) { # cache so `dws skill setup --mode multi` can find a source later $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" - if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } - New-Item -ItemType Directory -Path $cache -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + Publish-DevSkillCopy $src $cache - $agentDirs = @( - ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", - ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", - ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", - ".hermes\skills", ".config\opencode\skills" - ) - $installed = 0; $idx = 0 - foreach ($d in $agentDirs) { - $base = Join-Path $HOME $d - $parent = Split-Path $base -Parent - if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $canonicalBase = Join-Path $HOME ".agents\skills" + $canonical = Join-Path $canonicalBase $SkillName + Publish-DevSkillCopy $src $canonical + $installed = 1 + $failed = 0 + $retireFailed = 0 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { + $parts = $row.Split('|'); $universal = $parts[1] -eq "1" + $base = Resolve-AgentBase $row + if ([string]::IsNullOrWhiteSpace($base)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) + if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } + if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - New-Item -ItemType Directory -Path $dest -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force - $installed++; $idx++ + if ($universal) { + # Cleanup-only migration: universal Agents read the + # canonical store directly, so nothing is installed here. A + # retire failure only leaves an obsolete copy behind and + # must never fail an otherwise complete install. + try { Backup-DevSkill $dest } catch { + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))" + $retireFailed++ + } + continue + } + # Per-agent degrade like install.sh: a failed link→copy fallback + # skips that agent loudly instead of aborting the whole loop. + try { + New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null + try { Publish-DevSkillJunction $canonical $dest } + catch { + # A failed rollback leaves the old backup as the only + # trusted copy. Do not run a second publisher over that + # state; surface the failure and continue other agents. + if ($_.Exception.Data["DWSRollbackFailed"]) { throw } + Publish-DevSkillCopy $canonical $dest + } + } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue + } + $installed++ + } + if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } + if ($retireFailed -gt 0) { + Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)" } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" } } catch { - Say "(skill install skipped: $($_.Exception.Message))" + Die "Skill install failed: $($_.Exception.Message)" } } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 1e7c5d7f..b7afc315 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,448 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "✅ SHA256 checksum verified: ${name}" +} + + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + +copy_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. + if mkdir "$dest" 2>/dev/null; then + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" + return 0 + fi + retract_published_children "$stage" "$dest" "$manifest" + rmdir "$dest" 2>/dev/null || true + fi + discard_stage "$stage" + rm -f "$manifest" + if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 + if mkdir "$dest" 2>/dev/null; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then + rmdir "$backup" 2>/dev/null || true + else + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + rm -f "$restore_manifest" + fi + return 1 +} + +# refresh_cache_tree +# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree +# is byte-reproducible from the release asset, so it is never archived into +# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the +# old cache after the replacement is fully staged, so an interrupted install can +# no longer leave a half-copied cache behind. +refresh_cache_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. + prune_skill_backups + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done + record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 + mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi + return 1 + } + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. +SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) + done +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + # 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" ]; 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 +} + need_cmd curl detect_os() { @@ -81,20 +523,43 @@ install_skill() { # cache so `dws skill setup --mode multi` can find a source later cache="$HOME/.dws/skills/multi/$SKILL_NAME" - rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" + refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; } - installed=0; idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi - dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" - installed=$((installed + 1)); idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$SKILL_NAME" + copy_tree "$src" "$canonical" || return 1 + installed=1 + failed=0 + retire_failed=0 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base" || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. + if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2 + retire_failed=$((retire_failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + continue + fi + installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 + return 1 + fi say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } @@ -119,6 +584,7 @@ main() { say "⬇ Downloading ${asset} ..." curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" else @@ -132,10 +598,11 @@ main() { # 2) dev skill from the release's skills bundle if [ "$NO_SKILLS" != "1" ]; then if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + verify_release_asset "dws-skills.zip" "$tmp/skills.zip" mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" - install_skill "$tmp/sk" + install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警" else say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)" fi diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 3402a333..aa3716cf 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "SHA256 checksum verified: ${name}" +} + detect_os() { case "$(uname -s)" in Linux*) echo linux ;; @@ -89,39 +111,467 @@ resolve_event_version() { [ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag." } + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + copy_tree() { src="$1" dest="$2" - rm -rf "$dest" - mkdir -p "$dest" - cp -R "$src/." "$dest/" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. + if mkdir "$dest" 2>/dev/null; then + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" + return 0 + fi + retract_published_children "$stage" "$dest" "$manifest" + rmdir "$dest" 2>/dev/null || true + fi + discard_stage "$stage" + rm -f "$manifest" + if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 + if mkdir "$dest" 2>/dev/null; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then + rmdir "$backup" 2>/dev/null || true + else + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + rm -f "$restore_manifest" + fi + return 1 +} + +# refresh_cache_tree +# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills +# caches: those trees are byte-reproducible from the release asset, so archiving +# every stale generation into ~/.dws/skill-backups only grows that directory +# without bound. The previous cache is deleted, exactly like install-devapp.sh — +# but only after the replacement is fully staged and published. +refresh_cache_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. + prune_skill_backups + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp" + backup="$backup_root/$name" + i=0 + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done + record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 + mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi + return 1 + } + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. +SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) + done +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + # 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" ]; 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 } install_skill_to_homes() { src="$1" skill_name="$2" - - installed=0 - idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then - idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$skill_name" + copy_tree "$src" "$canonical" || return 1 + installed=1 + failed=0 + retire_failed=0 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base" || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. + if ! backup_skill_dir "$base/$skill_name" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2 + retire_failed=$((retire_failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) continue fi - copy_tree "$src" "$base/$skill_name" installed=$((installed + 1)) - idx=$((idx + 1)) done - - if [ "$installed" -eq 0 ]; then - copy_tree "$src" "$HOME/.agents/skills/$skill_name" - installed=1 + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 + return 1 fi printf '%s\n' "$installed" } @@ -176,17 +626,21 @@ install_skills_from_bundle() { event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME" misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME" - copy_tree "$event_src" "$event_cache" - copy_tree "$shared_src" "$shared_cache" - copy_tree "$misc_src" "$misc_cache" + refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}" + refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}" + refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}" mono_cache="$HOME/.dws/skills/mono" - copy_tree "$mono_src" "$mono_cache" + refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}" - event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" - shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" - misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" - mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" + event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ + || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \ + || err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \ + || err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \ + || err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)" @@ -212,6 +666,7 @@ install_binary() { say "Downloading ${asset} ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then extract_zip "$tmp/$asset" "$tmp/bin" @@ -240,6 +695,7 @@ install_skills() { say "Downloading dws-skills.zip ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip" mkdir -p "$tmp/skills" extract_zip "$tmp/dws-skills.zip" "$tmp/skills" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index ad4eeb03..77ad6145 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -53,24 +53,54 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir" return 1 fi done - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 + mkdir -p "$_bed_stamp_root" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi + printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target" @@ -80,6 +110,88 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. +DWS_SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac + rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + _psb_drop=$((_psb_drop - 1)) + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -123,6 +235,46 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } + curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { + printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 + return 1 + } + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { + printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 + return 1 + } + [ "$_checksum_actual" = "$_checksum_expected" ] || { + printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 + return 1 + } + printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -189,24 +341,147 @@ backup_and_record_skill_dir() { fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + printf ' ⚠️ 无法隔离待回滚 Skill: %s\n' "$_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s(并发对象保留于 %s)\n' "$_qrr_dest" "$_qrr_payload" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported +# its exact backup path. Dest is claimed into quarantine first; identity is +# re-checked there so a concurrent replacement is renamed back instead of +# being deleted by path. Paths containing newlines are outside the supported # installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; } + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi @@ -215,6 +490,135 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree, mirroring the Go +# fingerprint: sorted relative paths emit type+permission bits plus either a +# content sha256 or the symlink target, and the whole line stream feeds +# sha256_stdin. Failure detection is asymmetric and best-effort: with no hash +# tool installed sha256_stdin itself returns 1 and the identity genuinely +# fails, but an unreadable file only exits the left-hand subshell — /bin/sh +# has no pipefail, so the pipeline returns sha256_stdin's status and the +# digest is computed over a truncated stream instead of failing. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then + rm -f "$_cnsl_nested" + fi +} + +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir @@ -408,16 +812,99 @@ multi_tree_has_skills() { return 1 } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering duplicates. -retire_generic_skill_root() { +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots, in pinned registry order, de-duplicated. Records +# without a global root (`-`) and canonical-direct records (`.agents/skills`) +# are not per-Agent targets and are skipped. +agent_skill_dirs() { + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -430,76 +917,162 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. +link_canonical_skills_to_base() { + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n' + printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical" + return 1 + fi + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + # 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 + # 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 + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_dest" + done + rm -rf "$_lcs_stage" +} + # Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_multi_skills_to_root() { multi_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else - failed=$((failed + 1)) - printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed" return 1 fi write_skills_state "$multi_src" "install-skills.sh" || return 1 + printf ' ✅ DWS Skills 安装完成\n' + printf ' 统一安装位置:%s/.agents/skills\n' "$root" + printf ' 已自动适配本机上检测到的 Agent\n' + printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } _install_multi_to_base() { @@ -574,12 +1147,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_ms_stage" @@ -633,12 +1201,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_mono_stage" @@ -654,84 +1217,48 @@ install_skills_to_root() { skill_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if [ "$root" = "$HOME" ]; then - label="~/$agent_dir/$SKILL_NAME" - else - label="$root/$agent_dir/$SKILL_NAME" + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue fi - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - if [ "$root" = "$HOME" ]; then - flabel="~/.agents/skills/$SKILL_NAME" - else - flabel="$root/.agents/skills/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed" return 1 fi rm -f "$DWS_STATE_ROOT/skills-state.json" + printf ' ✅ DWS Skills 安装完成\n' + printf ' 统一安装位置:%s/.agents/skills\n' "$root" + printf ' 已自动适配本机上检测到的 Agent\n' + printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } # ── Main ───────────────────────────────────────────────────────────────────── @@ -755,6 +1282,7 @@ main() { [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" + verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. @@ -803,6 +1331,10 @@ main() { fi fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d9c62a1b..93fdf168 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @( "dingtalk-todo", "dingtalk-wiki", "dws-shared" ) -# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS). -$AgentDirs = @( - ".agents\skills", - ".claude\skills", - ".cursor\skills", - ".qoder\skills", - ".qoderwork\skills", - ".gemini\skills", - ".codex\skills", - ".zcode\skills", - ".github\skills", - ".windsurf\skills", - ".augment\skills", - ".cline\skills", - ".amp\skills", - ".kiro\skills", - ".trae\skills", - ".openclaw\skills", - ".hermes\skills" +# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6). +# Universal agents read the canonical store directly; their separate historical +# global directories are cleanup-only. Non-universal agents get junctions to +# canonical (or complete copies when junction creation is unavailable). +$AgentRegistry = @( + [pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" }, + [pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" }, + [pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" }, + [pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" }, + [pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" }, + [pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" }, + [pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" }, + [pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" }, + [pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" }, + [pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" }, + [pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" }, + [pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" }, + [pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" }, + [pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" }, + [pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" }, + [pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" }, + [pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" }, + [pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" }, + [pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" }, + [pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" }, + [pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" }, + [pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" }, + [pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" }, + [pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null }, + [pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" }, + [pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" }, + [pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" }, + [pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" }, + [pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" }, + [pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" }, + [pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" }, + [pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" }, + [pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" }, + [pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" }, + [pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" }, + [pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" }, + [pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" }, + [pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" }, + [pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" }, + [pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" }, + [pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" }, + [pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" }, + [pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" }, + [pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" }, + [pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" }, + [pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" }, + [pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" }, + [pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" }, + [pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" }, + [pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" }, + [pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" }, + [pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" }, + [pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" }, + [pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" }, + [pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" }, + [pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" }, + [pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" }, + [pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" }, + [pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" }, + [pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" }, + [pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" }, + [pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" }, + [pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" }, + [pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" }, + [pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" }, + [pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" }, + [pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" }, + [pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null }, + [pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" }, + [pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" } ) +# DWS compatibility targets that are not part of the upstream registry. +# Qoderwork remains a non-universal install target; the other entries are +# migration cleanup targets only and intentionally do not count as agents. +$LegacyAgentCleanupTargets = @( + [pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" }, + [pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" }, + [pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" }, + [pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" }, + [pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" } +) + +# Kept as a compatibility surface for policy tests and downstream packagers. +$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir }) + # ── Helpers ────────────────────────────────────────────────────────────────── function Write-Say { @@ -182,7 +258,255 @@ function Move-SkillPath { [string]$Source, [string]$Destination ) - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-SkillPathLexically -Path $Destination) { + throw "Skill move destination already exists: $Destination" + } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + # Move-Item treats an existing directory as a container and nests the + # source below it. Directory.Move has the exact rename semantics this + # transaction requires: an occupied destination fails without touching + # either path. + [System.IO.Directory]::Move($Source, $Destination) + } else { + # File.Move replaces an occupied dest on Windows. Copy without + # overwrite refuses that dest, then the source is removed only after + # the exclusive copy succeeds. + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-SkillPathLexically -Path $Source + } catch { + $removeErr = $_ + try { + Remove-SkillPathLexically -Path $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } + } +} + +function Test-CrossDeviceMoveError { + param([System.Management.Automation.ErrorRecord]$Record) + $exception = $Record.Exception + while ($null -ne $exception) { + # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as + # the low word of an IOException HRESULT. + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-SkillPathMetadata { + param($SourceItem, [string]$Destination) + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-SkillPathPermissionFingerprint { + param([string]$Path) + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl + } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name) + } + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { + throw "不支持复制特殊 Skill 路径 $Source" + } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination +} + +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill 路径类型不一致: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill 链接目标不一致: $Source != $Destination" + } + return + } + if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne + (Get-SkillPathPermissionFingerprint -Path $Destination)) { + throw "Skill 路径权限不一致: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { + throw "Skill 目录项数量不一致: $Source != $Destination" + } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { + throw "Skill 目录项不一致: $Source != $Destination" + } + Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { + throw "Skill 文件大小不一致: $Source != $Destination" + } + $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash + $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash + if ($sourceHash -ne $destinationHash) { + throw "Skill 文件内容摘要不一致: $Source != $Destination" + } +} + +function Remove-SkillPathLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Removes a staging directory that may hold unpublished junction/symlink +# children. Every link child must be removed non-recursively first: Windows +# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points +# during Remove-Item -Recurse and would delete the canonical store's contents +# through the staged link. CI runs pwsh 7, which masks this behavior. +function Remove-LinkStageRoot { + param([string]$StageRoot) + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false } + } + try { + Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop + } catch { + $ok = $false + } + return $ok +} + +# Resolves a path to its physical location, dereferencing junction/symlink +# reparse points at any depth. Resolve-Path alone is lexical and never +# recognizes DWS's own junctions, so every rerun would back them up and +# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P +# (shell) on the other installer surfaces. +function Get-PhysicalSkillPath { + param([string]$Path, [int]$Depth = 0) + if ($Depth -gt 40) { return $null } + $parent = Split-Path $Path -Parent + if ([string]::IsNullOrWhiteSpace($parent)) { return $Path } + $parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1) + if ($null -eq $parentPhysical) { return $null } + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical } + $candidate = Join-Path $parentPhysical $leaf + try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } + $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 + if ($item.LinkType -and $target) { + $targetPath = "" + if ([System.IO.Path]::IsPathRooted([string]$target)) { + $targetPath = [string]$target + } else { + $targetPath = Join-Path $parentPhysical ([string]$target) + } + # Resolve the target recursively as well. A custom HOME or canonical + # root can itself sit below another junction, which must compare equal + # to the fully physical path just like EvalSymlinks/realpath do. + return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1) + } + return $candidate +} + +# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on +# the destination filesystem, copy links lexically, verify, publish, then +# remove the source. Before source removal every failure keeps the original; +# a removal failure deliberately leaves both verified copies and fails loud. +function Move-SkillPathRecoverably { + param([string]$Source, [string]$Destination) + if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-SkillPath -Source $Source -Destination $Destination + return + } catch { + if (!(Test-CrossDeviceMoveError -Record $_)) { throw } + } + + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false + try { + Copy-SkillPathLexically -Source $Source -Destination $stage + Assert-SkillPathCopy -Source $Source -Destination $stage + Move-SkillPath -Source $stage -Destination $Destination + $published = $true + $publication = [pscustomobject]@{ Path = $Destination; Source = $Source } + try { + Assert-SkillPathCopy -Source $Source -Destination $Destination + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "Skill staging 清理失败: $stageRoot" + } + } catch { + $postErr = $_ + try { + Remove-PublishedSkillPathSafely -Record $publication + } catch { + throw "Skill 移动状态不确定:$postErr;撤回目标 $Destination 失败: $_;源 $Source 与目标 $Destination 均保留" + } + throw "Skill 移动失败,目标已撤回,原路径保留 ${Source}: $postErr" + } + try { + Remove-SkillPathLexically -Path $Source + } catch { + throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_" + } + if (Test-SkillPathLexically -Path $Source) { + throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)" + } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + if (-not $published) { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" + } + } + } + throw $failure + } } function Get-Arch { @@ -264,6 +588,26 @@ function Get-GiteeAssetUrl { return "" } +function Assert-ReleaseAssetChecksum { + param([string]$AssetPath, [string]$AssetName, [string]$TempDir) + if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } + if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." } + $checksumPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop + } catch { + Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName." + } + $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." } + $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." } + Write-Say "✅ SHA256 checksum verified: $AssetName" +} + function Resolve-Source { # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. if ($GiteeRepo -ne "") { return } @@ -389,6 +733,116 @@ function Publish-SkillCache { } } +# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so +# copies retired from different Agent roots stay distinguishable inside one +# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths +# outside HOME fall back to the bare leaf. +function Get-SkillBackupName { + param([string]$Dir) + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { + return $leaf + } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest +# stamped backup directories are kept, matching skillBackupKeep and +# pruneSkillBackups in internal/upgrade/paths.go. +$SkillBackupKeep = 5 +$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$SkillBackupMarkerFile = ".dws-skill-backup" +$SkillBackupMarkerBody = "dws skill backup v1" + +# Write-SkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-SkillBackupMarker { + param([string]$Root) + [System.IO.File]::WriteAllText( + (Join-Path $Root $SkillBackupMarkerFile), + "$SkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-SkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-SkillBackupMarker { + param([string]$Dir) + try { + $marker = Join-Path $Dir $SkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $SkillBackupMarkerBody) + } catch { + return $false + } +} + +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-LinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-SkillBackupTreeLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-SkillBackupTreeLexically -Path $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + +# Remove-OldSkillBackups deletes the oldest stamped backup directories once +# more than $SkillBackupKeep remain. Stamps sort lexicographically in +# chronological order, so name order is age order. Roots created during this +# run are never pruned: an in-flight transaction still needs them to roll +# back. Best-effort — a prune failure never fails the install. +function Remove-OldSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $SkillBackupKeep. + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-SkillBackupMarker -Dir $_.FullName) } | + Sort-Object -Property Name) + $excess = $dirs.Count - $SkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + try { Remove-SkillBackupTreeLexically -Path $dir.FullName } catch { } + $excess-- + } +} + # Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\\ # instead of destroying it (non-interactive installs cannot confirm, so # removals must stay reversible). Missing paths are a no-op success. On any @@ -400,14 +854,21 @@ function Backup-SkillDir { [ref]$BackupPath ) if ($null -ne $BackupPath) { $BackupPath.Value = "" } - if (!(Test-Path $Dir -PathType Container)) { return $true } + if (!(Test-SkillPathLexically -Path $Dir)) { return $true } $backupRoot = Join-Path $HOME ".dws\skill-backups" $stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss") - $name = Split-Path $Dir -Leaf - $target = Join-Path (Join-Path $backupRoot $stamp) $name + $name = Get-SkillBackupName -Dir $Dir + $targetRoot = Join-Path $backupRoot $stamp + $target = Join-Path $targetRoot $name $i = 1 - while (Test-Path $target) { - $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name + # Bump not only when \ is taken but also when the stamp root + # itself exists without a verified ownership marker: a same-second + # foreign directory must never be stamped DWS-owned and pruned later. A + # marker-verified root from this run's same second stays reusable. + while ((Test-SkillPathLexically -Path $target) -or + ((Test-SkillPathLexically -Path $targetRoot) -and !(Test-SkillBackupMarker -Dir $targetRoot))) { + $targetRoot = Join-Path $backupRoot "$stamp-$i" + $target = Join-Path $targetRoot $name $i++ if ($i -gt 1000) { Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir" @@ -416,16 +877,97 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $Dir -Destination $target + # Stamp ownership immediately after creating the stamp root and + # before any skill directory moves into it, so an interrupted backup + # can never leave an unmarked (never-prunable) stamp behind. + Write-SkillBackupMarker -Root $targetRoot } catch { - Write-Say "⚠️ 备份失败,保留原目录 $Dir" + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $targetRoot -Force -ErrorAction SilentlyContinue + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" + return $false + } + try { + Move-SkillPathRecoverably -Source $Dir -Destination $target + } catch { + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null + Remove-OldSkillBackups + } catch { + Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_" + } Write-Say " × 已备份并移除 $Dir → $target" return $true } +function Get-SkillLinkSignature { + param([string]$Path) + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch { + return $null + } + if (!$item.LinkType) { return $null } + $targets = @($item.Target) | ForEach-Object { [string]$_ } + return ([string]$item.LinkType + "`0" + ($targets -join "`0")) +} + +function New-PublishedSkillLinkRecord { + param([string]$Path) + $signature = Get-SkillLinkSignature -Path $Path + if ([string]::IsNullOrEmpty($signature)) { + throw "published Skill path is not a link: $Path" + } + return [pscustomobject]@{ Path = $Path; LinkSignature = $signature } +} + +function New-PublishedSkillCopyRecord { + param([string]$Path, [string]$Source) + Assert-SkillPathCopy -Source $Source -Destination $Path + return [pscustomobject]@{ Path = $Path; Source = $Source } +} + +function Remove-PublishedSkillPathSafely { + param($Record) + $path = [string]$Record.Path + $parent = Split-Path $path -Parent + $quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N")) + + # Claim the current directory entry with an exact same-filesystem rename. + # If another process replaced our link, verification below fails and that + # object is moved back instead of ever being recursively removed. + Move-SkillPath -Source $path -Destination $quarantine + try { + if ($Record.LinkSignature) { + if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) { + throw "发布链接已被其他进程替换,拒绝删除" + } + } elseif ($Record.Source) { + Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine + } else { + throw "发布路径缺少事务身份,拒绝删除" + } + Remove-SkillPathLexically -Path $quarantine + } catch { + $failure = $_ + if (Test-SkillPathLexically -Path $quarantine) { + try { + if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" } + Move-SkillPath -Source $quarantine -Destination $path + } catch { + throw "$failure;并发对象保留于 $quarantine`: $_" + } + } + throw $failure + } +} + function Restore-MultiSkillSet { param( [array]$Published, @@ -433,23 +975,29 @@ function Restore-MultiSkillSet { ) $ok = $true for ($i = $Published.Count - 1; $i -ge 0; $i--) { + $publishedItem = $Published[$i] + $publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path } try { - if (Test-Path $Published[$i]) { - Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop + if (Test-SkillPathLexically -Path $publishedPath) { + if (!($publishedItem -is [string])) { + Remove-PublishedSkillPathSafely -Record $publishedItem + } else { + Remove-SkillPathLexically -Path $publishedPath + } } } catch { - Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" + Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_" $ok = $false } } for ($i = $Backups.Count - 1; $i -ge 0; $i--) { $item = $Backups[$i] try { - if (Test-Path $item.Original) { + if (Test-SkillPathLexically -Path $item.Original) { throw "恢复目标仍存在" } New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $item.Backup -Destination $item.Original + Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original } catch { Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_" $ok = $false @@ -458,13 +1006,81 @@ function Restore-MultiSkillSet { return $ok } -function Move-GenericSkillRootToBackup { - param([string]$Root) +function Test-SkillPathLexically { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + try { + Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null + return $true + } catch { + $parent = Split-Path $Path -Parent + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) + } +} + +function Resolve-AgentSkillBase { + param([string]$Root, $Agent) + if ($null -eq $Agent.Dir) { return $null } + switch ($Agent.Id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } } + "codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } } + "grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } } + "hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } } + "mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } } + "openclaw" { + foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $Root $name + if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") } + } + } + { $_ -in @("amp", "replit", "universal") } { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + return (Join-Path $configHome "agents\skills") + } + { $_ -in @("crush", "devin", "goose", "opencode") } { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + $child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } } + return (Join-Path $configHome $child) + } + "kimchi" { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + return (Join-Path $configHome "kimchi\harness\skills") + } + } + return (Join-Path $Root $Agent.Dir) +} + +function Test-AgentSkillBaseDetected { + param([string]$BaseDir, $Agent) + $parent = Split-Path $BaseDir -Parent + switch ($Agent.Id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + +function Test-SamePhysicalSkillRoot { + param([string]$Left, [string]$Right) + if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } + $leftPhysical = Get-PhysicalSkillPath -Path $Left + $rightPhysical = Get-PhysicalSkillPath -Path $Right + if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false } + return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/')) +} + +function Move-AgentSkillRootToBackup { + param([string]$Root, [string]$BaseDir) - $baseDir = Join-Path $Root ".agents\skills" $victims = [System.Collections.Generic.List[string]]::new() $victims.Add((Join-Path $baseDir $SkillName)) - foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) { + foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) { if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } @@ -476,7 +1092,7 @@ function Move-GenericSkillRootToBackup { if (!$seen.Add($victim)) { continue } $backupPath = "" if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { - throw "通用 Skill 副本备份失败: $victim" + throw "Agent Skill 旧副本备份失败: $victim" } if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } @@ -485,11 +1101,81 @@ function Move-GenericSkillRootToBackup { return $true } catch { Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null - Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_" + Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_" return $false } } +function Publish-CanonicalSkillLinks { + param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @()) + $canonical = Join-Path $Root ".agents\skills" + if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } + if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } + $stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N")) + $backups = @() + $published = @() + try { + New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null + if ($Mode -eq "mono") { + $names = @($SkillName) + } elseif ($BundleNames.Count -gt 0) { + # The canonical store is SHARED: enumerate the link set from the + # installed bundle, never from ~\.agents\skills, or third-party and + # user skills would be republished into every Agent root. + $names = @($BundleNames) + } else { + # No bundle list means we cannot tell DWS skills apart from the + # user's own entries in the shared store. Fail this Agent (the + # caller degrades to a copy install) instead of guessing, matching + # link_canonical_skills_to_base in scripts/install-skills.sh. + throw "multi mode requires the installed bundle skill names" + } + $publishNames = @() + foreach ($name in $names) { + if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue } + $absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name)) + New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null + $publishNames += $name + } + $victims = [System.Collections.Generic.List[string]]::new() + $victims.Add((Join-Path $BaseDir $SkillName)) + foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) { + if ($existing.FullName -eq $stageRoot) { continue } + if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } + } + # Every published name replaces whatever occupies its destination, even + # when that copy predates central ownership metadata; the loop below + # still skips destinations that are already correct links. + foreach ($name in $names) { + $victims.Add((Join-Path $BaseDir $name)) + } + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($victim in $victims) { + if (!$seen.Add($victim)) { continue } + if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue } + $backupPath = "" + if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" } + if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } } + } + foreach ($name in $publishNames) { + $dest = Join-Path $BaseDir $name + Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + # Only record a publication after the staged junction has occupied + # the exact destination. Rollback also checks this identity before + # deleting, so a concurrent user directory is never removed. + $published += New-PublishedSkillLinkRecord -Path $dest + Write-Say "↪ Skills → $dest" + } + return $true + } catch { + Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null + Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" + return $false + } finally { + Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null + } +} + function Copy-SkillToDir { param([string]$SkillSrc, [string]$Dest, [string]$Label) @@ -616,26 +1302,7 @@ function Install-Binary { $archivePath = Join-Path $tmpDir $archiveName Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing - # Download and verify SHA256 checksum - if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } - try { - $checksumPath = Join-Path $tmpDir "checksums.txt" - Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing - $checksumContent = Get-Content $checksumPath - $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) } - if ($expectedLine) { - $expected = ($expectedLine -split '\s+')[0] - $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting." - } - Write-Say "✅ SHA256 checksum verified" - } else { - Write-Say "⚠️ Archive not found in checksums.txt; skipping verification" - } - } catch { - Write-Say "⚠️ Could not download checksums.txt; skipping verification" - } + Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir Write-Say "📦 Extracting..." Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force @@ -784,8 +1451,8 @@ function Install-MonoToBase { } } - $published += $dest Move-SkillPath -Source $stagedSkill -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc } catch { $transactionError = $_ if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) { @@ -811,46 +1478,37 @@ function Install-SkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } - $agentDir = $AgentDirs[$i] - $baseDir = Join-Path $Root $agentDir - $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + $canonical = Join-Path $Root ".agents\skills" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) { + $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent + if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) + if (!$seenBases.Add($baseKey)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if ($agent.Universal) { + # Cleanup-only migration: universal Agents read the canonical store + # directly, so nothing is installed here. Leaving an obsolete copy + # behind is a warning, never a reason to fail an install whose + # canonical store and links all succeeded. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - $attempted++ - if ($Root -eq $HOME) { - $label = "~\$agentDir\$SkillName" - } else { - $label = Join-Path $Root (Join-Path $agentDir $SkillName) - } - $copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label - if ($copied) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { $installed++ } else { - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - $fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName - if ($Root -eq $HOME) { - $flabel = "~\.agents\skills\$SkillName" - } else { - $flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName) - } - if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) { - $installed++ - } else { - $failed++ + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { + Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用" + $installed++ + } else { $failed++ } } } if ($installed -eq 0) { @@ -862,6 +1520,10 @@ function Install-SkillsToHomes { return $false } Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue + Write-Say "✅ DWS Skills 安装完成" + Write-Say " 统一安装位置:$canonical" + Write-Say " 已自动适配本机上检测到的 Agent" + Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" return $true } @@ -889,35 +1551,40 @@ function Install-MultiSkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } - $agentDir = $AgentDirs[$i] - $baseDir = Join-Path $Root $agentDir - $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + $canonical = Join-Path $Root ".agents\skills" + # The link set must come from the installed bundle, not from the shared + # canonical store (which may also hold user/third-party skills). + $bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) { + $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent + if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) + if (!$seenBases.Add($baseKey)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if ($agent.Universal) { + # Cleanup-only migration (see Install-SkillsToHomes): a retire + # failure must not fail an otherwise complete install. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - $attempted++ - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) { $installed++ } else { - Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)" - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") { - $installed++ - } else { - $failed++ + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) { + Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用" + $installed++ + } else { $failed++ } } } if ($installed -eq 0) { @@ -929,6 +1596,10 @@ function Install-MultiSkillsToHomes { return $false } Write-SkillsState -MultiSrc $MultiSrc + Write-Say "✅ DWS Skills 安装完成" + Write-Say " 统一安装位置:$canonical" + Write-Say " 已自动适配本机上检测到的 Agent" + Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" return $true } @@ -990,8 +1661,8 @@ function Install-MultiToBase { foreach ($skillDir in $skillDirs) { $dest = Join-Path $BaseDir $skillDir.Name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName } } catch { $transactionError = $_ @@ -1072,6 +1743,7 @@ function Install-Skills { Write-Err "Cannot download skills from GitHub and no local source checkout found." } } + Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force diff --git a/scripts/install.sh b/scripts/install.sh index 016055fe..9505ff13 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,24 +71,54 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir" return 1 fi done - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 + mkdir -p "$_bed_stamp_root" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi + say " ⚠️ 无法写入备份所有权标记,保留原目录 $_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" say " × 已备份并移除 $_bed_dir → $_bed_target" @@ -98,6 +128,88 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. +DWS_SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac + rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + _psb_drop=$((_psb_drop - 1)) + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -141,6 +253,40 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. +sha256_file() { + if need_cmd sha256sum; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd shasum; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd openssl; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}." + download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \ + err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}." + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt." + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \ + err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl." + [ "$_checksum_actual" = "$_checksum_expected" ] || \ + err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}." + say "✅ SHA256 checksum verified: ${_checksum_asset}" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -208,31 +354,155 @@ backup_and_record_skill_dir() { backup_and_remove_skill_dir "$_bars_victim" || return 1 if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then - mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)" + mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: ${_bars_victim}(备份位于 ${DWS_LAST_SKILL_BACKUP})" return 1 fi fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + say " ⚠️ 无法隔离待回滚 Skill: $_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest(并发对象保留于 $_qrr_payload)" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported -# installer path contract; spaces are preserved. +# its exact backup path. Publication manifest entries are : +# pairs recorded at publish time. Dest is claimed into quarantine first; +# identity is re-checked there so a concurrent replacement is renamed back +# instead of being deleted by path. Paths containing newlines are outside +# the supported installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; } + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then - say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)" + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then + say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})" _rms_ok=0 fi done < "$_rms_backups" @@ -240,6 +510,137 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree: for each path (LC_ALL=C +# sorted, relative) emit path, type+permission bits from ls -ld, and either +# the sha256 of the file content or the symlink target. Sorted-path lines are +# piped through sha256_stdin, so one opaque token covers renames, mode flips, +# content edits and link retargets — the same in-place-edit guarantee the Go +# fingerprint provides. Failure detection is asymmetric and best-effort: with +# no hash tool installed sha256_stdin itself returns 1 and the identity +# genuinely fails, but an unreadable file only exits the left-hand subshell — +# /bin/sh has no pipefail, so the pipeline returns sha256_stdin's status and +# the digest is computed over a truncated stream instead of failing. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then + rm -f "$_cnsl_nested" + fi +} + +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Copies a complete cache into a sibling staging directory, then publishes it # with rename. Copy/publish failures retain the previous cache; if restoration @@ -627,12 +1028,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_mono_stage" @@ -643,17 +1039,100 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering the same Skill -# through both ~/.agents/skills and ~/.codex/skills. -retire_generic_skill_root() { +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots from vercel-labs/skills, in pinned registry order and +# de-duplicated (amp/replit/universal and zencoder/zenflow share a root). +# Records without a global root (`-`) and canonical-direct records +# (`.agents/skills`) are not per-Agent targets and are skipped. +agent_skill_dirs() { + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -666,6 +1145,136 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. +link_canonical_skills_to_base() { + _lcs_root="$1" + _lcs_base="$2" + _lcs_mode="$3" + _lcs_bundle="${4-}" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;" + say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。" + return 1 + fi + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups" + _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then + continue + fi + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + # 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 + # 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 + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_dest" + done + rm -rf "$_lcs_stage" +} + # Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes). # Installing mono removes proven DWS-managed multi leftovers for mutual exclusion, # mirroring `dws skill setup --mode mono`. @@ -673,90 +1282,48 @@ install_skills_to_homes() { skill_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - case "$root" in - "$HOME") - label="~/$agent_dir/$SKILL_NAME" - ;; - *) - label="$root/$agent_dir/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then + say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - case "$root" in - "$HOME") - flabel="~/.agents/skills/$SKILL_NAME" - ;; - *) - flabel="$root/.agents/skills/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败" return 1 fi rm -f "$SKILL_STATE_ROOT/skills-state.json" + say "✅ DWS Skills 安装完成" + say " 统一安装位置:$root/.agents/skills" + say " 已自动适配本机上检测到的 Agent" + say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" } # multi_tree_has_skills returns 0 only when the given multi bundle directory @@ -783,71 +1350,48 @@ install_multi_skills_to_homes() { multi_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else - failed=$((failed + 1)) - say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装失败" return 1 fi write_skills_state "$multi_src" "install.sh" || return 1 + say "✅ DWS Skills 安装完成" + say " 统一安装位置:$root/.agents/skills" + say " 已自动适配本机上检测到的 Agent" + say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" } _install_multi_to_base() { @@ -922,12 +1466,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_ms_stage" @@ -1018,32 +1557,7 @@ install_binary() { download "$download_url" "$tmpdir/$archive_name" - # Download and verify SHA256 checksum - checksum_url="$(asset_url checksums.txt)" - if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then - expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")" - if [ -n "$expected" ]; then - if need_cmd sha256sum; then - actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')" - elif need_cmd shasum; then - actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')" - else - actual="" - fi - if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then - err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting." - fi - if [ -n "$actual" ]; then - say "✅ SHA256 checksum verified" - else - say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification" - fi - else - say "⚠️ Archive not found in checksums.txt; skipping verification" - fi - else - say "⚠️ Could not download checksums.txt; skipping verification" - fi + verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir" say "📦 Extracting..." tar xzf "$tmpdir/$archive_name" -C "$tmpdir" @@ -1108,6 +1622,8 @@ install_skills() { fi fi + verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills" + extract_root="$tmpdir_skills/skills" mkdir -p "$extract_root" if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then @@ -1206,6 +1722,10 @@ main() { install_skills fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' say "🎉 Installation complete!" say "" diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 16453ad4..45441cfd 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -13,8 +13,13 @@ STABLE_REF="" CANDIDATE_REF="HEAD" SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 @@ -144,6 +149,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -173,6 +189,94 @@ has_any_schema_migration_governance_artifact() { return 1 } +has_complete_schema_command_migration_governance() { + governance_commit="$1" + has_complete_schema_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_schema_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -225,10 +329,12 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then exit 2 fi check_candidate_alias_source_policy +check_candidate_const_params_source_policy EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false if has_complete_schema_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -252,6 +358,27 @@ else fi fi +if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_schema_command_governance + require_base_identical_command_migration_bridges +elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap keeps the old base-owned checker in control and therefore grants + # no command migration authorization in the governance PR itself. + require_complete_candidate_schema_command_governance +fi + +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE" +fi + BASE_BIN="$TMP_ROOT/base-dws" STABLE_BIN="$TMP_ROOT/stable-dws" CANDIDATE_BIN="$TMP_ROOT/candidate-dws" @@ -337,16 +464,28 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT" generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT" generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT" -check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" +check_with_migrations() { + historical_kind="$1" + historical_ref="$2" + historical_baseline="$3" + if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" + else + check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" + fi +} -check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \ - --approved-flag-migrations "$APPROVED_MANIFEST" \ - --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ - --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ - --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ - --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" +check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE" +check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index d26a7abd..2292bd77 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -12,7 +12,12 @@ BASE_REF="" STABLE_REF="" CANDIDATE_REF="HEAD" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2 @@ -130,6 +135,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -166,6 +182,84 @@ require_complete_candidate_governance() { fi } +has_complete_command_migration_governance() { + governance_commit="$1" + has_complete_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -222,9 +316,36 @@ install_authority_helper() { fi } +install_authority_const_params_registry() { + source_root="$1" + worktree="$2" + + [ "$source_root" = "$worktree" ] && return + mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")" + rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL" + cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL" +} + EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false + +if has_complete_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_command_governance + require_base_identical_command_migration_bridges +elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap is safe because the base-owned comparator receives no new + # authorization input and still performs the ordinary compatibility check. + require_complete_candidate_command_governance +fi if has_complete_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -233,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true else @@ -252,6 +374,7 @@ else AUTHORITY_ROOT="$BASE_WORKTREE" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2 @@ -262,6 +385,11 @@ fi install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE" +fi generate_snapshot() { worktree="$1" @@ -280,7 +408,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE" generate_snapshot "$BASE_WORKTREE" "$BASELINE" generate_snapshot "$STABLE_WORKTREE" "$STABLE" -if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" + ) +elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then ( cd "$AUTHORITY_ROOT" go run ./cmd/interface-snapshot compare \ diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go index 9f1ddd8b..494a50e2 100644 --- a/scripts/policy/commandcompat/authority_integration_test.go +++ b/scripts/policy/commandcompat/authority_integration_test.go @@ -51,6 +51,8 @@ func main() { base := flags.String("base", "", "base snapshot") stable := flags.String("stable", "", "stable snapshot") _ = flags.String("approved-flag-migrations", "", "approved manifest") + _ = flags.String("approved-command-migrations", "", "approved command manifest") + _ = flags.String("candidate-command-migrations", "", "candidate command manifest") _ = flags.Parse(os.Args[2:]) for _, path := range []string{*current, *base, *stable} { content, err := os.ReadFile(path) @@ -97,6 +99,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const commandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-command-compat-authority-test/internal/corecmd" + "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const baseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -121,6 +144,54 @@ const ( ) ` +func constParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const baseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const forgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const baseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const forgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -131,11 +202,13 @@ func main() { ` type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - want string - wantAuthorityDecision bool + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + want string + wantAuthorityDecision bool } func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test baseGovernance: "none", wantAuthorityDecision: true, }, + { + name: "command governed base owns bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + wantAuthorityDecision: true, + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "bootstrap", + wantAuthorityDecision: true, + }, + { + name: "command governed candidate cannot delete bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + wantAuthorityDecision: true, + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + wantAuthorityDecision: true, + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } run(t, fixtureRoot, "git", "add", ".") run(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { 0o644, ) } + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } switch test.candidateMutation { case "": case "delete-alias-contract": @@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json new file mode 100644 index 00000000..52a6bff1 --- /dev/null +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "migrations": [ + { + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group_conversation", + "replacement_tool_id": "chat.create_topic_conversation", + "parameters": [ + {"from": "name", "to": "name"}, + {"from": "type", "to": "type"}, + {"from": "users", "to": "users"}, + { + "from": "thread", + "replacement_constant": { + "property": "convThreadEnabled", + "value": true + } + } + ] + }, + "state": "pending", + "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message list-topic-replies", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic list-replies", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.list_topic_replies", + "replacement_tool_id": "chat.list_topic_replies", + "parameters": [ + {"from": "conversation-id", "to": "open-topic-id"}, + {"from": "topic-id", "to": "open-conv-thread-id"} + ] + }, + "state": "pending", + "reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message forward-topic", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic forward", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.forward_topic", + "replacement_tool_id": "chat.forward_topic", + "parameters": [ + {"from": "src-conversation-id", "to": "src-open-topic-id"}, + {"from": "src-thread-id", "to": "src-open-conv-thread-id"}, + {"from": "dest-conversation-id", "to": "dest-open-conversation-id"} + ] + }, + "state": "pending", + "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command." + } + ] +} diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index 6ddbafb7..c1c596fa 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -760,6 +760,39 @@ "state": "consumed", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, + { + "command": "dws chat group-role set-user", + "legacy": { + "name": "role-ids", + "before": { + "present": true, + "type": "string", + "scope": "local", + "required": true + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "hidden": true, + "alias_of": "role-id" + } + }, + "canonical": { + "name": "role-id", + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "required": true + } + }, + "state": "consumed", + "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" + }, { "command": "dws chat group-role update", "legacy": { diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go new file mode 100644 index 00000000..ada5dcc7 --- /dev/null +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -0,0 +1,867 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("normalize exact command migrations: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact command migrations remained incompatible: %v", failures) + } + + unrelated := cloneContract(current) + product := unrelated.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "keep") + product.Tools["chat.move"] = tool + unrelated.Products["chat"] = product + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) { + t.Fatalf("unrelated parameter removal was hidden: %s", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + parameterDrift := schemaCommandMigrationContract(true) + product := parameterDrift.Products["chat"] + tool := product.Tools["chat.move"] + parameter := tool.Parameters["new-id"] + parameter.Property = "differentProperty" + tool.Parameters["new-id"] = parameter + product.Tools["chat.move"] = tool + parameterDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") { + t.Fatalf("parameter drift error=%v", err) + } + + safetyDrift := schemaCommandMigrationContract(true) + product = safetyDrift.Products["chat"] + replacement := product.Tools["chat.create_topic"] + replacement.Risk = "high" + product.Tools["chat.create_topic"] = replacement + safetyDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") { + t.Fatalf("extraction safety drift error=%v", err) + } + + missingReplacement := schemaCommandMigrationContract(true) + product = missingReplacement.Products["chat"] + delete(product.Tools, "chat.create_topic") + missingReplacement.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") { + t.Fatalf("missing replacement error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + for _, test := range []struct { + name string + parameter parameterSchema + }{ + {name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}}, + {name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}}, + {name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}}, + } { + t.Run(test.name, func(t *testing.T) { + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["must"] = test.parameter + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required parameter error=%v", err) + } + }) + } + + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"} + product.Tools["chat.move"] = tool + current.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("optional addition should remain compatible: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("optional addition should remain compatible: %v", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}} + + product := current.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err == nil { + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures) + } + t.Fatal("parameter target collision was accepted") + } + if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) { + t.Fatalf("parameter target collision error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + + missingHistorical := cloneContract(baseline) + product := missingHistorical.Products["chat"] + delete(product.Tools, "chat.move") + missingHistorical.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil { + t.Fatalf("missing historical tool should be a no-op: %v", err) + } + + missingCurrent := cloneContract(current) + product = missingCurrent.Products["chat"] + delete(product.Tools, "chat.move") + missingCurrent.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil { + t.Fatalf("missing current source should remain for ordinary checker: %v", err) + } + + alreadyAfter := schemaCommandMigrationContract(true) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil { + t.Fatalf("already-after baseline should be a no-op: %v", err) + } + + wrongHistoricalPath := cloneContract(baseline) + product = wrongHistoricalPath.Products["chat"] + tool := product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongHistoricalPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") { + t.Fatalf("wrong historical path error=%v", err) + } + + wrongCurrentPath := cloneContract(current) + product = wrongCurrentPath.Products["chat"] + tool = product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongCurrentPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil { + t.Fatalf("wrong current path should remain for ordinary checker: %v", err) + } + + missingHistoricalParameter := cloneContract(baseline) + product = missingHistoricalParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "old-id") + product.Tools["chat.move"] = tool + missingHistoricalParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") { + t.Fatalf("missing historical parameter error=%v", err) + } + + legacyPublished := cloneContract(current) + product = legacyPublished.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") { + t.Fatalf("legacy parameter error=%v", err) + } + + missingReplacementParameter := cloneContract(current) + product = missingReplacementParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + product.Tools["chat.move"] = tool + missingReplacementParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") { + t.Fatalf("missing replacement parameter error=%v", err) + } + + constraintDrift := cloneContract(current) + product = constraintDrift.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = `{"require_one_of":[["new-id"]]}` + product.Tools["chat.move"] = tool + constraintDrift.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") { + t.Fatalf("constraint drift was hidden: %s", failures) + } + + extractionWrongSource := cloneContract(current) + product = extractionWrongSource.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.create_group"] = tool + extractionWrongSource.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil { + t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err) + } + + extractionHistoricalMissing := cloneContract(baseline) + product = extractionHistoricalMissing.Products["chat"] + tool = product.Tools["chat.create_group"] + delete(tool.Parameters, "thread") + product.Tools["chat.create_group"] = tool + extractionHistoricalMissing.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") { + t.Fatalf("missing extracted historical parameter error=%v", err) + } + + extractionStillPublished := cloneContract(current) + product = extractionStillPublished.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"] + product.Tools["chat.create_group"] = tool + extractionStillPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") { + t.Fatalf("still-published extraction error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) { + t.Run("missing historical mapping", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1) + for _, parameter := range migrations[1].Schema.Parameters { + if parameter.From != "users" { + parameters = append(parameters, parameter) + } + } + migrations[1].Schema.Parameters = parameters + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) { + t.Fatalf("missing historical mapping error=%v", err) + } + }) + + for _, test := range []struct { + name string + mutate func([]interfacesnapshot.CommandMigration) + want string + }{ + { + name: "duplicate historical mapping", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters = append( + migrations[1].Schema.Parameters, + migrations[1].Schema.Parameters[0], + ) + }, + want: "maps historical Schema parameter \"name\" more than once", + }, + { + name: "legacy mapping has two targets", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].To = "thread" + }, + want: "must map to one replacement constant", + }, + { + name: "shared mapping has no target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[0].To = "" + }, + want: "must map to one replacement parameter", + }, + { + name: "duplicate replacement target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[1].To = "name" + }, + want: "is mapped from both \"name\" and \"type\"", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("invalid mapping error=%v, want %q", err, test.want) + } + }) + } + + t.Run("replacement removes historical dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), "changed or removed dry_run") { + t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err) + } + }) + + t.Run("replacement adds dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil { + t.Fatalf("additive replacement dry_run was rejected: %v", err) + } + }) + + for _, test := range []struct { + name string + mutate func(*schemaContract) + want string + }{ + { + name: "missing shared parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + delete(tool.Parameters, "users") + }) + }, + want: `does not publish mapped Schema parameter "users"`, + }, + { + name: "shared parameter field drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) { + parameter.Property = "differentUserIds" + }) + }, + want: "changed a non-name field", + }, + { + name: "extra replacement parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"} + }) + }, + want: `publishes unmapped Schema parameter "extra"`, + }, + { + name: "constraints drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["name","type"]]}` + }) + }, + want: "changed constraints", + }, + { + name: "positionals drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals[0].Name = "topic-name" + }) + }, + want: "changed positionals", + }, + { + name: "positionals length drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"}) + }) + }, + want: "changed positionals", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + test.mutate(¤t) + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement projection error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration) + want string + }{ + { + name: "constant property mismatch", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty" + }, + want: "constant property", + }, + { + name: "constant false", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false + }, + want: "constant true", + }, + { + name: "legacy type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Type = `"string"` + }) + }, + want: "must have boolean type", + }, + { + name: "legacy required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Required = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy cli required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.CLIRequired = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy required when", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.RequiredWhen = "mode=topic" + }) + }, + want: "must not declare required_when", + }, + { + name: "legacy default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Default = "true" + }) + }, + want: "default must be absent or false", + }, + { + name: "legacy interface default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceDefault = "true" + }) + }, + want: "interface_default must be absent or false", + }, + { + name: "legacy interface type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceType = "string" + }) + }, + want: "interface_type must be empty or boolean", + }, + { + name: "legacy enum excludes true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Enum = []string{"false"} + }) + }, + want: "enum must allow true", + }, + { + name: "legacy format", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Format = "custom-bool" + }) + }, + want: "format must be empty", + }, + { + name: "replacement publishes constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "still publishes replacement constant property", + }, + { + name: "source renames constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "source Schema tool still publishes replacement constant property", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(&baseline, ¤t, migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement constant error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) { + directory := t.TempDir() + baselinePath := filepath.Join(directory, "baseline.json") + currentPath := filepath.Join(directory, "current.json") + approvedPath := filepath.Join(directory, "approved.json") + candidatePath := filepath.Join(directory, "candidate.json") + currentSnapshotPath := filepath.Join(directory, "current-snapshot.json") + baseSnapshotPath := filepath.Join(directory, "base-snapshot.json") + stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json") + + writeSchemaContractFile(t, baselinePath, schemaCommandMigrationContract(false)) + writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true)) + writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending)) + writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false)) + writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false)) + + args := []string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + "--migration-current-snapshot", currentSnapshotPath, + "--migration-base-snapshot", baseSnapshotPath, + "--migration-stable-snapshot", stableSnapshotPath, + } + var stdout, stderr strings.Builder + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("command migration run code=%d stderr=%s", code, stderr.String()) + } + + paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath} + for index := range paths { + invalid := append([]string(nil), paths...) + invalid[index] = filepath.Join(directory, "missing.json") + if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil { + t.Fatalf("missing command migration input %d was accepted", index) + } + } + + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") { + t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") { + t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") { + t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String()) + } + + badArgs := append([]string(nil), args...) + badArgs[5] = filepath.Join(directory, "missing-approved.json") + stderr.Reset() + if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") { + t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String()) + } + + legacyCurrent := schemaCommandMigrationContract(true) + product := legacyCurrent.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyCurrent.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, legacyCurrent) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") { + t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String()) + } + + requiredAddition := schemaCommandMigrationContract(true) + product = requiredAddition.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true} + product.Tools["chat.move"] = tool + requiredAddition.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, requiredAddition) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String()) + } +} + +func schemaCommandMigrationContract(after bool) schemaContract { + id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} + keep := parameterSchema{Type: `"string"`, Property: "keep"} + name := parameterSchema{Type: `"string"`, Property: "name"} + groupType := parameterSchema{Type: `"string"`, Property: "type"} + users := parameterSchema{Type: `"array"`, Property: "userIds"} + thread := parameterSchema{ + Type: `"boolean"`, + Property: "threadEnabled", + InterfaceType: "boolean", + Default: "false", + InterfaceDefault: "false", + Enum: []string{"false", "true"}, + } + group := toolSchema{ + PrimaryCLIPath: "chat group create", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`, + Availability: "available", + Parameters: map[string]parameterSchema{ + "name": name, + "thread": thread, + "type": groupType, + "users": users, + }, + Constraints: `{"require_together":[["name","type"]]}`, + Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}}, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", + } + move := toolSchema{ + PrimaryCLIPath: "chat message old", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`, + Availability: "available", + Parameters: map[string]parameterSchema{"old-id": id, "keep": keep}, + Constraints: `{"require_together":[["keep","old-id"]]}`, + Effect: "read", + Risk: "low", + Confirmation: "not_required", + Idempotency: "idempotent", + } + tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move} + if after { + delete(group.Parameters, "thread") + tools["chat.create_group"] = group + replacement := group + replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters)) + for name, parameter := range group.Parameters { + replacement.Parameters[name] = parameter + } + replacement.Positionals = append([]positionalSchema(nil), group.Positionals...) + replacement.PrimaryCLIPath = "chat topic create" + tools["chat.create_topic"] = replacement + delete(move.Parameters, "old-id") + move.Parameters["new-id"] = id + move.PrimaryCLIPath = "chat topic new" + move.Constraints = `{"require_together":[["keep","new-id"]]}` + tools["chat.move"] = move + } + return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}} +} + +func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration { + return []interfacesnapshot.CommandMigration{ + { + Kind: interfacesnapshot.CommandMigrationMove, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat message old", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic new", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.move", + ReplacementToolID: "chat.move", + Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + }, + { + Kind: interfacesnapshot.CommandMigrationFlagExtraction, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat group create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic create", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: interfacesnapshot.CommandMigrationFlag{ + Name: "thread", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []interfacesnapshot.CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{ + Property: "threadEnabled", + Value: true, + }, + }, + }, + }, + }, + } +} + +func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest { + migrations := schemaCommandMigrationAuthorizations() + for index := range migrations { + migrations[index].State = state + migrations[index].Reason = "Reviewed Schema command migration." + } + return interfacesnapshot.CommandMigrationManifest{ + Version: interfacesnapshot.CommandMigrationManifestVersion, + Migrations: migrations, + } +} + +func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { + commands := []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + { + Path: "dws chat group create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{{ + Name: "thread", Type: "bool", NoOpt: "true", + }}, + InheritedFlags: []interfacesnapshot.Flag{}, + }, + {Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + commands = append(commands, + interfacesnapshot.Command{ + Path: "dws chat topic create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + BoolConstParams: map[string]bool{"threadEnabled": true}, + }, + interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + ) + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"}, + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + +func mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) { + product := contract.Products["chat"] + tool := product.Tools[toolID] + mutate(&tool) + product.Tools[toolID] = tool + contract.Products["chat"] = product +} + +func mutateSchemaCommandMigrationParameter( + contract *schemaContract, + toolID string, + parameterName string, + mutate func(*parameterSchema), +) { + mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) { + parameter := tool.Parameters[parameterName] + mutate(¶meter) + tool.Parameters[parameterName] = parameter + }) +} + +func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(manifest); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 1f55eae4..7496d4f3 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -86,6 +86,7 @@ func main() { func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string var approvedFlagMigrationsPath, candidateFlagMigrationsPath string + var approvedCommandMigrationsPath, candidateCommandMigrationsPath string var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) flags.SetOutput(stderr) @@ -95,6 +96,8 @@ func run(args []string, stdout, stderr io.Writer) int { flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest") flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest") + flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest") + flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest") flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization") flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization") flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization") @@ -112,25 +115,38 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") return 2 } - migrationInputs := []string{ - approvedFlagMigrationsPath, - candidateFlagMigrationsPath, + flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != "" + commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != "" + if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests") + return 2 + } + if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests") + return 2 + } + migrationSnapshots := []string{ migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath, } - migrationInputCount := 0 - for _, path := range migrationInputs { + migrationSnapshotCount := 0 + for _, path := range migrationSnapshots { if path != "" { - migrationInputCount++ + migrationSnapshotCount++ } } - if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) { - fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs") + migrationsEnabled := flagMigrationPair || commandMigrationPair + if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) { + fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots") return 2 } - if migrationInputCount != 0 && checkPath == "" { - fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check") + if !migrationsEnabled && migrationSnapshotCount != 0 { + fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair") + return 2 + } + if migrationsEnabled && checkPath == "" { + fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check") return 2 } @@ -159,7 +175,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } - if migrationInputCount != 0 { + if flagMigrationPair { migrations, err := authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, @@ -177,6 +193,24 @@ func run(args []string, stdout, stderr io.Writer) int { return 2 } } + if commandMigrationPair { + migrations, err := authorizeSchemaCommandMigrations( + approvedCommandMigrationsPath, + candidateCommandMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + ) + if err != nil { + fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) + return 2 + } + baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err) + return 2 + } + } failures := checkCompatibility(baseline, current) if len(failures) > 0 { fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:") @@ -251,6 +285,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data)) } +func authorizeSchemaCommandMigrations( + approvedManifestPath string, + candidateManifestPath string, + currentSnapshotPath string, + baseSnapshotPath string, + stableSnapshotPath string, +) ([]interfacesnapshot.CommandMigration, error) { + approved, err := readCommandMigrationManifestFile(approvedManifestPath) + if err != nil { + return nil, fmt.Errorf("read approved command migrations: %w", err) + } + candidate, err := readCommandMigrationManifestFile(candidateManifestPath) + if err != nil { + return nil, fmt.Errorf("read candidate command migrations: %w", err) + } + current, err := readInterfaceSnapshotFile(currentSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration current snapshot: %w", err) + } + base, err := readInterfaceSnapshotFile(baseSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration base snapshot: %w", err) + } + stable, err := readInterfaceSnapshotFile(stableSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration stable snapshot: %w", err) + } + return interfacesnapshot.AuthorizeCommandMigrations( + current, + map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable}, + approved, + candidate, + ) +} + +func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data)) +} + func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) { data, err := os.ReadFile(path) if err != nil { @@ -1290,6 +1367,472 @@ func normalizeSchemaFlagMigrations( return normalized, nil } +// normalizeSchemaCommandMigrations projects only the Schema consequences that +// are coupled to an already-authorized CLI command migration. It rewrites a +// cloned historical contract; the ordinary checker still rejects every field +// not proven equivalent here. +func normalizeSchemaCommandMigrations( + baseline schemaContract, + current schemaContract, + migrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + normalized := cloneContract(baseline) + for _, migration := range migrations { + oldProduct, productExists := baseline.Products[migration.Schema.ProductID] + oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !toolExists { + // The historical baseline predates this Schema tool, so it has no + // compatibility surface for this migration. + continue + } + newProduct, productExists := current.Products[migration.Schema.ProductID] + newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !sourceExists { + // Preserve the baseline so the ordinary checker reports the removal. + continue + } + legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ") + replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ") + if oldTool.PrimaryCLIPath == replacementPath { + // A consumed receipt can still be needed for the stable baseline after + // main has already reached the after state. + continue + } + if oldTool.PrimaryCLIPath != legacyPath { + return schemaContract{}, fmt.Errorf( + "approved command migration %s historical Schema tool %q has primary_cli_path %q", + migration.Kind, + migration.Schema.SourceToolID, + oldTool.PrimaryCLIPath, + ) + } + + normalizedProduct := normalized.Products[migration.Schema.ProductID] + normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] + switch migration.Kind { + case interfacesnapshot.CommandMigrationMove: + if newSource.PrimaryCLIPath != replacementPath { + continue + } + renames := make(map[string]string, len(migration.Schema.Parameters)) + renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters)) + for _, parameter := range migration.Schema.Parameters { + oldParameter, existed := oldTool.Parameters[parameter.From] + if !existed { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := oldTool.Parameters[parameter.To]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q Schema parameter target %q already exists in historical Schema tool %q", + migration.Legacy.Command, + parameter.To, + migration.Schema.SourceToolID, + ) + } + if _, exists := newSource.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q still publishes legacy Schema parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + newParameter, exists := newSource.Parameters[parameter.To] + if !exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q does not publish replacement Schema parameter %q", + migration.Replacement.Command, + parameter.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil { + return schemaContract{}, err + } + delete(normalizedTool.Parameters, parameter.From) + normalizedTool.Parameters[parameter.To] = newParameter + renames[parameter.From] = parameter.To + renameTargets[parameter.To] = struct{}{} + } + parameterNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + parameterNames = append(parameterNames, name) + } + sort.Strings(parameterNames) + for _, name := range parameterNames { + parameter := newSource.Parameters[name] + if _, existed := oldTool.Parameters[name]; existed { + continue + } + if _, approvedRename := renameTargets[name]; approvedRename { + continue + } + if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" { + return schemaContract{}, fmt.Errorf( + "approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q", + migration.Legacy.Command, + migration.Schema.SourceToolID, + name, + ) + } + } + if oldTool.Constraints != newSource.Constraints { + oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil) + if oldOK && newOK && oldConstraints == newConstraints { + normalizedTool.Constraints = newSource.Constraints + } + } + normalizedTool.PrimaryCLIPath = replacementPath + + case interfacesnapshot.CommandMigrationFlagExtraction: + if newSource.PrimaryCLIPath != legacyPath { + continue + } + replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID] + if !exists || replacement.PrimaryCLIPath != replacementPath { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q lacks replacement Schema tool %q at %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + replacementPath, + ) + } + if oldTool.InterfaceMode != replacement.InterfaceMode || + oldTool.InterfaceRef != replacement.InterfaceRef || + oldTool.Availability != replacement.Availability || + oldTool.Effect != replacement.Effect || + oldTool.Risk != replacement.Risk || + oldTool.Confirmation != replacement.Confirmation || + oldTool.Idempotency != replacement.Idempotency { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed interface or safety identity", + migration.Legacy.Command, + ) + } + if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed or removed dry_run", + migration.Legacy.Command, + ) + } + var err error + normalizedTool, err = normalizeFlagExtractionSchemaMigration( + migration, + oldTool, + newSource, + replacement, + normalizedTool, + ) + if err != nil { + return schemaContract{}, err + } + } + normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool + normalized.Products[migration.Schema.ProductID] = normalizedProduct + } + return normalized, nil +} + +func normalizeFlagExtractionSchemaMigration( + migration interfacesnapshot.CommandMigration, + oldTool toolSchema, + newSource toolSchema, + replacement toolSchema, + normalizedSource toolSchema, +) (toolSchema, error) { + mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters)) + for _, mapping := range migration.Schema.Parameters { + if _, duplicate := mappingsBySource[mapping.From]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q maps historical Schema parameter %q more than once", + migration.Legacy.Command, + mapping.From, + ) + } + if _, exists := oldTool.Parameters[mapping.From]; !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + mapping.From, + ) + } + mappingsBySource[mapping.From] = mapping + } + + historicalNames := make([]string, 0, len(oldTool.Parameters)) + for name := range oldTool.Parameters { + historicalNames = append(historicalNames, name) + } + sort.Strings(historicalNames) + for _, name := range historicalNames { + if _, mapped := mappingsBySource[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q does not map historical Schema parameter %q", + migration.Legacy.Command, + name, + ) + } + } + + legacyName := migration.LegacyFlag.Name + legacyMapping, mapped := mappingsBySource[legacyName] + if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must map to one replacement constant", + migration.Legacy.Command, + legacyName, + ) + } + legacyParameter := oldTool.Parameters[legacyName] + if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil { + return toolSchema{}, err + } + constantProperty := legacyMapping.ReplacementConstant.Property + if _, exists := newSource.Parameters[legacyName]; exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q still publishes extracted Schema parameter %q", + migration.Legacy.Command, + legacyName, + ) + } + sourceNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + sourceNames = append(sourceNames, name) + } + sort.Strings(sourceNames) + for _, name := range sourceNames { + if newSource.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q", + migration.Legacy.Command, + constantProperty, + name, + migration.Schema.SourceToolID, + ) + } + } + + replacementNames := make([]string, 0, len(replacement.Parameters)) + for name := range replacement.Parameters { + replacementNames = append(replacementNames, name) + } + sort.Strings(replacementNames) + for _, name := range replacementNames { + if replacement.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + constantProperty, + name, + ) + } + } + + replacementTargets := make(map[string]string, len(historicalNames)-1) + renames := make(map[string]string, len(historicalNames)-1) + for _, sourceName := range historicalNames { + if sourceName == legacyName { + continue + } + mapping := mappingsBySource[sourceName] + if mapping.To == "" || mapping.ReplacementConstant != nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q shared Schema parameter %q must map to one replacement parameter", + migration.Legacy.Command, + sourceName, + ) + } + if previous, duplicate := replacementTargets[mapping.To]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q", + migration.Legacy.Command, + mapping.To, + previous, + sourceName, + ) + } + replacementParameter, exists := replacement.Parameters[mapping.To] + if !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + mapping.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil { + return toolSchema{}, err + } + replacementTargets[mapping.To] = sourceName + renames[sourceName] = mapping.To + } + for _, name := range replacementNames { + if _, mapped := replacementTargets[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + name, + ) + } + } + + oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil) + if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed constraints", + migration.Legacy.Command, + ) + } + if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed positionals", + migration.Legacy.Command, + ) + } + + delete(normalizedSource.Parameters, legacyName) + return normalizedSource, nil +} + +func validateFlagExtractionBooleanConstant( + migration interfacesnapshot.CommandMigration, + parameter parameterSchema, + constant *interfacesnapshot.CommandReplacementConstant, +) error { + if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property { + return fmt.Errorf( + "approved flag extraction %q replacement constant property %q does not match historical Schema property %q", + migration.Legacy.Command, + constant.Property, + parameter.Property, + ) + } + if !constant.Value { + return fmt.Errorf( + "approved flag extraction %q replacement constant for Schema property %q must be constant true", + migration.Legacy.Command, + constant.Property, + ) + } + if parameter.Type != `"boolean"` { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must have boolean type", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Required || parameter.CLIRequired { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must remain optional", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.RequiredWhen != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must not declare required_when", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Default != "" && parameter.Default != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Format != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q format must be empty", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if len(parameter.Enum) > 0 { + allowsTrue := false + for _, value := range parameter.Enum { + if value == "true" { + allowsTrue = true + break + } + } + if !allowsTrue { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q enum must allow true", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + } + return nil +} + +func equivalentMigratedPositionals( + historical []positionalSchema, + replacement []positionalSchema, + renames map[string]string, +) bool { + if len(historical) != len(replacement) { + return false + } + for index, positional := range historical { + if renamed := renames[positional.Name]; renamed != "" { + positional.Name = renamed + } + if positional != replacement[index] { + return false + } + } + return true +} + +func validateEquivalentCommandSchemaParameter( + migration interfacesnapshot.CommandMigration, + parameter interfacesnapshot.CommandParameterMigration, + oldParameter parameterSchema, + newParameter parameterSchema, +) error { + if oldParameter.Type != newParameter.Type || + oldParameter.Property != newParameter.Property || + oldParameter.InterfaceType != newParameter.InterfaceType || + oldParameter.Required != newParameter.Required || + oldParameter.CLIRequired != newParameter.CLIRequired || + oldParameter.RequiredWhen != newParameter.RequiredWhen || + oldParameter.Default != newParameter.Default || + oldParameter.InterfaceDefault != newParameter.InterfaceDefault || + oldParameter.Format != newParameter.Format || + !equalStringSlices(oldParameter.Enum, newParameter.Enum) { + return fmt.Errorf( + "approved command migration %q Schema parameter %q -> %q changed a non-name field", + migration.Legacy.Command, + parameter.From, + parameter.To, + ) + } + return nil +} + func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef { var matches []schemaToolRef for productID, product := range contract.Products { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index f33f09c5..c5eaa912 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1589,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t "--migration-stable-snapshot", "stable.json", } var stdout, stderr bytes.Buffer - if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") { + if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") { t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String()) } diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index 5866ee50..be8857f4 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -32,6 +32,8 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") + _ = flag.String("approved-command-migrations", "", "base command ledger") + _ = flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -171,6 +173,8 @@ func main() { currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") + _ = flag.String("approved-command-migrations", "", "base command ledger") + _ = flag.String("candidate-command-migrations", "", "candidate command ledger") flag.Parse() if *normalize != "" { @@ -282,6 +286,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const schemaCommandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-schema-compat-authority-test/internal/corecmd" + "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const schemaBaseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -306,6 +331,54 @@ const ( ) ` +func schemaConstParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const schemaBaseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaForgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaBaseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const schemaForgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -327,12 +400,14 @@ func main() { } type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - checkerMode string - want string - authorityMarker string + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + checkerMode string + want string + authorityMarker string } func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -353,6 +428,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi baseGovernance: "none", authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED", }, + { + name: "command governed base owns bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "bootstrap", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governed candidate cannot delete bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete Schema command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -479,6 +633,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } schemaRun(t, fixtureRoot, "git", "add", ".") schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -508,6 +675,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n" } schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644) + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644) switch test.candidateMutation { case "": @@ -534,6 +712,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 71b0d8c0..da1512b7 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -54,42 +54,92 @@ while [ "$#" -gt 0 ]; do done TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" -HOME_SPECIFIC_AGENT_PARENTS=" -.claude -.cursor -.qoder -.qoderwork -.gemini -.codex -.zcode -.github -.windsurf -.augment -.cline -.amp -.kiro -.trae -.openclaw -.hermes -" -HOME_SPECIFIC_SKILL_BASES=" -.claude/skills -.cursor/skills -.qoder/skills -.qoderwork/skills -.gemini/skills -.codex/skills -.zcode/skills -.github/skills -.windsurf/skills -.augment/skills -.cline/skills -.amp/skills -.kiro/skills -.trae/skills -.openclaw/skills -.hermes/skills -" +# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs. +# Format: id|universal(1/0)|effective default global root; '-' has no global root. +UPSTREAM_AGENT_REGISTRY='aider-desk|0|.aider-desk/skills +amp|1|.config/agents/skills +antigravity|1|.gemini/antigravity/skills +antigravity-cli|1|.gemini/antigravity-cli/skills +astrbot|0|.astrbot/data/skills +autohand-code|0|.autohand/skills +augment|0|.augment/skills +bob|0|.bob/skills +claude-code|0|.claude/skills +openclaw|0|.openclaw/skills +cline|1|.agents/skills +codearts-agent|0|.codeartsdoer/skills +codebuddy|0|.codebuddy/skills +codemaker|0|.codemaker/skills +codestudio|0|.codestudio/skills +codex|1|.codex/skills +command-code|0|.commandcode/skills +continue|0|.continue/skills +cortex|0|.snowflake/cortex/skills +crush|0|.config/crush/skills +cursor|1|.cursor/skills +deepagents|1|.deepagents/agent/skills +devin|0|.config/devin/skills +dexto|1|.agents/skills +droid|0|.factory/skills +eve|0|- +firebender|1|.firebender/skills +forgecode|0|.forge/skills +gemini-cli|1|.gemini/skills +github-copilot|1|.copilot/skills +goose|0|.config/goose/skills +grok|0|.grok/skills +hermes-agent|0|.hermes/skills +inference-sh|0|.inferencesh/skills +jazz|0|.jazz/skills +junie|0|.junie/skills +iflow-cli|0|.iflow/skills +kilo|0|.kilocode/skills +kimchi|0|.config/kimchi/harness/skills +kimi-code-cli|1|.agents/skills +kiro-cli|0|.kiro/skills +kode|0|.kode/skills +lingma|0|.lingma/skills +loaf|1|.agents/skills +mcpjam|0|.mcpjam/skills +minimax-code|0|.minimax/skills +mistral-vibe|0|.vibe/skills +moxby|0|.moxby/skills +mux|0|.mux/skills +opencode|1|.config/opencode/skills +openhands|0|.openhands/skills +ona|0|.ona/skills +pi|0|.pi/agent/skills +qoder|0|.qoder/skills +qoder-cn|0|.qoder-cn/skills +qwen-code|0|.qwen/skills +replit|1|.config/agents/skills +reasonix|0|.reasonix/skills +rovodev|0|.rovodev/skills +roo|0|.roo/skills +tabnine-cli|0|.tabnine/agent/skills +terramind|0|.terramind/skills +tinycloud|0|.tinycloud/skills +trae|0|.trae/skills +trae-cn|0|.trae-cn/skills +warp|1|.agents/skills +windsurf|0|.codeium/windsurf/skills +zed|1|.agents/skills +zcode|0|.zcode/skills +zencoder|0|.zencoder/skills +zenflow|0|.zencoder/skills +neovate|0|.neovate/skills +pochi|0|.pochi/skills +promptscript|1|- +adal|0|.adal/skills +universal|1|.config/agents/skills' + +# DWS-only compatibility and old wrong global paths. Qoderwork remains a +# non-universal install target; the other entries verify migration cleanup. +LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills +dws-legacy-github|1|.github/skills +dws-legacy-amp|1|.amp/skills +dws-legacy-cline|1|.cline/skills +dws-legacy-windsurf|1|.windsurf/skills' cleanup() { if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -103,11 +153,61 @@ trap cleanup EXIT INT TERM seed_specific_agent_homes() { home_root="$1" - for parent in $HOME_SPECIFIC_AGENT_PARENTS; do - mkdir -p "$home_root/$parent" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + mkdir -p "${base%/skills}" done } +resolve_agent_base() { + home_root="$1"; id="$2"; default_path="$3" + case "$id" in + autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;; + claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;; + codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;; + grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;; + hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;; + mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;; + amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;; + crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;; + openclaw) + for alias in .openclaw .clawdbot .moltbot; do + if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi + done + printf '%s\n' "$home_root/.openclaw/skills" + ;; + *) printf '%s\n' "$home_root/$default_path" ;; + esac +} + +# Package verification must never inherit the runner's real Agent roots. In +# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two +# parallel verification jobs would then publish links into the same directory +# and make one another's links appear broken. Keep every supported override +# inside the scenario-specific temporary HOME instead. +with_isolated_agent_env() ( + isolated_home=$1 + shift + HOME="$isolated_home" + AUTOHAND_HOME="$isolated_home/.autohand" + CLAUDE_CONFIG_DIR="$isolated_home/.claude" + CODEX_HOME="$isolated_home/.codex" + GROK_HOME="$isolated_home/.grok" + HERMES_HOME="$isolated_home/.hermes" + VIBE_HOME="$isolated_home/.vibe" + XDG_CONFIG_HOME="$isolated_home/.config" + export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME + "$@" +) + verify_skill_base() { home_root="$1" base="$2" @@ -116,25 +216,71 @@ verify_skill_base() { [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" } -verify_skill_targets() { - home_root="$1" - found_specific=0 - for base in $HOME_SPECIFIC_SKILL_BASES; do - parent="${base%/skills}" - if [ -d "$home_root/$parent" ]; then - verify_skill_base "$home_root" "$base" - found_specific=1 +verify_compatible_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + target="$base/$name" + if [ -L "$target" ]; then + link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target" + # Relative link targets are the headline invariant of the canonical store: + # an absolute target breaks as soon as the home directory moves or the + # store is relocated, so reject it even though it resolves today. + case "$link_target" in + /*) err "canonical Skill link must be relative at $target (target=$link_target)" ;; + esac + linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" + canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" + [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" + elif [ -d "$target" ]; then + diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target" + else + err "canonical Skill compatibility target missing at $target" fi done + [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" +} - if [ "$found_specific" -eq 0 ]; then - verify_skill_base "$home_root" ".agents/skills" - return - fi +# Universal Agents read the canonical store directly, so they must own no copy +# of ANY canonical Skill. Iterate the real store instead of a hardcoded name +# list: a hardcoded list silently passed leftover copies of every other +# dingtalk-* Skill from earlier betas. +verify_universal_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" + done + # The mono layout is absent from a multi canonical store, so gate it directly. + { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/dws" +} - for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/.agents/skills/$name" ] || \ - err "unexpected generic Skill copy found in $home_root/.agents/skills/$name" +verify_skill_targets() { + home_root="$1" + # The universal-convention canonical store is always present, regardless of + # which concrete Agent homes existed at install time. + verify_skill_base "$home_root" ".agents/skills" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + [ "$base" != "$home_root/.agents/skills" ] || continue + [ -d "${base%/skills}" ] || continue + if [ "$universal" -eq 1 ]; then + verify_universal_skill_base "$home_root" "$base" + continue + fi + verify_compatible_skill_base "$home_root" "$base" done } @@ -146,15 +292,15 @@ verify_npm_install() { npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" if [ "$scenario" = "specific-agent-roots" ]; then - seed_specific_agent_homes "$npm_home" + with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home" fi say "==> verifying npm package install ($scenario)" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" @@ -165,9 +311,9 @@ verify_npm_install() { if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" fi - [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null } @@ -176,6 +322,7 @@ verify_npm() { need_cmd node need_cmd tar need_cmd unzip + need_cmd diff need_file "$NPM_STAGE_DIR/package.json" tarball_name="$( diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index 440c149e..8dc8895a 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -108,6 +108,10 @@ coverage: multi_skill: dingtalk-misc multi_refs: - references/whiteboard.md + - mono: recruit + multi_skill: dingtalk-misc + multi_refs: + - references/recruit.md omit_coverage: - mono: simple diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 2fb8cb3d..d695e969 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -44,7 +44,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aitable` | 93 | `dingtalk-aitable` | -| `attendance` | 19 | `dingtalk-misc` | +| `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | @@ -52,7 +52,7 @@ cli_version: ">=1.0.15" | `ding` | 4 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | -| `mail` | 10 | `dingtalk-mail` | +| `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | | `oa` | 7 | `dingtalk-misc` | | `report` | 2 | `dingtalk-misc` | @@ -94,6 +94,7 @@ cli_version: ">=1.0.15" | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) | | `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) | +| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) | | `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -121,6 +122,7 @@ cli_version: ">=1.0.15" 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` 用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert` +用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit` 用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume` 用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume` diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 5fbff337..e31a2fe0 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -437,23 +437,23 @@ Flags: --role-id string 群身份 openRoleId,由 group-role list 返回 (必填) ``` -#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份) +#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份 ``` Usage: dws chat group-role set-user [flags] Example: - dws chat group-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group-role set-user --group --user --role-id # 查询人员: dws aisearch person --query "姓名" --dimension name # 查询 role-id: dws chat group-role list --group Flags: - --group string 群聊 openConversationId (必填) - --user string 用户 userId(必填) - --role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份 + --group string 群聊 openConversationId (必填) + --user string 用户 userId(必填) + --role-id string 群身份 openRoleId,由 group-role list 返回 ``` #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -2457,7 +2457,7 @@ Flags: | `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 | | `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file` | | `chat message list` | `openMsgId` | message read-status 的 --message-id | -| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id | +| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids | | `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | | `chat category list` | `categoryId` | category list-conversations 的 --category-id | | `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId | @@ -2494,7 +2494,7 @@ Flags: - `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送 - `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) - `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10` -- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` +- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` - 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行) - `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner` - `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久) diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 332d8649..a568a9ce 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -634,6 +634,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -743,6 +767,7 @@ Flags: 用户说"我发起的审批单" -> `approval list-submitted` 用户说"我审批/处理过的审批单" -> `approval list-executed` 用户说"抄送我的审批单" -> `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -792,6 +817,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -834,6 +862,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance | | `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners | | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -844,6 +873,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。 - `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。 diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md new file mode 100644 index 00000000..93502d4c --- /dev/null +++ b/skills/mono/references/products/recruit.md @@ -0,0 +1,39 @@ +# 钉钉招聘 + +`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前 +操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。 + +## 查询 + +```bash +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job get --job-id JOB_ID --format json +``` + +列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`; +`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得 +`jobId`。 + +## 创建 + +创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并 +交由 CLI 的确认流程处理: + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`。 +`jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、 +5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时 +提供时最低薪资不得高于最高薪资。 + +`creatorUserId` 是当前创建人 userId,必须来自同一 profile 下的真实通讯录结果。 +`ownerUserIds` 可选;提供时是负责人 userId 的 JSON 字符串数组,也必须使用真实 +通讯录结果。禁止猜测任何 userId。`extData.headCount` 范围 1–999,薪资月份范围 +12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。 + +文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode` +或 `opUserId`;这些字段由 CLI 或 Connector 处理。 diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 19b89174..8b2580a1 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -24,15 +24,13 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws mail +contact-list` | read | 列出指定邮箱的所有邮件联系人 | | `dws mail +find-mail-user` | read | 按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等) | | `dws mail +folder-list` | read | 列出顶层文件夹或指定父文件夹下的子文件夹 | -| `dws mail +recent-mail` | read | 列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId) | +| `dws mail +message` | read | 读取一封邮件的完整正文与附件元数据 | +| `dws mail +messages` | read | 按请求顺序读取多封邮件并逐封验证身份 | | `dws mail +search-mail` | read | 按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId) | -| `dws mail +tag-list` | read | 列出指定邮箱下的所有邮件标签 | -| `dws mail +template-list` | read | 列出指定邮箱的所有邮件模板 | -| `dws mail +thread-list` | read | 列出指定邮箱文件夹下的邮件会话(thread) | -| `dws mail +unread-mail` | read | 列出未读邮件并投影列表(主题/发件人/时间/messageId) | +| `dws mail +thread` | read | 读取完整邮件会话并精确验证 conversationId | +| `dws mail +triage` | read | 列出或筛选邮件摘要,自动解析邮箱与收件箱 | | `dws mail +user-search` | read | 按关键词或工号搜索邮箱用户(仅企业邮箱) | diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index 487f8c7f..d4eed650 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -31,6 +31,7 @@ metadata: | 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) | | 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) | | 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) | +| 招聘 / 职位 / JD / 在招职位 / 创建职位 / 职位详情 | 钉钉招聘职位的查询、详情与创建 | `dws recruit` | [recruit.md](references/recruit.md) | | 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) | | 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) | | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | diff --git a/skills/multi/dingtalk-misc/references/attendance.md b/skills/multi/dingtalk-misc/references/attendance.md index 00c70d03..994a6c4a 100644 --- a/skills/multi/dingtalk-misc/references/attendance.md +++ b/skills/multi/dingtalk-misc/references/attendance.md @@ -1417,23 +1417,12 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res |---|---|---| | `dws attendance +check-record` | read | 查询用户打卡流水(打卡时间/地点/定位方式) | | `dws attendance +check-result` | read | 查询用户打卡结果(迟到/早退/缺卡等) | -| `dws attendance +get-adjustment-rule` | read | 根据补卡规则主键 ID 查询补卡规则详情 | | `dws attendance +get-approve-template` | read | 查询补卡/请假/加班/外出/出差审批提交链接 | -| `dws attendance +get-checkin-record` | read | 查询指定员工一段时间内的签到记录 | -| `dws attendance +get-leave-records` | read | 查询指定员工的假期余额变更记录 | | `dws attendance +get-overtime-rule` | read | 根据加班规则主键 ID 查询加班规则详情 | -| `dws attendance +get-schedule` | read | 获取指定用户一段时间内的排班记录 | -| `dws attendance +get-self-setting` | read | 查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等) | -| `dws attendance +get-summary` | read | 查询某个人的考勤统计摘要(周/月) | | `dws attendance +list-approve` | read | 查询用户考勤审批单(补卡/加班/请假/出差外出) | -| `dws attendance +list-leave-types` | read | 查询当前用户可用的假期规则列表 | -| `dws attendance +my-attendance` | read | 查我今天的考勤打卡记录(打卡流水,自动解析当前用户) | -| `dws attendance +query-report-data` | read | 根据字段查询考勤报表数据(仅管理员) | | `dws attendance +search-adjustment-rule` | read | 查询当前用户可管理的补卡规则列表 | | `dws attendance +search-class` | read | 查询当前用户可管理的班次详情列表 | -| `dws attendance +search-group` | read | 查询当前用户可管理的考勤组列表 | | `dws attendance +search-overtime-rule` | read | 查询当前用户可管理的加班规则列表 | -| `dws attendance +this-month` | read | 查我本月的考勤打卡记录(打卡流水,自动解析当前用户) | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 0f218814..f526c23c 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -638,6 +638,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -757,6 +781,7 @@ Flags: 用户说"抄送我的审批单/抄送我的XX审批/CC我的XX/查抄送我的XX" → `approval list-cc`,将 XX 作为 `--query` 关键字传入(可搜索表单名称或表单详情内容) - 示例:"查抄送我的补卡审批单" → `approval list-cc --query 补卡` - 示例:"抄送我的审批单"(无关键词)→ `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -815,6 +840,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 aisearch person) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -879,6 +907,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | | `ding-info` | `userId` | ding message send 的 --users(多个逗号拼接);**robotCode 优先走 `$DINGTALK_DING_ROBOT_CODE` 环境变量,content 由 agent 根据审批上下文撰写;返回空时报错并停止** | | `revert-activities` | `activityId`, `revertAction`, `activityName` | revert-task 的 --target-activity-id 和 --action;**返回空时必须告知用户"无可回退节点"** | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -900,6 +929,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取 - 已知表单名称关键字时优先用 `search-forms`;需枚举全部表单时用 `list-forms` +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - 催办必须两步串联:`ding-info` 仅返回被催办人 `userId`,不返回 robotCode/content;需再调用 `dws ding message send`,其中 `--robot-code` 优先使用环境变量 `$DINGTALK_DING_ROBOT_CODE`,若无则向用户确认;`--content` 由 agent 根据审批上下文撰写催办文案;**严禁跳过 `ding-info` 直接猜测 userId** - 催办文案建议格式:`"请尽快审批《{表单名}》(提交人:{发起人},提交时间:{时间})"` - `ding-info` 返回空或报错时,必须明确告知用户"无法获取该任务的被催办人信息"并停止 diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md new file mode 100644 index 00000000..2ed44b96 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -0,0 +1,89 @@ +# 钉钉招聘 + +`dws recruit` 查询和创建钉钉招聘职位。当前公开命令只覆盖职位列表、职位详情和 +职位创建;候选人、面试、Offer、职位修改、开放或关闭尚未作为稳定命令发布。 + +所有命令都应加 `--format json`。组织、业务标识和当前操作人由登录身份及 MCP +Connector 注入,不要要求用户提供或猜测 `corpId`、`bizCode`、`opUserId`。 + +## 查询职位列表 + +```bash +dws recruit job list --format json +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json +``` + +可选筛选包括 `--job-ids`、`--required-edu`、`--status`、`--job-nature`、 +`--campus`、`--start-modified-time`、`--end-modified-time`、 +`--creator-user-ids`、`--keyword`、`--category`。状态接受: + +- `draft`:草稿 +- `open`:招聘中 +- `invalid`:已失效 +- `closed`:已关闭/完成 + +`--size` 默认 20,范围 1–100。首页不传 `--cursor`;响应 `hasMore=true` 时, +把 `nextCursor` 回填到下一次查询。 + +## 查询职位详情 + +先从列表结果取得真实 `jobId`,不得编造: + +```bash +dws recruit job get --job-id JOB_ID --format json +``` + +## 创建职位 + +创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资、创建人和负责人(如有),取得 +明确确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 + +`creatorUserId` 是必填字段,必须使用同一 profile 下当前操作者的真实 userId,不得 +猜测或复用其他组织的 userId。`ownerUserIds` 是可选的负责人 userId JSON 字符串数组; +提供负责人时同样必须使用真实通讯录结果。用户说“负责人是我”时先运行 +`dws contact user get-self --format json` 取得当前 userId;用户指定其他负责人时按 +通讯录 Skill 解析唯一 userId。未指定负责人时允许省略 `ownerUserIds`,不要默认选人。 + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +最小 `job.json`: + +```json +{ + "name": "Java 开发工程师", + "description": "负责服务端系统开发", + "jobNature": "FULL-TIME", + "requiredEdu": 6, + "minSalary": 20000, + "maxSalary": 35000, + "extData": { + "headCount": 1, + "fullTimeExtData": { + "salaryMonth": 12 + } + }, + "creatorUserId": "CURRENT_USER_ID", + "ownerUserIds": ["OWNER_USER_ID"] +} +``` + +必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`;`ownerUserIds` 可选。 +`jobNature` 当前固定为 `FULL-TIME`;`requiredEdu` 为 1–9 的整数(1小学、2初中、 +3高中、4中专、5大专、6本科、7硕士、8博士、9其他)。`minSalary` 与 +`maxSalary` 可选;两者同时提供时最低薪资不得高于最高薪资。 + +`extData.headCount` 范围 1–999;`extData.fullTimeExtData.salaryMonth` 范围 +12–24;最高工作年限不得小于最低工作年限。提供 `address` 时必须同时包含 +`name`、`detail`、`longitude`、`latitude`,经纬度只能来自地图选点或可信地址解析, +不得猜测。`source` 可省略,由 Connector 默认补充为 `manual`;不传 `category` 时 +Connector 会设置 `checkJobCategory=false`。 + +可选字段以当前 `dws recruit job create --help` 和 leaf Schema 为准。不要把 MCP +信封字段 `atsAddJobParam`、`corpId`、`bizCode` 或 `opUserId` 写进文件;CLI 与 +Connector 会负责包装和身份注入。 diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index 1f89476c..89d0f01a 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -22,7 +22,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { // All of these commands should be hidden because they are not in // the dynamic server discovery product list. for _, module := range []string{ - "aisearch", "contract", "recruit", + "aisearch", "contract", "chat", "drive", "minutes", "mail", "credit", "credit-risk", "finance", "message", "notify", "doc", } { @@ -30,4 +30,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { t.Fatalf("root help should not show hidden module %q:\n%s", module, got) } } + if !strings.Contains(got, "● recruit ") { + t.Fatalf("root help should show explicitly registered recruit module:\n%s", got) + } } diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index db802415..9611e9b7 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -1,7 +1,7 @@ [root] runnable: true hidden: false - commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki + commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, recruit, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent [agoal] diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go new file mode 100644 index 00000000..4b414380 --- /dev/null +++ b/test/scripts/agent_registry_test.go @@ -0,0 +1,279 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +type packagedAgent struct { + universal bool + path string +} + +var upstreamAgentIDs = strings.Fields(` +aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob +claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex +command-code continue cortex crush cursor deepagents devin dexto droid eve +firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh +jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam +minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn +qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn +warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal +`) + +func TestAgentRegistriesMatchUpstream76(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1")) + verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh")) + + wantIDs := append([]string(nil), upstreamAgentIDs...) + sort.Strings(wantIDs) + registries := map[string]map[string]packagedAgent{ + "install.ps1": parseInstallPowerShellRegistry(t, installPS), + "install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")), + "verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")), + } + + for name, registry := range registries { + gotIDs := make([]string, 0, len(registry)) + universal := 0 + for id, agent := range registry { + gotIDs = append(gotIDs, id) + if agent.universal { + universal++ + } + } + sort.Strings(gotIDs) + if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") { + t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs) + } + if universal != 19 || len(registry)-universal != 57 { + t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal) + } + } + + canonical := registries["install.ps1"] + for name, got := range registries { + for id, want := range canonical { + if got[id] != want { + t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want) + } + } + } + + for id, wantPath := range map[string]string{ + "amp": ".config/agents/skills", + "github-copilot": ".copilot/skills", + "opencode": ".config/opencode/skills", + "windsurf": ".codeium/windsurf/skills", + "cline": ".agents/skills", + "eve": "-", + "promptscript": "-", + } { + if got := canonical[id].path; got != wantPath { + t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath) + } + } +} + +func TestDWSCompatibilityAgentTargets(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as") + if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) { + t.Error("DWS Qoderwork compatibility target must remain non-universal") + } + for _, id := range []string{ + "dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf", + } { + if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) { + t.Errorf("legacy cleanup registry missing cleanup-only target %s", id) + } + } + for _, id := range upstreamAgentIDs { + if strings.HasPrefix(id, "dws-") { + t.Fatalf("DWS compatibility target %s leaked into upstream registry", id) + } + } +} + +func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) { + t.Parallel() + installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1")) + for _, want := range []string{ + `$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`, + `New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`, + `Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`, + `Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`, + } { + if !strings.Contains(installPS, want) { + t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want) + } + } +} + +func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) { + t.Parallel() + checks := map[string][]string{ + "build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + } + for rel, wants := range checks { + body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel))) + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s missing detection contract %q", rel, want) + } + } + } +} + +func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + cut := strings.LastIndex(installPS, "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + home := t.TempDir() + custom := filepath.Join(t.TempDir(), "custom roots") + xdg := filepath.Join(custom, "xdg") + clawdbot := filepath.Join(home, ".clawdbot") + if err := os.MkdirAll(clawdbot, 0o755); err != nil { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode") +foreach ($id in $ids) { + $agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent)) +} +exit 0 +` + harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1") + mustWriteFile(t, harness, []byte(prefix), 0o644) + + envRoots := map[string]string{ + "AUTOHAND_HOME": filepath.Join(custom, "autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"), + "CODEX_HOME": filepath.Join(custom, "codex"), + "GROK_HOME": filepath.Join(custom, "grok"), + "HERMES_HOME": filepath.Join(custom, "hermes"), + "VIBE_HOME": filepath.Join(custom, "vibe"), + "XDG_CONFIG_HOME": xdg, + } + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + for key, value := range envRoots { + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output) + } + want := map[string]string{ + "amp": filepath.Join(xdg, "agents", "skills"), + "autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(envRoots["CODEX_HOME"], "skills"), + "grok": filepath.Join(envRoots["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"), + "openclaw": filepath.Join(clawdbot, "skills"), + "goose": filepath.Join(xdg, "goose", "skills"), + "kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"), + "opencode": filepath.Join(xdg, "opencode", "skills"), + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "|", 2) + if len(parts) == 2 { + got[parts[0]] = filepath.Clean(parts[1]) + } + } + for id, path := range want { + if got[id] != filepath.Clean(path) { + t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output) + } + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(b) +} + +func between(t *testing.T, text, start, end string) string { + t.Helper() + i := strings.Index(text, start) + if i < 0 { + t.Fatalf("start marker %q missing", start) + } + i += len(start) + j := strings.Index(text[i:], end) + if j < 0 { + t.Fatalf("end marker %q missing", end) + } + return text[i : i+j] +} + +func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent { + t.Helper() + block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets") + re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if path == "" { + path = "-" + } + result[match[1]] = packagedAgent{universal: match[2] == "true", path: path} + } + return result +} + +func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent { + t.Helper() + re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if _, exists := result[match[1]]; exists { + t.Fatalf("duplicate agent %q in pipe registry", match[1]) + } + result[match[1]] = packagedAgent{universal: match[2] == "1", path: path} + } + return result +} diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index b94bb679..ace89d5c 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -16,23 +16,52 @@ * skills, mono leftover dws/ and stale * dingtalk-* are removed, and the * ~/.dws/skills/{multi,mono} caches fill. - * 2. empty multi/ tree — warns and falls back to mono instead of + * 2. universal topology — canonical store installed, pre-existing private + * copies retired and recoverable under + * ~/.dws/skill-backups//. + * 3. second run — idempotent: an already-correct link is neither + * re-backed-up nor rewritten. + * 4. unretireable universal root — a cleanup-only failure WARNS; canonical + * store, non-universal links and skills-state.json + * are all still written and the install exits 0. + * 5. empty multi/ tree — warns and falls back to mono instead of * crashing postinstall; a previously good multi * cache is NOT wiped. - * 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an + * 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an * "invalid DWS_SKILL_MODE" error. - * 4. multi-only zip, mono — mono install is skipped with a warning; the + * 7. multi-only zip, mono — mono install is skipped with a warning; the * staging root is NOT copied into a dws/ dir. - * 5. multi backup failure — preserves mono, writes no multi skill, and + * 8. multi backup failure — preserves mono, writes no multi skill, and * reports postinstall failure. - * 6. mono backup failure — preserves multi, writes no mono skill, and + * 9. mono backup failure — preserves multi, writes no mono skill, and * reports postinstall failure. - * 7. mono switch — migrates only centrally owned multi Skills. - * 8. cache copy failure — preserves the previous complete cache. - * 9. multi publish failure — restores the complete previous Skill set. - * 10. multi backup failure — restores every earlier successful backup. - * 11. mono transaction failure — restores every managed multi Skill after + * 10. mono switch — migrates only centrally owned multi Skills. + * 11. cache copy failure — preserves the previous complete cache. + * 12. no-replace publish — an occupied destination that is not a victim is + * refused, not replaced or linked into. + * 13. simulated win32 — junction type + ABSOLUTE canonical target and + * the rename publish path with its no-replace + * guard, exercised by swapping process.platform + * instead of requiring a Windows host. + * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. + * 15. unknown preservation — prune never removes non-DWS directories in skill-backups. + * 16. multi publish failure — restores the complete previous Skill set. + * 17. multi backup failure — restores every earlier successful backup. + * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. + * 19. copy publish no-replace — mono/multi set publication refuses a + * concurrently occupied destination (atomic + * claim, no check-then-rename window) on + * POSIX and simulated win32. + * 20. unmarked stamps — stamp-shaped directories without the exact + * ownership marker survive pruning and never + * consume a keep slot. + * 24. stamp-root cleanup — a failed marker write removes an empty fresh + * root (rmdir path) but never a non-empty + * foreign root. + * 25. rollback ENOENT gate — a vanished child makes the destination + * unverifiable (rollback refused), never + * "destination already gone". * * Requirements: unix host with tar/zip/unzip on PATH (the same tools * install.js itself shells out to). Skips cleanly on win32. @@ -62,10 +91,36 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), const installJsSource = path.join(repoRoot, "build", "npm", "install.js"); const require = createRequire(import.meta.url); const { + UPSTREAM_AGENTS, + resolvedAgentTargets, + agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, + backupAndRemoveSkillDir, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, } = require(installJsSource); + +assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned"); +assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); +assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); +const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-")); +fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true }); +fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true }); +const detectionTargets = resolvedAgentTargets(detectionHome); +for (const id of ["kimchi", "tabnine-cli"]) { + assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`); +} +fs.rmSync(detectionHome, { recursive: true, force: true }); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -90,6 +145,18 @@ function writeFile(filePath, content, mode = 0o644) { fs.writeFileSync(filePath, content, { mode }); } +// Cross-surface backup ownership contract (mirrors internal/upgrade/paths.go, +// scripts/install.ps1, scripts/install-devapp.ps1, and scripts/install.sh): +// every DWS-created stamp root carries .dws-skill-backup whose entire content +// is exactly these bytes. Hardcoded here on purpose so drift from any writer +// fails the smoke test. +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +function markBackupStamp(stampRoot) { + writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + function writeManagedState(home, names) { const state = { version: "old", @@ -107,6 +174,255 @@ function writeManagedState(home, names) { writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`); } +function crossDeviceError() { + return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); +} + +// withSimulatedWin32 runs fn with process.platform === "win32" so the +// Windows-only publish branches (junction type, absolute link target, and +// the atomic junction creation at the destination) get real execution +// coverage on a unix host. +// fs.symlinkSync ignores the type argument off Windows, so the same call is +// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the +// simulated platform. +function withSimulatedWin32(fn) { + const original = process.platform; + const set = (value) => + Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false }); + set("win32"); + try { + return fn(); + } finally { + set(original); + } +} + +function runCrossDeviceMoveContract() { + const roots = []; + const fixture = (name) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`)); + roots.push(root); + const src = path.join(root, "external", "skill"); + const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill"); + writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640); + return { root, src, dest }; + }; + const injectedRename = (source, target) => { + if (path.basename(source) === "skill" && path.basename(target) === "skill") { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }; + try { + { + const { src, dest } = fixture("success"); + fs.symlinkSync("SKILL.md", path.join(src, "skill-link")); + fs.symlinkSync("missing.md", path.join(src, "dangling-link")); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.lstatSync(dest).isDirectory(), true); + assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640); + assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md"); + assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md"); + assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last"); + + const restored = path.join(path.dirname(src), "restored-skill"); + movePathRecoverablySync(dest, restored, { + renameFn(source, target) { + if (source === dest && target === restored) throw crossDeviceError(); + fs.renameSync(source, target); + }, + }); + assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup"); + } + + for (const [name, options] of [ + ["copy", { copyFn() { throw new Error("copy failed"); } }], + ["verify", { verifyFn() { throw new Error("verify failed"); } }], + ]) { + const { root, src, dest } = fixture(name); + assert.throws( + () => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }), + new RegExp(`${name} failed`), + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false); + const backupParent = path.dirname(dest); + if (fs.existsSync(backupParent)) { + assert.equal( + fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")), + false, + `${name} failure cleans destination-filesystem staging`, + ); + } + assert.ok(root); + } + + { + const { src, dest } = fixture("remove"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("read-only"); + fs.chmodSync(src, 0o555); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("read-only-remove-failure"); + fs.chmodSync(src, 0o555); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode"); + fs.chmodSync(src, 0o755); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("permission"); + let copied = false; + const permission = Object.assign(new Error("permission denied"), { code: "EACCES" }); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn() { throw permission; }, + copyFn() { copied = true; }, + }), + /permission denied/, + ); + assert.equal(copied, false, "EACCES must not be mistaken for EXDEV"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("dest-verify"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + verifyFn(left, right) { + if (right === dest) throw new Error("dest-verify failed"); + }, + }), + /dest retracted|dest-verify failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy verify failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-chmod"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + chmodFn(target, mode) { + if (target === dest) throw new Error("chmod failed"); + fs.chmodSync(target, mode); + }, + }), + /dest retracted|chmod failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy chmod failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-cleanup"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target !== src && path.basename(target).startsWith(".skill.cross-device-")) { + throw new Error("cleanup failed"); + } + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /dest retracted|cleanup failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy staging cleanup failure retracts dest"); + } + + { + const { src, dest } = fixture("same-volume-occupied"); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => movePathRecoverablySync(src, dest), + /already exists/, + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); + roots.push(root); + const home = path.join(root, "home"); + const victim = path.join(home, ".agents", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + const stamp = "20260813-150000"; + const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat"); + fs.mkdirSync(path.dirname(occupied), { recursive: true }); + fs.symlinkSync("missing-backup", occupied); + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }); + assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling"); + assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-")); + roots.push(root); + const home = path.join(root, "home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const victim = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], { + renameFn(source, target) { + if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }, + publishSymlinkFn() { throw new Error("link publish failed"); }, + }), + /link publish failed/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n"); + } + } finally { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + } +} + // stagePkg builds a fake npm package whose assets/dws-skills.zip contains // exactly the given zip entries ({ "relative/path": "content" }) plus any // listed empty directories. Returns { tmp, pkg, home }. @@ -133,8 +449,8 @@ function stagePkg(zipEntries, emptyDirs = []) { return { tmp, pkg, home: path.join(tmp, "home") }; } -function runInstall(pkg, home, skillMode) { - const env = { ...process.env, HOME: home }; +function runInstall(pkg, home, skillMode, extraEnv = {}) { + const env = { ...process.env, HOME: home, ...extraEnv }; if (skillMode !== undefined) { env.DWS_SKILL_MODE = skillMode; } else { @@ -193,14 +509,32 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex uses its canonical root without a generic duplicate", () => { +scenario("pinned universal global topology installs canonical and retires private copies", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", "multi/dws-shared/SKILL.md": "# dws-shared\n", }); try { - writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); + // The pinned upstream installer treats global universal Agents as + // canonical-only even when their registry also retains a distinct + // globalSkillsDir for legacy discovery/removal. Exercise every unique + // non-canonical universal root so this distinction cannot drift again. + const retiredUniversalRoots = [ + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".codex/skills", + ".cursor/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".gemini/skills", + ".copilot/skills", + ".config/opencode/skills", + ]; + for (const root of retiredUniversalRoots) { + writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`); + } writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -209,13 +543,15 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { const res = runInstall(pkg, home, "multi"); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( - fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")), - "Codex canonical Skill installed", - ); - assert.ok( - !fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), - "generic flat duplicate not installed", + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "universal canonical Skill installed", ); + for (const root of retiredUniversalRoots) { + assert.ok( + !fs.existsSync(path.join(home, root, "dingtalk-chat")), + `beta.6 universal duplicate migrated from ${root}`, + ); + } assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy nested duplicate retired", @@ -225,7 +561,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { } }); -scenario("ZCode uses its canonical root without a generic duplicate", () => { +scenario("ZCode links its Agent root to the canonical store", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -241,8 +577,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")), - "ZCode canonical Skill installed", + "ZCode Skill resolves through canonical link", ); + assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink()); + assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"))); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy generic duplicate retired", @@ -252,6 +590,172 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { } }); +scenario("a second install run is idempotent and preserves correct links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + try { + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + + const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + const firstTarget = fs.readlinkSync(linked); + const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + + const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link"); + assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + !second.stdout.includes(`已备份并移除 ${linked}`), + "an already-correct link must not be backed up again", + ); + const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs"); + assert.deepEqual( + secondState.managed_skills.map((record) => record.digest), + firstState.managed_skills.map((record) => record.digest), + "re-installing the same bundle yields the same provenance digests", + ); + assert.ok( + fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5, + "repeated installs keep the backup root bounded", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("unretireable universal copy warns but still completes the install", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const codexSkills = path.join(home, ".codex", "skills"); + try { + // Non-universal Agent that must still receive its link. + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + // Universal Agent whose obsolete copy cannot be moved out: r-x on the + // parent means rename() fails with EACCES. + writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + fs.chmodSync(codexSkills, 0o500); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: path.join(home, ".config"), + CODEX_HOME: "", + }); + assert.equal( + res.status, + 0, + `a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`, + ); + assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning"); + assert.ok( + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "canonical store is still installed", + ); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + fs.existsSync(path.join(home, ".dws", "skills-state.json")), + "skills-state.json is written even when a retire failed", + ); + assert.equal( + fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"), + "old codex copy\n", + "a copy that cannot be backed up is never deleted", + ); + } finally { + try { + fs.chmodSync(codexSkills, 0o700); + } catch {} + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const xdg = path.join(tmp, "xdg config"); + const autohand = path.join(tmp, "autohand home"); + const claude = path.join(tmp, "claude config"); + const codex = path.join(tmp, "codex home"); + const hermes = path.join(tmp, "hermes home"); + try { + writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); + writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); + writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); + writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(claude, "config.json"), "{}\n"); + writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n"); + writeFile(path.join(codex, "config.toml"), "model=test\n"); + writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(hermes, "config.json"), "{}\n"); + writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n"); + writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); + writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: xdg, + AUTOHAND_HOME: autohand, + CLAUDE_CONFIG_DIR: claude, + CODEX_HOME: codex, + HERMES_HOME: hermes, + }); + assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); + + for (const linked of [ + path.join(xdg, "goose", "skills", "dingtalk-chat"), + path.join(autohand, "skills", "dingtalk-chat"), + path.join(claude, "skills", "dingtalk-chat"), + path.join(hermes, "skills", "dingtalk-chat"), + path.join(home, ".qoderwork", "skills", "dingtalk-chat"), + ]) { + assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); + assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + } + const backupText = []; + const backupNames = []; + const collectBackupText = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + backupNames.push(entry.name); + collectBackupText(full); + } else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + } + }; + collectBackupText(path.join(home, ".dws", "skill-backups")); + assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); + assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); + // Retired universal copies are the PR's advertised recovery story: every + // one of them must be readable back out of ~/.dws/skill-backups. + assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable"); + assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable"); + assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable"); + assert.ok( + backupNames.includes(".amp-skills-dingtalk-chat"), + `backup names record the HOME-relative origin, got ${backupNames.join(",")}`, + ); + for (const retired of [ + path.join(xdg, "agents", "skills", "dingtalk-chat"), + path.join(codex, "skills", "dingtalk-chat"), + path.join(home, ".amp", "skills", "dingtalk-chat"), + ]) { + assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => { const { tmp, pkg, home } = stagePkg({ "SKILL.md": "# mono root copy\n", @@ -407,6 +911,514 @@ 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 { + { + // Real no-replace property: the destination is occupied by data this + // transaction never backed up (it is not a victim), so the production + // publisher must refuse rather than replace or link into it. No stub + // creates or destroys anything here. + 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"); + writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []), + /Skill publish destination already exists/, + ); + assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.deepEqual( + fs.readdirSync(destination), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + 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], + { + publishSymlinkFn(target, linkPath, type) { + publishCalls += 1; + if (publishCalls === 1) { + fs.symlinkSync(target, linkPath, type); + return; + } + fs.unlinkSync(first); + writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n"); + throw new Error("injected second canonical publish failure"); + }, + }, + ), + /rollback failed|refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(first, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "unmatched quarantine must be restored onto dest", + ); + assert.ok( + !fs.existsSync(path.join(first, "SKILL.md")), + "restored concurrent dest must not be replaced by the backup", + ); + 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, 0, "restored concurrent object must not stay hidden in quarantine"); + } + + { + // Direct rollback of a recorded publication after a concurrent writer + // replaced dest: ownership is proven at dest first, so the foreign + // object is never claimed into quarantine. + const dest = path.join(tmp, "direct-rollback", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + fs.rmSync(dest, { recursive: true, force: true }); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "concurrent replacement stays at the original destination", + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-")), + false, + "unowned dest must not be moved into quarantine", + ); + } + + { + // Dest matched at the pre-check, then the quarantined object no longer + // matches: restore it onto dest with no-replace instead of deleting. + const dest = path.join(tmp, "post-quarantine", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + assert.throws( + () => rollbackPublishedSkillPath(publication, { + quarantineRenameFn(source, target) { + fs.renameSync(source, target); + fs.writeFileSync(path.join(target, "SKILL.md"), "mutated-in-quarantine\n"); + }, + }), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), + "mutated-in-quarantine\n", + "post-quarantine mismatch is restored onto dest", + ); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-") + && fs.existsSync(path.join(parent, name, "payload", "SKILL.md"))), + false, + "restored dest must not stay hidden in quarantine", + ); + } + + { + // Injected race: a concurrent creator occupies the destination at the + // instant of publication. Link creation must fail atomically with + // EEXIST, leaving the foreign object and its contents completely + // unchanged — nothing is replaced, and nothing is linked into it (the + // old `ln -P` publish treated an occupied directory as a container and + // left its stray link behind because the publication had not yet + // entered the rollback list). + const home = path.join(tmp, "injected-race-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".codex", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + assert.throws( + () => + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(dest), + ["concurrent-user-data.txt"], + "the foreign object is neither replaced nor linked into", + ); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.lstatSync(dest).isDirectory(), true, "the foreign directory is untouched"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("mono and multi set publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-copy-race-")); + try { + { + // Real no-replace property for the multi copy publisher: the destination + // is occupied by data this transaction never backed up (it is not a + // victim), so publication must refuse rather than rename over it. The + // claim (mkdir) is the existence check itself — a concurrent creator + // either loses the claim race or is never touched. + const home = path.join(tmp, "multi-home"); + const source = path.join(tmp, "multi-source"); + const base = path.join(home, ".cursor", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + writeFile(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], []), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(path.join(base, "dingtalk-second")), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Same contract for the mono publisher (canonical publish and the + // copy fallback both flow through it). + const home = path.join(tmp, "mono-home"); + const source = path.join(tmp, "mono-source"); + const base = path.join(home, ".agents", "skills"); + writeFile(path.join(source, "SKILL.md"), "new mono\n"); + writeFile(path.join(base, "dws", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMonoSkillSetAtomically(home, source, base, []), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(path.join(base, "dws")), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(base, "dws", "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-mono-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Injected race: a concurrent creator occupies the destination the + // instant before the claim. The old check-then-rename code replaced it; + // the claim must now fail with EEXIST and leave the user object intact. + const home = path.join(tmp, "race-home"); + const source = path.join(tmp, "race-source"); + const base = path.join(home, ".zcode", "skills"); + const secondDest = path.join(base, "dingtalk-second"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], [], { + publishMkdirFn(target) { + if (target === secondDest) { + writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); + } + fs.mkdirSync(target); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(secondDest), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); + assert.equal(fs.readFileSync(path.join(secondDest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + } + + { + // The guarantee holds on the simulated Windows publisher path as well + // (libuv's rename replaces outright there, so the claim matters most). + const home = path.join(tmp, "win32-home"); + const source = path.join(tmp, "win32-source"); + const base = path.join(home, ".qoder", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "must survive\n"); + withSimulatedWin32(() => { + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first"], []), + /Skill publish destination already exists/, + ); + }); + assert.deepEqual(fs.readdirSync(path.join(base, "dingtalk-first")), ["concurrent-user-data.txt"]); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("simulated win32 publishes junctions with absolute canonical targets", () => { + // Temp roots are created before entering the simulated platform: os.tmpdir() + // would otherwise resolve through Windows environment variables. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-")); + try { + const home = path.join(tmp, "home"); + const canonical = path.join(home, ".agents", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const base = path.join(home, ".zcode", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n"); + writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n"); + + const symlinkCalls = []; + const publishCalls = []; + withSimulatedWin32(() => { + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], { + symlinkFn(target, linkPath, type) { + symlinkCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + publishSymlinkFn(target, linkPath, type) { + publishCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + }); + }); + + assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill"); + assert.equal(symlinkCalls[0].type, "junction", "Windows must stage junctions, not symlinks"); + assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target"); + assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store"); + assert.equal(publishCalls.length, 1, "win32 publishes by creating the junction directly at the destination"); + assert.equal(publishCalls[0].linkPath, dest); + assert.equal(publishCalls[0].type, "junction"); + assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + const stamps = fs.readdirSync(backupRoot); + assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp"); + assert.deepEqual( + fs.readdirSync(path.join(backupRoot, stamps[0])).sort(), + [SKILL_BACKUP_MARKER_FILE, ".zcode-skills-dingtalk-chat"], + "the stamp root carries the ownership marker next to the backup payload", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], SKILL_BACKUP_MARKER_FILE), "utf8"), + SKILL_BACKUP_MARKER_BODY, + "backup creation stamps the exact cross-surface marker bytes", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"), + "old zcode copy\n", + "the replaced copy stays recoverable", + ); + + // FIX 5: an object appearing at the destination at publish time must never + // be replaced — junction creation fails atomically with EEXIST (the old + // win32 rename passed MOVEFILE_REPLACE_EXISTING and replaced it). + const raceBase = path.join(home, ".qoder", "skills"); + const occupied = path.join(raceBase, "dingtalk-chat"); + withSimulatedWin32(() => { + assert.throws( + () => + publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), + /Skill publish destination already exists/, + ); + }); + assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); + assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(process.platform !== "win32", true, "the simulated platform is restored"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("skill backups stay bounded to the newest five stamps", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" }); + + const stamps = fs.readdirSync(backupRoot).sort(); + assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`); + assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning"); + assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first"); + assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess"); + assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained"); + assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat"); + assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("prune preserves unknown directories in skill-backups", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const unknowns = [ + "user-personal-backup", + "20260101-00000", // too few digits in the time part + "20260101-000000-abc", // non-numeric suffix + "notes", + "20260101", // missing time portion + ]; + for (const name of unknowns) { + writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`); + } + + pruneSkillBackups(home); + + const remaining = fs.readdirSync(backupRoot).sort(); + const stamped = remaining.filter((n) => isSkillBackupStamp(n)); + assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`); + assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first"); + assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned"); + assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained"); + for (const name of unknowns) { + assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`); + assert.equal( + fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"), + `unknown:${name}\n`, + `unknown directory ${name} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("prune preserves unmarked stamp directories without consuming keep slots", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unmarked-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + // Exactly SKILL_BACKUP_KEEP verified stamps: nothing may be pruned, even + // though foreign stamp-shaped siblings would push the naive count past + // the limit. + const marked = ["20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004", "20260101-000005"]; + for (const stamp of marked) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const foreign = [ + { stamp: "20260102-000001", body: null }, // no marker at all + { stamp: "20260102-000002", body: "dws skill backup v2\n" }, // wrong body + { stamp: "20260101-000000", body: "dws skill backup v1" }, // missing trailing LF + { stamp: "20260101-000099", body: "dws skill backup v1\r\n" }, // CRLF is not the exact contract bytes + ]; + for (const { stamp, body } of foreign) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "user-data.txt"), `foreign:${stamp}\n`); + if (body !== null) writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), body); + } + + pruneSkillBackups(home); + + let remaining = fs.readdirSync(backupRoot).sort(); + assert.deepEqual( + remaining, + [...marked, ...foreign.map(({ stamp }) => stamp)].sort(), + "with exactly keep-many verified stamps, nothing is pruned and unmarked stamps never count", + ); + + // Force real pruning: three more verified stamps push marked history to + // eight, so exactly the three oldest verified stamps go while every + // unmarked sibling stays put (still not consuming a keep slot). + for (const stamp of ["20260103-000001", "20260103-000002", "20260103-000003"]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + + pruneSkillBackups(home); + + remaining = fs.readdirSync(backupRoot).sort(); + for (const pruned of marked.slice(0, 3)) { + assert.ok(!remaining.includes(pruned), `oldest verified stamp ${pruned} is pruned`); + } + assert.deepEqual( + remaining, + [marked[3], marked[4], ...foreign.map(({ stamp }) => stamp), "20260103-000001", "20260103-000002", "20260103-000003"].sort(), + "pruning keeps exactly the newest five verified stamps and preserves every unmarked sibling", + ); + for (const { stamp } of foreign) { + assert.equal( + fs.readFileSync(path.join(backupRoot, stamp, "user-data.txt"), "utf8"), + `foreign:${stamp}\n`, + `unmarked stamp ${stamp} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); @@ -431,11 +1443,23 @@ scenario("multi set publish failure restores the complete previous set", () => { [first, second], { renameFn(src, dest) { - if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { - throw new Error("injected second publish failure"); + if ( + src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second)) + ) { + throw crossDeviceError(); } originalRename(src, dest); }, + // Child files publish through the link primitive now, so the + // injected failure rides the link seam. + publishLinkFn(src, dest) { + if (src.includes(".dws-multi-set.tmp-") && src.includes(`${path.sep}dingtalk-second${path.sep}`)) { + throw new Error("injected second publish failure"); + } + return fs.linkSync(src, dest); + }, }, ), /injected second publish failure/, @@ -516,13 +1540,20 @@ for (const failureKind of ["backup", "publish"]) { } if ( failureKind === "publish" && - src.includes(".dws-mono-set.tmp-") && - path.basename(src) === "dws" + (src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second))) ) { - throw new Error("injected mono publish failure"); + throw crossDeviceError(); } originalRename(src, target); }, + publishLinkFn(src, target) { + if (failureKind === "publish" && src.includes(".dws-mono-set.tmp-")) { + throw new Error("injected mono publish failure"); + } + return fs.linkSync(src, target); + }, }), /injected|failed to back up/, ); @@ -539,6 +1570,216 @@ for (const failureKind of ["backup", "publish"]) { }); } +// A stamp root whose ownership marker write fails must not linger: an empty +// fresh root is removed (rmdir, never a recursive delete), while a +// pre-existing non-empty root holding foreign data always survives. +scenario("a failed marker write never leaves an empty unowned stamp root", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-stamp-cleanup-")); + try { + const home = path.join(tmp, "home"); + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + // Fresh root: created by this call, then the marker write is poisoned so + // the backup aborts before any payload moves. + const stamp = "20260801-000000"; + const markerPath = path.join(home, ".dws", "skill-backups", stamp, SKILL_BACKUP_MARKER_FILE); + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = (target, data, options) => { + if (target === markerPath) { + throw Object.assign(new Error("injected marker write failure"), { code: "EACCES" }); + } + return originalWriteFileSync(target, data, options); + }; + try { + assert.throws( + () => backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }), + /failed to back up Skill directory/, + ); + } finally { + fs.writeFileSync = originalWriteFileSync; + } + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old codex copy\n", "the source stays in place"); + assert.equal( + fs.existsSync(path.join(home, ".dws", "skill-backups", stamp)), + false, + "an empty unowned stamp root is cleaned up", + ); + + // Foreign non-empty root with the marker path occupied by a directory: + // unusable, so the backup moves to a suffixed root and the foreign root + // is never touched. + const foreignStamp = "20260801-000001"; + const foreignRoot = path.join(home, ".dws", "skill-backups", foreignStamp); + writeFile(path.join(foreignRoot, "user-data.txt"), "foreign\n"); + fs.mkdirSync(path.join(foreignRoot, SKILL_BACKUP_MARKER_FILE)); + const foreignBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => foreignStamp }); + assert.ok(foreignBackup.includes(`${foreignStamp}-1`), `the backup moves to a suffixed root: ${foreignBackup}`); + assert.equal(fs.readFileSync(path.join(foreignRoot, "user-data.txt"), "utf8"), "foreign\n", "a non-empty foreign stamp root survives untouched"); + + // Plain foreign root (ordinary files, no marker): must never be adopted — + // the backup lands in the suffixed root instead, and the foreign root + // never receives our marker. + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + const plainStamp = "20260801-000002"; + const plainRoot = path.join(home, ".dws", "skill-backups", plainStamp); + writeFile(path.join(plainRoot, "user-data.txt"), "foreign\n"); + const plainBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => plainStamp }); + assert.ok(plainBackup.includes("20260801-000002-1"), `the backup moves to a suffixed root: ${plainBackup}`); + assert.equal(fs.readFileSync(path.join(plainRoot, "user-data.txt"), "utf8"), "foreign\n", "plain foreign data survives"); + assert.equal(fs.existsSync(path.join(plainRoot, SKILL_BACKUP_MARKER_FILE)), false, "the foreign root never receives our marker"); + assert.equal(fs.readFileSync(path.join(plainBackup, "SKILL.md"), "utf8"), "old codex copy\n", "the payload is intact in the suffixed root"); + assert.equal( + fs.readFileSync(path.join(path.dirname(plainBackup), SKILL_BACKUP_MARKER_FILE), "utf8"), + "dws skill backup v1\n", + "the suffixed root carries the ownership marker", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ENOENT during the pre-quarantine identity walk means "already gone" only +// when the destination itself is missing; a concurrently removed CHILD must +// fail the rollback loudly instead of silently skipping it. +scenario("rollback refuses an unverifiable destination whose child vanished", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-rollback-enoent-")); + try { + const dest = path.join(tmp, "skill"); + writeFile(path.join(dest, "SKILL.md"), "payload\n"); + writeFile(path.join(dest, "child.md"), "child\n"); + const publication = recordSkillPathPublicationSync(dest); + + // Remove the child while the pre-quarantine fingerprint walk sits between + // its readdir and the per-child lstat, so the walk itself fails ENOENT. + const firstChild = path.join(dest, "SKILL.md"); + const vanishedChild = path.join(dest, "child.md"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = (target, options) => { + if (target === firstChild) { + fs.rmSync(vanishedChild); + } + return originalReadFileSync(target, options); + }; + try { + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete unverifiable Skill/, + ); + } finally { + fs.readFileSync = originalReadFileSync; + } + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "payload\n", "the unverifiable destination is preserved"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("per-child no-clobber refuses same-name concurrent entries after the claim", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-child-race-")); + try { + const buildFixture = (label) => { + const home = path.join(tmp, label); + const source = path.join(tmp, `${label}-src`); + fs.mkdirSync(path.join(source, "dingtalk-chat", "references"), { recursive: true }); + writeFile(path.join(source, "dingtalk-chat", "SKILL.md"), "new chat\n"); + writeFile(path.join(source, "dingtalk-chat", "notes.md"), "notes\n"); + writeFile(path.join(source, "dingtalk-chat", "references", "guide.md"), "guide\n"); + fs.symlinkSync("SKILL.md", path.join(source, "dingtalk-chat", "rel-link")); + return { home, source, base: path.join(home, ".zcode", "skills") }; + }; + + { + // Same-name concurrent FILE: the foreign file lands at + // /SKILL.md between the claim and the per-child link primitive. + const { home, source, base } = buildFixture("file-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "SKILL.md"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishLinkFn(src, target) { + if (target === planted && !fs.existsSync(planted)) { + writeFile(planted, "concurrent-user-edit\n"); + } + return fs.linkSync(src, target); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readFileSync(planted, "utf8"), "concurrent-user-edit\n", "the concurrent file is never replaced"); + assert.deepEqual( + fs.readdirSync(dest).sort(), + ["SKILL.md"], + "no other child may move once the concurrent entry is seen", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent DIRECTORY at /references. + const { home, source, base } = buildFixture("dir-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "references"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishMkdirFn(target) { + if (target === planted && !fs.existsSync(planted)) { + fs.mkdirSync(path.join(planted, "user-data"), { recursive: true }); + writeFile(path.join(planted, "user-data", "keep.txt"), "keep\n"); + } + fs.mkdirSync(target, { mode: 0o700 }); + }, + }), + /Skill move destination already exists/, + ); + assert.equal( + fs.readFileSync(path.join(planted, "user-data", "keep.txt"), "utf8"), + "keep\n", + "the concurrent directory survives byte-identical", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent SYMLINK at /rel-link. + const { home, source, base } = buildFixture("link-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "rel-link"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkname) { + if (linkname === planted && !fs.existsSync(planted)) { + fs.symlinkSync("foreign-target", planted); + } + return fs.symlinkSync(target, linkname); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readlinkSync(planted), "foreign-target", "the concurrent link is never replaced"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); +runCrossDeviceMoveContract(); +process.stdout.write("ok\n"); + for (const [name, fn] of scenarios) { process.stdout.write(`• ${name} ... `); fn(); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d6e299a5..0ff9b5af 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" @@ -182,13 +184,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } - skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md") + skillPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output)) } - genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws") - if _, err := os.Stat(genericPath); !os.IsNotExist(err) { - t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output)) + duplicatePath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws") + if _, err := os.Lstat(duplicatePath); !os.IsNotExist(err) { + t.Fatalf("universal Cursor root must not duplicate canonical Skill: Lstat(%s) = %v\noutput:\n%s", duplicatePath, err, string(output)) } } @@ -214,13 +216,1192 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) { } } +func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required string + forbidden string + }{ + {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + if !strings.Contains(text, check.required) { + t.Errorf("%s missing explicit failure contract %q", check.path, check.required) + } + if strings.Contains(text, check.forbidden) { + t.Errorf("%s still contains silent failure path %q", check.path, check.forbidden) + } + } +} + +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + installPs1 := filepath.Join("..", "..", "scripts", "install.ps1") + data, err := os.ReadFile(installPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", installPs1, err) + } + text := string(data) + for _, want := range []string{ + "function Remove-LinkStageRoot", + "Remove-LinkStageRoot -StageRoot $stageRoot", + } { + if !strings.Contains(text, want) { + t.Errorf("install.ps1 missing junction-safe removal contract %q", want) + } + } + // The rollback of published junction paths must go through the lexical + // remover: Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse and could delete the canonical store's contents. + // Assert on the Restore-MultiSkillSet section so identity-anchor + // refactors keep the contract without breaking on variable naming. + begin := strings.Index(text, "function Restore-MultiSkillSet") + if begin < 0 { + t.Fatal("install.ps1 missing Restore-MultiSkillSet") + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + t.Fatal("install.ps1 Restore-MultiSkillSet section not terminated") + } + restore := text[begin : begin+1+end] + if !strings.Contains(restore, "Remove-SkillPathLexically") { + t.Errorf("install.ps1 Restore-MultiSkillSet must remove published paths lexically") + } + if strings.Contains(restore, "Remove-Item") && strings.Contains(restore, "-Recurse") { + t.Errorf("install.ps1 Restore-MultiSkillSet still contains a recursive removal:\n%s", restore) + } + + devappPs1 := filepath.Join("..", "..", "scripts", "install-devapp.ps1") + devappData, err := os.ReadFile(devappPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", devappPs1, err) + } + for _, want := range []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + } { + if !strings.Contains(string(devappData), want) { + t.Errorf("install-devapp.ps1 missing junction-safe removal contract %q", want) + } + } + + // The backup prune must also be reparse-safe: a stamp tree can hold + // junction/symlink entries at any depth (victims are collected before + // the physical-equality filter), and Windows PowerShell 5.1 follows + // reparse points during Remove-Item -Recurse. + for scriptName, pruneFn := range map[string]string{ + "install.ps1": "Remove-OldSkillBackups", + "install-devapp.ps1": "Remove-OldDevSkillBackups", + } { + scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptName, err) + } + body := extractPowerShellFunction(t, string(scriptData), pruneFn) + if strings.Contains(body, "-Recurse") { + t.Errorf("%s %s must delegate to child-first lexical removal, not Remove-Item -Recurse:\n%s", scriptName, pruneFn, body) + } + } +} + +// extractPowerShellFunction returns a `function Name { ... }` body from a +// PowerShell script, terminated by the next top-level function declaration. +func extractPowerShellFunction(t *testing.T, text, name string) string { + t.Helper() + begin := strings.Index(text, "function "+name) + if begin < 0 { + t.Fatalf("function %s not found", name) + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + end = len(text) - begin - 1 + } + return text[begin : begin+1+end] +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + // Prefer the advertised Windows PowerShell 5.1 irm|iex surface on Windows; + // pwsh 7 can mask junction removal and resolution differences. + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Probe Test-SamePhysicalSkillRoot with both link flavors: junctions need + // no privilege on Windows but silently no-op on non-Windows pwsh builds; + // symlinks need no privilege on Unix but may fail without Developer Mode + // on Windows. Exit 4 marks "no link flavor testable on this host". + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$other = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-shared' +New-Item -ItemType Directory -Path $other -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$asserted = 0 +$junction = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $junction) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } + $junctionChain = Join-Path $linkParent 'dingtalk-chat-chain' + New-Item -ItemType Junction -Path $junctionChain -Target $junction -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $junctionChain -Right $canonical)) { exit 5 } +} +$symlink = Join-Path $linkParent 'dingtalk-shared' +New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $symlink) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } + $symlinkChain = Join-Path $linkParent 'dingtalk-shared-chain' + New-Item -ItemType SymbolicLink -Path $symlinkChain -Target $symlink -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $symlinkChain -Right $other)) { exit 5 } +} +if ($asserted -eq 0) { exit 4 } +if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } +if (Test-Path -LiteralPath $junction) { exit 0 } +exit 4 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + probeOutput, probeErr := probeCmd.CombinedOutput() + if probeErr != nil { + exitCode := 0 + if exitErr, ok := probeErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + if exitCode != 4 { + t.Fatalf("link must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(probeOutput)) + } + return // junctions unavailable on this host (non-Windows pwsh): probed via symlink only + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + +func TestInstallPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$root = $env:DWS_TEST_HOME +$stage = Join-Path $root 'stage-payload' +$dest = Join-Path $root 'agent\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $stage -Force | Out-Null +New-Item -ItemType Directory -Path $dest -Force | Out-Null +Set-Content -LiteralPath (Join-Path $dest 'user-data.txt') -Value 'keep' +try { + Move-SkillPath -Source $stage -Destination $dest + exit 11 +} catch {} +if (!(Test-Path -LiteralPath $stage -PathType Container)) { exit 12 } +if (Test-Path -LiteralPath (Join-Path $dest 'stage-payload')) { exit 13 } +if (!(Test-Path -LiteralPath (Join-Path $dest 'user-data.txt') -PathType Leaf)) { exit 14 } + +# Simulate a later failure after publication, with another process replacing +# our link before rollback. Identity-aware rollback must retain its directory. +$canonical = Join-Path $root '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$publishedPath = Join-Path $root 'published-link' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +New-Item -ItemType $linkType -Path $publishedPath -Target $canonical -ErrorAction Stop | Out-Null +$record = New-PublishedSkillLinkRecord -Path $publishedPath +Remove-SkillPathLexically -Path $publishedPath +New-Item -ItemType Directory -Path $publishedPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -Value 'keep' +$restored = Restore-MultiSkillSet -Published @($record) -Backups @() +if ($restored) { exit 15 } +if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } + +# Copied mono/multi publications use the same identity-protected rollback. +$copySource = Join-Path $root 'copy-source' +$copyPath = Join-Path $root 'published-copy' +New-Item -ItemType Directory -Path $copySource -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copySource 'SKILL.md') -Value 'transaction copy' +Copy-SkillPathLexically -Source $copySource -Destination $copyPath +$copyRecord = New-PublishedSkillCopyRecord -Path $copyPath -Source $copySource +Remove-SkillPathLexically -Path $copyPath +New-Item -ItemType Directory -Path $copyPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -Value 'keep' +$copyRestored = Restore-MultiSkillSet -Published @($copyRecord) -Backups @() +if ($copyRestored) { exit 17 } +if (!(Test-Path -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -PathType Leaf)) { exit 18 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell publication race must retain concurrent directory: %v\n%s", err, string(output)) + } +} + +// The ownership-marker contract (skillBackupMarkerFile / skillBackupMarkerBody, +// declared in skill_backup_prune_test.go) is shared by the Go core +// (internal/upgrade/paths.go), install.sh, both PowerShell installers, and +// build/npm/install.js: every stamp root DWS creates carries the marker, and +// pruning only deletes stamp-named directories whose marker verifies. + +// seedSkillBackupOwnershipFixture fills ~/.dws/skill-backups with five +// verified stamps (marker bytes exactly as the Go core writes them, proving +// cross-surface acceptance) plus three foreign stamp-shaped siblings: two +// without a marker and one with a wrong marker body. +func seedSkillBackupOwnershipFixture(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range []string{ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", + } { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "placeholder", "SKILL.md"), []byte(stamp+"\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, stamp, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + for _, stamp := range []string{"20260102-000001", "20260102-000002", "20260101-000000"} { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "user-data.txt"), []byte("foreign:"+stamp+"\n"), 0o644) + } + mustWriteFile(t, filepath.Join(backupRoot, "20260101-000000", skillBackupMarkerFile), []byte("dws skill backup v2\n"), 0o644) +} + +// assertSkillBackupOwnershipResult checks the post-prune contract shared by +// both PowerShell installers: the four oldest verified stamps are gone +// (three by the final prune, one by the backup's internal prune), exactly +// five verified stamps survive, every foreign stamp-shaped sibling is intact, +// and the current-run stamp carries the exact marker bytes next to the moved +// payload. +func assertSkillBackupOwnershipResult(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, pruned := range []string{"20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004"} { + if _, err := os.Lstat(filepath.Join(backupRoot, pruned)); !os.IsNotExist(err) { + t.Fatalf("verified stamp %s must be pruned, err = %v", pruned, err) + } + } + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", backupRoot, err) + } + surviving := map[string]bool{} + runStamps := []string{} + for _, entry := range entries { + surviving[entry.Name()] = true + if entry.Name() >= "20260801-000000" { + runStamps = append(runStamps, entry.Name()) + } + } + for _, want := range []string{ + "20260101-000005", + "20260101-000000", "20260102-000001", "20260102-000002", + "20260103-000001", "20260103-000002", "20260103-000003", + } { + if !surviving[want] { + t.Fatalf("stamp %s must survive pruning, surviving = %v", want, surviving) + } + } + if len(runStamps) != 1 || len(entries) != 8 { + t.Fatalf("expected the five newest verified stamps, three foreign stamps, and one run stamp, got %d entries (run stamps %v)", len(entries), runStamps) + } + runRoot := filepath.Join(backupRoot, runStamps[0]) + marker, err := os.ReadFile(filepath.Join(runRoot, skillBackupMarkerFile)) + if err != nil || string(marker) != skillBackupMarkerBody { + t.Fatalf("run stamp marker = %q, %v; want exactly %q", string(marker), err, skillBackupMarkerBody) + } + if backup := findSkillBackupContent(home, "old copy\n"); backup == "" { + t.Fatalf("backed-up Skill content missing from run stamp %s", runRoot) + } + for _, stamp := range []string{"20260101-000000", "20260102-000001", "20260102-000002"} { + data, err := os.ReadFile(filepath.Join(backupRoot, stamp, "user-data.txt")) + if err != nil || string(data) != "foreign:"+stamp+"\n" { + t.Fatalf("foreign stamp %s must be intact, got %q, %v", stamp, string(data), err) + } + } +} + +func TestInstallPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 10 } +} +# A real backup both proves the writer stamps the exact marker bytes and adds +# a current-run root that pruning must keep. +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +if (!(Backup-SkillDir -Dir $victim)) { exit 11 } +if (Test-Path -LiteralPath $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + +func TestInstallDevappPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + pwsh := "" + if candidate, err := exec.LookPath("pwsh"); err == nil { + pwsh = candidate + } else if runtime.GOOS == "windows" { + if candidate, err := exec.LookPath("powershell"); err == nil { + pwsh = candidate + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldDevSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 10 } +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +Backup-DevSkill $victim +if (Test-PathLexically $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + +// lookPowerShellForScripts prefers Windows PowerShell 5.1 (the advertised +// irm|iex surface) on Windows and falls back to pwsh everywhere. +func lookPowerShellForScripts(t *testing.T) string { + t.Helper() + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + return candidate + } + } + t.Skip("PowerShell is not available") + return "" +} + +// TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets pins +// the Blocker 2 fix: pruning a stamp root that contains a junction/symlink +// child must delete the link itself while the link target's contents survive. +// Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse, +// so the prune removes children lexically, recursing real directories only. +func TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Junctions need no privilege on Windows but silently no-op on non-Windows + // pwsh builds; symlinks need no privilege on Unix. Exit 4 marks "no link + // flavor testable on this host". + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +# The oldest of six verified stamps is prunable and holds a link child. +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-SkillBackupMarker -Root $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-Path -LiteralPath $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +if (Test-Path -LiteralPath $oldest) { exit 5 } +if (!(Test-Path -LiteralPath (Join-Path $precious 'target-data.txt') -PathType Leaf)) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + surviving = append(surviving, entry.Name()) + } + want := []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} + if len(surviving) != len(want) { + t.Fatalf("after pruning the link-bearing stamp, surviving stamps = %v, want %v", surviving, want) + } + for _, stamp := range want { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets +// is the install-devapp.ps1 twin of the link-aware prune contract above. +func TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-DevSkillBackupMarker $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-PathLexically $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +if (Test-PathLexically $oldest) { exit 5 } +if (!(Test-PathLexically (Join-Path $precious 'target-data.txt'))) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell devapp link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + for _, stamp := range []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot pins the +// collision-loop fix: a stamp root that exists without the verified +// ownership marker is foreign data, so Backup-SkillDir bumps to a -N root +// instead of writing its marker (and later prune eligibility) into it. +// Foreign roots are seeded for the current second and the next nine so the +// stamp Backup-SkillDir picks is always a pre-existing unverified root. +func TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +$now = [DateTime]::UtcNow +$seeded = @() +for ($i = 0; $i -lt 10; $i++) { + $stamp = $now.AddSeconds($i).ToString('yyyyMMdd-HHmmss') + $seeded += $stamp + $foreign = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path $foreign -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $foreign 'user-data.txt'), 'foreign') +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), 'old copy') +$backupPath = '' +if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { exit 2 } +if (!$backupPath) { exit 3 } +foreach ($stamp in $seeded) { + $foreign = Join-Path $backupRoot $stamp + if (Test-Path -LiteralPath (Join-Path $foreign '.dws-skill-backup')) { exit 4 } + $children = @(Get-ChildItem -LiteralPath $foreign -Force | ForEach-Object { $_.Name }) + if ($children.Count -ne 1 -or $children[0] -ne 'user-data.txt') { exit 5 } +} +# Every plain stamp root was foreign, so the payload must sit in a -N root. +if ($backupPath -notmatch '-[0-9]+[\\/]\.zcode-skills-dingtalk-chat$') { exit 6 } +if (!(Test-Path -LiteralPath (Join-Path $backupPath 'SKILL.md') -PathType Leaf)) { exit 7 } +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-collision.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup collision harness failed: %v\n%s", err, string(output)) + } + // The foreign same-second roots stay untouched: no marker, no payload. + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + foreignIntact, bumped := 0, "" + for _, entry := range entries { + children, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups", entry.Name())) + if err != nil { + t.Fatal(err) + } + hasPayload, hasMarker := false, false + for _, child := range children { + if child.Name() == ".dws-skill-backup" { + hasMarker = true + } + if child.Name() == ".zcode-skills-dingtalk-chat" { + hasPayload = true + } + } + if hasPayload { + bumped = entry.Name() + if !hasMarker { + t.Fatalf("backup stamp root %s must carry the ownership marker", bumped) + } + continue + } + if hasMarker { + t.Fatalf("foreign stamp root %s must never be marked DWS-owned", entry.Name()) + } + if len(children) != 1 || children[0].Name() != "user-data.txt" { + t.Fatalf("foreign stamp root %s must stay untouched, children = %v", entry.Name(), children) + } + foreignIntact++ + } + if foreignIntact != 10 || bumped == "" || !strings.Contains(bumped, "-") { + t.Fatalf("want 10 untouched foreign roots plus one -N backup root, got %d intact and backup root %q", foreignIntact, bumped) + } +} + +// TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication pins the +// path-blind retract fix: after a cross-device publish, a post-occupy +// verification failure must retract the destination only through +// Remove-VerifiedDevSkillPublication, so a concurrently replaced destination +// is restored instead of deleted. +func TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +# The first move throws the cross-device error, so the recoverable path +# stages and publishes a copy; right after that publish another process +# replaces the destination, and post-occupy verification fails. +$script:ReplacedPublished = $false +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (!$script:ReplacedPublished -and $Destination -eq $env:DWS_TEST_DESTINATION) { + $script:ReplacedPublished = $true + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Destination (Join-Path $env:DWS_TEST_HOME 'replaced-published') -ErrorAction Stop + New-Item -ItemType Directory -Path $Destination -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $Destination 'concurrent-user-data.txt') -Value 'keep' + } +} +try { + Move-DevSkillPathRecoverably $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_.Exception.Message -notmatch 'state uncertain') { Write-Error $_; exit 3 } +} +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt'))) { exit 4 } +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_SOURCE 'SKILL.md'))) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-retract-verify-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp verified retract must retain the concurrent replacement: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent replacement data = %q, %v; want it retained", got, err) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "new dev skill\n" { + t.Fatalf("source must be retained after the failed cross-device move, got %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(home, "replaced-published")); err != nil { + t.Fatalf("the replaced original copy must stay inspectable, not be deleted: %v", err) + } + for _, pattern := range []string{".dws-dev-rollback-*", ".dingtalk-misc.cross-device-*"} { + if matches, err := filepath.Glob(filepath.Join(root, "external", pattern)); err != nil || len(matches) != 0 { + t.Fatalf("devapp rollback leftovers %s = %v, %v", pattern, matches, err) + } + } +} + +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 TestInstallerStandaloneCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + if [ "$1" = "$DWS_TEST_DEST" ]; then + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" +} +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +[ -f "$DWS_TEST_DEST/concurrent-user-data.txt" ] || exit 3 +[ "$(cat "$DWS_TEST_DEST/concurrent-user-data.txt")" = "must-survive" ] || exit 4 +if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then + exit 5 +fi +[ -n "$(find "$HOME/.dws/skill-backups" -name SKILL.md -print 2>/dev/null)" ] || exit 6 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy publication race must preserve the concurrent object: %v\n%s", scriptName, err, output) + } + got, err := os.ReadFile(filepath.Join(dest, "concurrent-user-data.txt")) + if err != nil || strings.TrimSpace(string(got)) != "must-survive" { + t.Fatalf("%s concurrent dest = %q, %v", scriptName, got, err) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish", scriptName) + } + }) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), filepath.Join("..", "..", "scripts", "install-event.sh"), + filepath.Join("..", "..", "scripts", "install-devapp.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { scriptPath, err := filepath.Abs(rel) @@ -237,12 +1418,126 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { if !strings.Contains(text, "releases/download") || !strings.Contains(text, "dws-skills.zip") { t.Fatalf("%s should download dws-skills.zip from GitHub Releases", scriptPath) } + if !strings.Contains(text, "checksums.txt") || !strings.Contains(strings.ToLower(text), "sha256") { + t.Fatalf("%s should verify dws-skills.zip against release checksums", scriptPath) + } if strings.Contains(text, "archive/refs/heads/main.tar.gz") || strings.Contains(text, "archive/refs/tags/") { t.Fatalf("%s should not download skills from repository archive refs", scriptPath) } } } +func TestInstallerStandaloneCopyChildRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + // sameName: a concurrently created same-name empty child directory must + // refuse the publish (a POSIX mv would have replaced it wholesale). + // foreignName: a different-named entry landing inside the claimed dest + // mid-publish aborts the transaction with the destination retained. + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + for _, raceCase := range []string{"same-name", "foreign-name"} { + scriptName, raceCase := scriptName, raceCase + t.Run(scriptName+"/"+raceCase, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + if err := os.MkdirAll(filepath.Join(src, "aaa-subdir"), 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(src, "aaa-subdir", "inner.txt"), []byte("inner\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +case "$RACE_CASE" in + same-name) + mkdir() { + if [ "$1" = "$DWS_TEST_DEST/aaa-subdir" ]; then + # The concurrent writer takes the child path as an empty directory + # between the top-level claim and the child publish. POSIX mv would + # replace it; the no-clobber child primitive must refuse instead. + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" + } + ;; + foreign-name) + ln() { + if [ "$2" = "$DWS_TEST_DEST/SKILL.md" ]; then + command ln "$@" + # A different-named foreign entry lands inside the claim while the + # remaining staged children are still publishing. + printf '%s\n' must-survive > "$DWS_TEST_DEST/zzz-foreign" + return 0 + fi + command ln "$@" + } + ;; +esac +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +case "$RACE_CASE" in + same-name) + [ -f "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt")" = "must-survive" ] || exit 4 + [ ! -e "$DWS_TEST_DEST/aaa-subdir/inner.txt" ] || exit 5 + [ ! -e "$DWS_TEST_DEST/SKILL.md" ] || exit 6 + ;; + foreign-name) + [ -f "$DWS_TEST_DEST/zzz-foreign" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/zzz-foreign")" = "must-survive" ] || exit 4 + # The transaction's own children are retracted; only the foreign entry + # remains in the retained destination. + if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^zzz-foreign$')" ]; then + ls -A "$DWS_TEST_DEST" >&2 + exit 5 + fi + ;; +esac +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "copy-child-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + "RACE_CASE="+raceCase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy child race (%s) must preserve the concurrent object: %v\n%s", scriptName, raceCase, err, string(output)) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish (%s)", scriptName, raceCase) + } + }) + } + } +} + func TestInstallEventScriptStaticExpectations(t *testing.T) { t.Parallel() @@ -319,6 +1614,7 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", "SKILL.md": "legacy mono root\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, dir := range []string{ @@ -381,17 +1677,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { } expectedSkills := map[string]string{ - ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", - ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", - ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", + ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for _, duplicate := range []string{ + filepath.Join(fakeHome, ".codex", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".config", "opencode", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".codex", "skills", "dws"), + } { + if _, err := os.Lstat(duplicate); !os.IsNotExist(err) { + t.Fatalf("universal Agent duplicate remains at %s: %v", duplicate, err) + } } for rel, marker := range expectedSkills { p := filepath.Join(fakeHome, filepath.FromSlash(rel)) @@ -410,6 +1712,15 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { if err != nil || string(sibling) != "keep sibling\n" { t.Fatalf("unrelated sibling changed: data=%q err=%v", sibling, err) } + // The replaced standalone Event skill must sit in a marker-stamped backup + // root: that marker is what lets a later prune count and recycle it. The + // event installer archives under the flattened source path, so locate the + // backup by content and the stamp root two levels above it. + if got := findSkillBackupContent(fakeHome, "old standalone event\n"); got == "" { + t.Fatalf("replaced standalone Event skill should be backed up under .dws/skill-backups") + } else { + assertSkillBackupMarker(t, filepath.Dir(filepath.Dir(got))) + } if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-dev")); !os.IsNotExist(err) { t.Fatalf("dingtalk-dev should not be installed by install-event.sh, stat err=%v", err) } @@ -433,6 +1744,7 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) @@ -478,6 +1790,7 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { "multi/dingtalk-shared/SKILL.md": "new shared\n", "mono/SKILL.md": "new mono\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") @@ -512,6 +1825,46 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { } } +func TestInstallEventChecksumMismatchPreservesExistingSkills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + root := t.TempDir() + home := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "multi/dingtalk-misc/SKILL.md": "new misc\n", + "mono/SKILL.md": "new mono\n", + }) + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Repeat("0", 64)+" dws-skills.zip\n"), 0o644) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + existing := filepath.Join(home, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, runErr := cmd.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum mismatch") { + t.Fatalf("checksum mismatch result = %v\n%s", runErr, output) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "old event stays\n" { + t.Fatalf("existing Skill after checksum mismatch = %q, %v", got, err) + } +} + func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -534,6 +1887,7 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) @@ -590,6 +1944,7 @@ func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") @@ -930,12 +2285,17 @@ func TestInstallScriptSourceModeDefaultMultiInstall(t *testing.T) { } } // Removals must be reversible: the displaced dirs land under - // ~/.dws/skill-backups/ instead of being destroyed. - if findSkillBackup(fixture.fakeHome, "dws", "old mono\n") == "" { + // ~/.dws/skill-backups/ instead of being destroyed, each inside a stamp + // root stamped with the ownership marker. + if got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n"); got == "" { t.Errorf("old mono dws/ should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } - if findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n") == "" { + if got := findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n"); got == "" { t.Errorf("stale dingtalk-* should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil { t.Errorf("non-DWS skill must be preserved: %v", err) @@ -1067,6 +2427,21 @@ func TestInstallScriptSourceModeMonoMultiMonoExclusion(t *testing.T) { // directory named name whose SKILL.md equals content, returning its path // ("" when absent). The layout is // with // optional -N collision suffixes on the stamp. +func findSkillBackupContent(fakeHome, content string) string { + root := filepath.Join(fakeHome, ".dws", "skill-backups") + var found string + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || found != "" || info.IsDir() || info.Name() != "SKILL.md" { + return nil + } + if data, err := os.ReadFile(p); err == nil && string(data) == content { + found = p + } + return nil + }) + return found +} + func findSkillBackup(fakeHome, name, content string) string { root := filepath.Join(fakeHome, ".dws", "skill-backups") var found string @@ -1082,6 +2457,73 @@ func findSkillBackup(fakeHome, name, content string) string { return found } +// assertSkillBackupMarker pins the ownership contract shared with the Go core: +// every stamp root a shell installer creates carries .dws-skill-backup with +// exactly "dws skill backup v1\n", because pruning only ever removes +// stamp-shaped roots with those exact bytes. +func assertSkillBackupMarker(t *testing.T, stampRoot string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(stampRoot, skillBackupMarkerFile)) + if err != nil { + t.Fatalf("backup ownership marker missing in %s: %v", stampRoot, err) + } + if string(data) != skillBackupMarkerBody { + t.Fatalf("backup ownership marker in %s = %q, want %q", stampRoot, data, skillBackupMarkerBody) + } +} + +// TestInstallScriptPrunePreservesUnmarkedStampDirs runs the real install.sh +// (whose main ends with prune_skill_backups) against a HOME whose backup root +// already holds a full marked history plus one unmarked stamp-shaped +// directory. The end-of-run prune must remove the marked excess while the +// unmarked directory — foreign data without the ownership marker — is neither +// deleted nor counted toward the keep limit, and the stamp this run creates +// must carry the exact marker bytes. +func TestInstallScriptPrunePreservesUnmarkedStampDirs(t *testing.T) { + t.Parallel() + + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") + backupRoot := filepath.Join(fixture.fakeHome, ".dws", "skill-backups") + + // Oldest on purpose: without the marker check this directory would be the + // first pruning candidate and would inflate the excess count. + unmarked := filepath.Join(backupRoot, "20200101-000000") + mustWriteFile(t, filepath.Join(unmarked, "user-data.txt"), []byte("foreign\n"), 0o644) + for i := 1; i <= 6; i++ { + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), "skill", "SKILL.md"), []byte("old batch\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + seedAgentHome(t, fixture.fakeHome, "dws", "old mono\n") + + runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode("mono", + "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=0", + )) + + // Six marked batches plus this run's one exceed the keep limit of five, + // so exactly the two oldest marked batches are excess and must be gone — + // proving the prune actually ran. + for _, gone := range []string{oldStampName(1), oldStampName(2)} { + if _, err := os.Stat(filepath.Join(backupRoot, gone)); !os.IsNotExist(err) { + t.Fatalf("marked excess stamp %s should have been pruned, stat err=%v", gone, err) + } + } + if _, err := os.Stat(filepath.Join(backupRoot, oldStampName(3))); err != nil { + t.Fatalf("marked keep-range stamp should survive the prune: %v", err) + } + // The unmarked directory is never counted and never removed, even though + // it sorts before every pruned batch. + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "foreign\n" { + t.Fatalf("unmarked stamp-shaped directory must be preserved untouched: data=%q err=%v", data, err) + } + got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n") + if got == "" { + t.Fatal("this run's dws backup is missing after the prune") + } + assertSkillBackupMarker(t, filepath.Dir(got)) +} + // TestInstallScriptBackupFailureKeepsOriginalDir pins the fail-safe contract: // when the backup destination cannot be created (skill-backups pre-created as // a regular file), the installer must keep every pre-existing skill directory @@ -1189,6 +2631,82 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" } } +func TestInstallSkillsShellMvCrossFilesystemBackupAndRestore(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real cross-filesystem mv fixture uses Linux /dev/shm") + } + sharedMemoryRoot, err := os.MkdirTemp("/dev/shm", "dws-shell-exdev-") + if err != nil { + t.Skipf("/dev/shm is unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sharedMemoryRoot) }) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.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-skills.sh final main invocation not found") + } + library := filepath.Join(t.TempDir(), "install-skills-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + source := filepath.Join(t.TempDir(), "dingtalk-chat") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + probe := filepath.Join(sharedMemoryRoot, "rename-probe") + if err := os.Rename(source, probe); err == nil { + if restoreErr := os.Rename(probe, source); restoreErr != nil { + t.Fatal(restoreErr) + } + t.Skip("/dev/shm and the test temp directory share a filesystem") + } else if !strings.Contains(strings.ToLower(err.Error()), "cross-device") { + t.Skipf("could not establish a cross-filesystem fixture: %v", err) + } + + home := filepath.Join(sharedMemoryRoot, "home") + manifest := filepath.Join(sharedMemoryRoot, "backups.manifest") + harness := `. "$DWS_TEST_LIBRARY" +backup_and_remove_skill_dir "$DWS_TEST_SOURCE" +backup=$DWS_LAST_SKILL_BACKUP +[ -n "$backup" ] && [ -d "$backup" ] && [ ! -e "$DWS_TEST_SOURCE" ] +printf '%s\n%s\n' "$DWS_TEST_SOURCE" "$backup" > "$DWS_TEST_MANIFEST" +restore_multi_skill_set /dev/null "$DWS_TEST_MANIFEST" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_MANIFEST="+manifest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-filesystem shell backup/restore failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored shell content = %q, %v", got, err) + } + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(source, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("shell %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(source, name)); err != nil || got != want { + t.Fatalf("shell %s target = %q, %v; want %q", name, got, err, want) + } + } +} + func TestInstallSkillsShellPreservesUnregisteredDingtalkSkill(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) if err != nil { @@ -1242,7 +2760,7 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh") } -func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1277,11 +2795,11 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } for _, duplicate := range []string{ - filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"), + filepath.Join(home, ".codex", "skills", "dingtalk-chat"), filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -1292,7 +2810,7 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1328,7 +2846,13 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err) + t.Fatalf("%s linked ZCode Skill missing: %v", scriptName, err) + } + if info, err := os.Lstat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s ZCode target is not a canonical link: %#v, %v", scriptName, info, err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("%s generic duplicate remains: %v", scriptName, err) @@ -1337,7 +2861,591 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + bundle := filepath.Join(t.TempDir(), "multi") + 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 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" +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 ln "$@" +} +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then + exit 2 +fi +identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" +identity_manifest="$HOME/identity.manifest" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" +printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" +rm -f "$identity_dest" +command ln -s ../same-target "$identity_dest" +if restore_linked_skill_set "$identity_manifest" /dev/null; then + exit 3 +fi +rm -f "$identity_anchor" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_BUNDLE="+bundle, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not report identity-protected rollback:\n%s", scriptName, output) + } + identityDest := filepath.Join(base, "identity-link") + if target, err := os.Readlink(identityDest); err != nil || target != "../same-target" { + t.Fatalf("%s replacement link was removed: target=%q, err=%v", scriptName, target, err) + } + for path, want := range map[string]string{ + filepath.Join(first, "user.txt"): "first-user-data\n", + filepath.Join(second, "user.txt"): "second-user-data\n", + } { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s concurrent data at %s = %q, err=%v", scriptName, path, got, err) + } + } + for _, dir := range []string{first, second} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("%s read concurrent directory %s: %v", scriptName, dir, err) + } + if len(entries) != 1 || entries[0].Name() != "user.txt" { + t.Fatalf("%s transaction artifacts remain in %s: %v", scriptName, dir, entries) + } + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-link-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +func TestInstallerShellCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inject the race inside the second publication's child move: the + // first skill has already been published and recorded, so this + // simulates a concurrent writer replacing it and the transaction + // failing afterwards — rollback must verify identity and retain + // the foreign object instead of blind-deleting the path. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + rm -rf "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/concurrent-user-data.txt" + return 1 + fi + ;; + esac + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s copy race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip identity-mismatched rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "concurrent-user-data.txt")) + if err != nil || string(got) != "first-user-data\n" { + t.Fatalf("%s concurrent directory was deleted or modified: %q, %v", scriptName, string(got), err) + } + entries, err := os.ReadDir(first) + if err != nil || len(entries) != 1 || entries[0].Name() != "concurrent-user-data.txt" { + t.Fatalf("%s foreign directory contents changed: %v, %v", scriptName, entries, err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +// TestInstallerShellRollbackPreservesInPlaceEditedSkill pins the P1 contract: +// a published Skill directory whose file contents were edited in place (same +// directory inode, same child names) must not be deleted by the rollback of a +// later failed publication. The recorded identity carries a recursive content +// digest, so the edit makes the dest provably not-this-transaction's object. +func TestInstallerShellRollbackPreservesInPlaceEditedSkill(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "references", "guide.md"), []byte("guide\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inside the second publication's child move — after the first + // skill has been published and its identity recorded — edit a file + // inside the first destination in place (append keeps the inode, + // directory inode and child names are untouched), then fail the + // transaction. Rollback must detect the content drift through the + // recorded digest and leave the user's edit alone. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + printf 'user-edit\n' >> "$DWS_TEST_FIRST/references/guide.md" + return 1 + fi + ;; + esac + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s in-place edit harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip in-place-edited rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "references", "guide.md")) + if err != nil || string(got) != "guide\nuser-edit\n" { + t.Fatalf("%s in-place user edit was lost: %q, %v", scriptName, string(got), err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +// TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling pins +// the review blocker on the shared stamp root: the backup collision loop +// tests the payload path, so two backups in the same stamp second land in +// ONE stamp root. When the second call fails to write the ownership marker, +// the old `rm -rf` cleanup destroyed the first, already completed sibling +// payload whose original directory had already been moved away. The cleanup +// must be non-recursive and may only drop a stamp root the failing call +// itself created (marker file plus empty root; rmdir refuses non-empty). +func TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + // Both scenarios rely on permission-bit enforcement (unwritable marker + // file, unwritable fresh root), which root bypasses entirely. + if os.Geteuid() == 0 { + t.Skip("running as root ignores permission bits, the marker write would succeed") + } + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + backupFn := "backup_and_remove_skill_dir" + emitsMarkerWarning := true + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + backupFn = "backup_skill_dir" + emitsMarkerWarning = false + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + // Pin the stamp: a stub `date` always prints the same UTC second, + // so consecutive backups deterministically share one stamp root. + const stamp = "20260818-120000" + stubDir := filepath.Join(t.TempDir(), "bin") + mustWriteFile(t, filepath.Join(stubDir, "date"), []byte("#!/bin/sh\nprintf '"+stamp+"\\n'\n"), 0o755) + + t.Run("same-stamp sibling payload survives marker write failure", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victimA := filepath.Join(home, ".agents", "skills", "skill-a") + victimB := filepath.Join(home, ".agents", "skills", "skill-b") + mustWriteFile(t, filepath.Join(victimA, "SKILL.md"), []byte("payload A\n"), 0o644) + mustWriteFile(t, filepath.Join(victimB, "SKILL.md"), []byte("victim B\n"), 0o644) + + // The first backup succeeds and moves victim A into the stamp + // root; the marker is then made unwritable so the second, + // same-second backup fails exactly at the marker write. + harness := `. "$DWS_TEST_LIBRARY" +if ` + backupFn + ` "$DWS_TEST_VICTIM_A"; then :; else exit 10; fi +chmod 000 "$DWS_TEST_ROOT/` + skillBackupMarkerFile + `" +if ` + backupFn + ` "$DWS_TEST_VICTIM_B"; then + exit 11 +fi +[ -d "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM_B/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM_A="+victimA, + "DWS_TEST_VICTIM_B="+victimB, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s sibling marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if emitsMarkerWarning && !strings.Contains(string(output), "无法写入备份所有权标记") { + t.Fatalf("%s did not report the marker write failure:\n%s", scriptName, output) + } + if _, err := os.Lstat(victimA); !os.IsNotExist(err) { + t.Fatalf("%s first victim must have been moved into the stamp root: %v", scriptName, err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("%s stamp root must survive the failed marker write: %v", scriptName, err) + } + var payloadDirs []string + for _, entry := range entries { + if entry.Name() == skillBackupMarkerFile { + continue + } + if !entry.IsDir() { + t.Fatalf("%s stamp root carries an unexpected non-marker entry %s", scriptName, entry.Name()) + } + payloadDirs = append(payloadDirs, entry.Name()) + } + if len(payloadDirs) != 1 { + t.Fatalf("%s stamp root must retain exactly the first payload, got %v", scriptName, payloadDirs) + } + got, err := os.ReadFile(filepath.Join(root, payloadDirs[0], "SKILL.md")) + if err != nil || string(got) != "payload A\n" { + t.Fatalf("%s completed sibling payload was destroyed: %q, %v", scriptName, got, err) + } + if got, err := os.ReadFile(filepath.Join(victimB, "SKILL.md")); err != nil || string(got) != "victim B\n" { + t.Fatalf("%s second victim must stay at its original path: %q, %v", scriptName, got, err) + } + }) + + t.Run("fresh stamp root is dropped non-recursively", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victim := filepath.Join(home, ".agents", "skills", "skill-fresh") + mustWriteFile(t, filepath.Join(victim, "SKILL.md"), []byte("fresh victim\n"), 0o644) + + // The failing call itself created the root, so the cleanup + // must drop it again: mkdir is intercepted to hand back a + // non-writable root, failing the marker write while the root + // stays removable through its writable parent directory. + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + command mkdir "$@" + _mk_rc=$? + if [ "${1:-}" = "-p" ] && [ "${2:-}" = "$DWS_TEST_ROOT" ]; then + chmod 0555 "$DWS_TEST_ROOT" + fi + return "$_mk_rc" +} +if ` + backupFn + ` "$DWS_TEST_VICTIM"; then + exit 11 +fi +[ ! -e "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM="+victim, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s fresh marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("%s fresh stamp root must be removed again: %v", scriptName, err) + } + if got, err := os.ReadFile(filepath.Join(victim, "SKILL.md")); err != nil || string(got) != "fresh victim\n" { + t.Fatalf("%s victim must stay untouched at its original path: %q, %v", scriptName, got, err) + } + }) + }) + } +} + +func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + tmp := t.TempDir() + xdg := filepath.Join(tmp, "xdg config") + autohand := filepath.Join(tmp, "autohand home") + codex := filepath.Join(tmp, "codex home") + source := filepath.Join(tmp, "multi") + mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "goose", "config.yaml"), []byte("enabled: true\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("old amp copy\n"), 0o644) + mustWriteFile(t, filepath.Join(autohand, "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "config.toml"), []byte("model=test\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "skills", "dingtalk-chat", "SKILL.md"), []byte("old codex copy\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".qoderwork", "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), []byte("old DWS path\n"), 0o644) + + installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"` + if scriptName == "install-skills.sh" { + installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"` + } + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +`+installCall+` +`) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "XDG_CONFIG_HOME="+xdg, + "AUTOHAND_HOME="+autohand, + "CODEX_HOME="+codex, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s upstream-root harness failed: %v\n%s", scriptName, err, output) + } + + for _, linked := range []string{ + filepath.Join(xdg, "goose", "skills", "dingtalk-chat"), + filepath.Join(autohand, "skills", "dingtalk-chat"), + filepath.Join(home, ".qoderwork", "skills", "dingtalk-chat"), + } { + info, err := os.Lstat(linked) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a canonical symlink: %#v, %v", linked, info, err) + } + target, err := os.Readlink(linked) + if err != nil || filepath.IsAbs(target) { + t.Fatalf("%s link must be relative: target=%q err=%v", linked, target, err) + } + } + for _, retired := range []string{ + filepath.Join(xdg, "agents", "skills", "dingtalk-chat"), + filepath.Join(codex, "skills", "dingtalk-chat"), + filepath.Join(home, ".amp", "skills", "dingtalk-chat"), + } { + if _, err := os.Lstat(retired); !os.IsNotExist(err) { + t.Fatalf("%s universal/legacy duplicate remains: %v", retired, err) + } + } + }) + } +} + +func TestInstallerShellPinsCompleteUpstreamAgentRegistry(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +upstream_agent_registry +`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s registry failed: %v\n%s", scriptName, err, output) + } + lines := strings.Fields(strings.TrimSpace(string(output))) + if len(lines) != 76 { + t.Fatalf("%s registry has %d Agents, want 76", scriptName, len(lines)) + } + ids := map[string]bool{} + roots := map[string]bool{} + universal, nonUniversal, noGlobal, canonicalDirect := 0, 0, 0, 0 + for _, line := range lines { + parts := strings.Split(line, "|") + if len(parts) != 3 || ids[parts[0]] { + t.Fatalf("%s invalid or duplicate registry record %q", scriptName, line) + } + ids[parts[0]] = true + switch parts[1] { + case "U": + universal++ + case "N": + nonUniversal++ + default: + t.Fatalf("%s invalid classification in %q", scriptName, line) + } + switch parts[2] { + case "-": + noGlobal++ + case ".agents/skills": + canonicalDirect++ + default: + roots[parts[2]] = true + } + } + if universal != 19 || nonUniversal != 57 || noGlobal != 2 || canonicalDirect != 6 || len(roots) != 65 { + t.Fatalf("%s registry classification U=%d N=%d no-global=%d canonical=%d roots=%d", scriptName, universal, nonUniversal, noGlobal, canonicalDirect, len(roots)) + } + }) + } +} + +func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1378,15 +3486,18 @@ exit 0 if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical Codex Skill missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) { + t.Fatalf("PowerShell Codex duplicate remains: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) } } -func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellLinksZCodeRootToCanonical(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1428,7 +3539,10 @@ exit 0 t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err) + t.Fatalf("PowerShell linked ZCode Skill missing: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) @@ -1787,7 +3901,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-multi-set.*/dingtalk-second) return 1 ;; + */.dws-multi-set.*/dingtalk-second/*) return 1 ;; esac fi command mv "$@" @@ -1921,7 +4035,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-mono-set.*/dws) return 1 ;; + */.dws-mono-set.*/dws/*) return 1 ;; esac fi command mv "$@" @@ -2037,6 +4151,371 @@ exit 0 } } +func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + 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.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + + for _, phase := range []string{"success", "copy", "verify", "remove", "permission", "dest-verify"} { + phase := phase + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "skill") + destination := filepath.Join(home, ".dws", "skill-backups", "stamp", "skill") + restored := filepath.Join(root, "external", "restored-skill") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + linksCreated := false + if phase == "success" { + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + } + + harness := prefix + ` +$script:OriginalCopySkillPathLexically = ${function:Copy-SkillPathLexically} +$script:OriginalAssertSkillPathCopy = ${function:Assert-SkillPathCopy} +$script:OriginalRemoveSkillPathLexically = ${function:Remove-SkillPathLexically} +function New-CrossDeviceError { return [System.IO.IOException]::new("injected cross-device rename", -2147024879) } +function Move-SkillPath { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "permission" -and $Source -eq $env:DWS_TEST_SOURCE) { + throw [System.UnauthorizedAccessException]::new("permission denied") + } + if (($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) -or + ($Source -eq $env:DWS_TEST_DESTINATION -and $Destination -eq $env:DWS_TEST_RESTORED)) { + throw (New-CrossDeviceError) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "copy") { throw "copy failed" } + if ($env:DWS_TEST_PHASE -eq "permission") { + [System.IO.File]::WriteAllText($env:DWS_TEST_COPY_MARKER, "called") + } + & $script:OriginalCopySkillPathLexically -Source $Source -Destination $Destination +} +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $Destination -eq $env:DWS_TEST_DESTINATION) { throw "dest-verify failed" } + & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination +} +function Remove-SkillPathLexically { + param([string]$Path) + if ($env:DWS_TEST_PHASE -eq "remove" -and $Path -eq $env:DWS_TEST_SOURCE) { throw "remove failed" } + & $script:OriginalRemoveSkillPathLexically -Path $Path +} +try { + Move-SkillPathRecoverably -Source $env:DWS_TEST_SOURCE -Destination $env:DWS_TEST_DESTINATION + if ($env:DWS_TEST_PHASE -ne "success") { exit 2 } + Move-SkillPathRecoverably -Source $env:DWS_TEST_DESTINATION -Destination $env:DWS_TEST_RESTORED + exit 0 +} catch { + if ($env:DWS_TEST_PHASE -eq "success") { Write-Error $_; exit 3 } + if ($env:DWS_TEST_PHASE -eq "copy" -and $_ -notmatch "copy failed") { Write-Error $_; exit 4 } + if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } + if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } + if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $_ -notmatch "目标已撤回|dest-verify failed|状态不确定") { Write-Error $_; exit 8 } + exit 0 +} +` + harnessPath := filepath.Join(t.TempDir(), "install-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + copyMarker := filepath.Join(root, "copy-called") + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + "DWS_TEST_RESTORED="+restored, + "DWS_TEST_COPY_MARKER="+copyMarker, + "DWS_TEST_PHASE="+phase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell %s EXDEV harness failed: %v\n%s", phase, err, output) + } + + if phase == "success" { + if got, err := os.ReadFile(filepath.Join(restored, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed by rollback: %v", err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(restored, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + return + } + + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("source after %s failure = %q, %v", phase, got, err) + } + if phase == "remove" { + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("published backup after remove failure = %q, %v", got, err) + } + } else if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination after %s failure = %v", phase, err) + } + if phase == "permission" { + if _, err := os.Stat(copyMarker); !os.IsNotExist(err) { + t.Fatalf("permission error must not trigger copy fallback: %v", err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".skill.cross-device-*")); err != nil || len(matches) != 0 { + t.Fatalf("PowerShell %s staging leftovers = %v, %v", phase, matches, err) + } + }) + } +} + +func TestInstallDevappPowerShellCrossDeviceBackup(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old dev skill\n"), 0o640) + linksCreated := false + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +Backup-DevSkill $env:DWS_TEST_SOURCE +if (Test-PathLexically $env:DWS_TEST_SOURCE) { exit 2 } +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_SOURCE="+source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp EXDEV backup failed: %v\n%s", err, output) + } + backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n") + if backup == "" { + t.Fatal("PowerShell devapp backup missing") + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(backup, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("devapp %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(backup, name)); err != nil || got != want { + t.Fatalf("devapp %s target = %q, %v; want %q", name, got, err, want) + } + } + } +} + +func TestInstallDevappPowerShellPublishFailureRestoresAcrossDevice(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -match "[\\/].dws-dev-copy-[^\\/]+[\\/]payload$") { throw "injected publish failure" } + if ($Source -match "[\\/]skill-backups[\\/]" -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rollback", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_ -notmatch "injected publish failure") { Write-Error $_; exit 3 } +} +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publish-rollback-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publish rollback failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old dev skill\n" { + t.Fatalf("restored devapp content = %q, %v", got, err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".dws-dev-copy-*")); err != nil || len(matches) != 0 { + t.Fatalf("devapp publish staging leftovers = %v, %v", matches, err) + } +} + +func TestInstallDevappPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$script:OriginalBackupDevSkill = ${function:Backup-DevSkill} +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + & $script:OriginalBackupDevSkill $path $BackupPath + # Simulate another installer recreating the destination after the old + # content was backed up but before this transaction publishes its stage. + New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $path 'concurrent-user-data.txt') -Value 'keep' +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if (!$_.Exception.Data['DWSRollbackFailed']) { Write-Error $_; exit 3 } +} +if (!(Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt') -PathType Leaf)) { exit 4 } +if (Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'payload')) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publication race must retain concurrent directory: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent devapp data = %q, %v", got, err) + } + if backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n"); backup == "" { + t.Fatal("original devapp backup must be retained after publication race") + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { @@ -2257,12 +4736,34 @@ done case "$url" in *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *"/checksums.txt") cp "$FAKE_RELEASE_DIR/checksums.txt" "$out" ;; *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; esac ` mustWriteFile(t, path, []byte(script), 0o755) } +func writeInstallerFixtureChecksums(t *testing.T, releaseDir string) { + t.Helper() + entries, err := os.ReadDir(releaseDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", releaseDir, err) + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "checksums.txt" { + continue + } + data, err := os.ReadFile(filepath.Join(releaseDir, entry.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", entry.Name(), err) + } + digest := sha256.Sum256(data) + lines = append(lines, hex.EncodeToString(digest[:])+" "+entry.Name()) + } + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + func writeFakeGH(t *testing.T, path, version string) { t.Helper() script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index fd3746ba..46155da0 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{ ".augment/skills/dingtalk-shared", ".cline/skills/dingtalk-shared", ".amp/skills/dingtalk-shared", + ".copilot/skills/dingtalk-shared", + ".config/opencode/skills/dingtalk-shared", + ".config/agents/skills/dingtalk-shared", + ".codeium/windsurf/skills/dingtalk-shared", ".kiro/skills/dingtalk-shared", ".trae/skills/dingtalk-shared", ".openclaw/skills/dingtalk-shared", @@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") } for _, want := range []string{ - "HOME_SPECIFIC_SKILL_BASES=", - `$base/dingtalk-shared/SKILL.md`, - `$base/dingtalk-misc/SKILL.md`, + "UPSTREAM_AGENT_REGISTRY=", + "verify_compatible_skill_base", + `diff -qr "$canonical/$name" "$target"`, + "broken canonical Skill link", + "Skill link does not resolve to canonical source", + "Skill copy fallback differs from canonical source", "unexpected mono Skill layout", `verify_npm_install "$tarball_path" "specific-agent-roots"`, `verify_npm_install "$tarball_path" "generic-fallback"`, @@ -128,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } verifyCmd := exec.Command("sh", verifierPath, "--npm-only") - verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + hostXDG := filepath.Join(t.TempDir(), "host-xdg") + verifyCmd.Env = replaceTestEnv(os.Environ(), + "DWS_PACKAGE_DIST_DIR", distDir, + "XDG_CONFIG_HOME", hostXDG, + ) output, err := verifyCmd.CombinedOutput() if err != nil { t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) } + if _, err := os.Stat(hostXDG); !os.IsNotExist(err) { + t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err) + } for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output) @@ -140,6 +154,102 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } } +func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + text := string(data) + cut := strings.Index(text, "\nverify_npm_install()") + if cut < 0 { + t.Fatal("verify-package-managers.sh helper boundary missing") + } + library := filepath.Join(t.TempDir(), "verifier-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + for _, tc := range []struct { + name string + prepare func(t *testing.T, home, base string) + wantOK bool + }{ + { + name: "correct-link", + prepare: func(t *testing.T, _, base string) { + t.Helper() + // The canonical layout publishes RELATIVE links; the verifier now + // rejects absolute targets, so the fixture must match production. + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: true, + }, + { + name: "complete-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + }, + wantOK: true, + }, + { + name: "wrong-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + wrong := filepath.Join(home, "wrong", "dingtalk-shared") + mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "broken-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "different-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644) + }, + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".claude", "skills") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + tc.prepare(t, home, base) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base) + output, err := cmd.CombinedOutput() + if tc.wantOK && err != nil { + t.Fatalf("verifier rejected valid target: %v\n%s", err, output) + } + if !tc.wantOK && err == nil { + t.Fatalf("verifier accepted invalid target\n%s", output) + } + }) + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go new file mode 100644 index 00000000..75953d3d --- /dev/null +++ b/test/scripts/skill_backup_prune_test.go @@ -0,0 +1,320 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// shellPruneScriptTargets are the standalone installers that embed their own +// copy of prune_skill_backups. Each must keep every backup stamp the running +// process created, mirroring Go's run-root registry and install.js's +// currentRunBackupRoots, so a migration retiring more than the keep limit +// stays reversible. +var shellPruneScriptTargets = []string{ + "install.sh", + "install-skills.sh", + "install-event.sh", + "install-devapp.sh", +} + +// The ownership-marker contract shared with the Go core +// (internal/upgrade/paths.go): every stamp root DWS creates carries the +// marker, and prune only counts or removes roots with these exact bytes. +const ( + skillBackupMarkerFile = ".dws-skill-backup" + skillBackupMarkerBody = "dws skill backup v1\n" +) + +// extractShellFunction pulls a top-level `name() { ... }` definition out of a +// script so the shipped logic — not a copy — is what the test exercises. +func extractShellFunction(t *testing.T, scriptPath, name string) string { + t.Helper() + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", scriptPath, err) + } + text := string(data) + start := strings.Index(text, "\n"+name+"() {") + if start < 0 { + t.Fatalf("function %s not found in %s", name, scriptPath) + } + start++ + end := strings.Index(text[start:], "\n}\n") + if end < 0 { + t.Fatalf("function %s has no top-level closing brace in %s", name, scriptPath) + } + return text[start : start+end+3] +} + +// mkdirStampWithMarker creates a DWS-owned stamp fixture: the stamp directory +// plus the exact ownership marker bytes prune verifies. Without the marker a +// stamp-shaped directory is foreign data that prune must neither count nor +// remove. +func mkdirStampWithMarker(t *testing.T, backupRoot, stamp string) { + t.Helper() + dir := filepath.Join(backupRoot, stamp) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(dir, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) +} + +// runShellPruneScenario builds a sandbox HOME with the given pre-existing +// (earlier-run) stamps, registers the given stamps as created by the current +// run, optionally also plants stamp-shaped directories without the ownership +// marker, runs the script's own prune_skill_backups, and returns the +// surviving stamp directories. +func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string, unmarkedStamps ...string) []string { + t.Helper() + home := t.TempDir() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range foreignStamps { + mkdirStampWithMarker(t, backupRoot, stamp) + } + for _, stamp := range unmarkedStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp, "payload"), 0o755); err != nil { + t.Fatal(err) + } + } + + var driver strings.Builder + driver.WriteString("#!/bin/sh\n") + driver.WriteString("say() { :; }\n") + // The keep limit is a script-level variable outside the extracted + // functions; set both spellings the four installers use. + driver.WriteString("DWS_SKILL_BACKUP_KEEP=5\n") + driver.WriteString("SKILL_BACKUP_KEEP=5\n") + driver.WriteString(extractShellFunction(t, scriptPath, "is_skill_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "record_current_run_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups")) + driver.WriteString("\n") + for _, stamp := range runStamps { + mkdirStampWithMarker(t, backupRoot, stamp) + driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n") + } + driver.WriteString("prune_skill_backups\n") + + driverPath := filepath.Join(t.TempDir(), "prune-driver.sh") + if err := os.WriteFile(driverPath, []byte(driver.String()), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", driverPath) + cmd.Env = append(os.Environ(), "HOME="+home) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prune driver failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + if entry.IsDir() { + surviving = append(surviving, entry.Name()) + } + } + return surviving +} + +func assertStampsSurvive(t *testing.T, surviving []string, want ...string) { + t.Helper() + left := make(map[string]bool) + for _, stamp := range surviving { + left[stamp] = true + } + for _, stamp := range want { + if !left[stamp] { + t.Fatalf("stamp %s must survive pruning, surviving=%v", stamp, surviving) + } + delete(left, stamp) + } + for stamp := range left { + t.Fatalf("stamp %s must have been pruned, surviving=%v", stamp, surviving) + } +} + +// TestInstallShellPruneKeepsCurrentRunBackups pins the reversibility contract +// the changelog states for every install surface: pruning may only remove +// batches from earlier runs. The old shell logic deleted the oldest excess +// stamps regardless of origin, so a single migration retiring more than five +// batches silently destroyed its own rollback material. +func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("shell installer tests do not run on %s", runtime.GOOS) + } + for _, name := range shellPruneScriptTargets { + t.Run(name, func(t *testing.T) { + scriptPath := filepath.Join("..", "..", "scripts", name) + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("stat %s: %v", scriptPath, err) + } + + t.Run("run that exceeds the keep limit loses nothing", func(t *testing.T) { + var runStamps []string + for i := 1; i <= 8; i++ { + runStamps = append(runStamps, stampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, nil, runStamps) + assertStampsSurvive(t, surviving, runStamps...) + }) + + t.Run("only earlier-run batches are pruned", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 6; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + runStamps := []string{stampName(1), stampName(2)} + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, runStamps) + // Total 8, keep 5: three oldest foreign batches go, the + // current run keeps both of its own. + assertStampsSurvive(t, surviving, + oldStampName(4), oldStampName(5), oldStampName(6), + stampName(1), stampName(2)) + }) + + t.Run("foreign-only history still prunes to the keep limit", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil) + assertStampsSurvive(t, surviving, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7)) + }) + + // A stamp-shaped directory without the ownership marker is foreign + // data: it must be preserved untouched, never counted toward the + // keep limit, and never treated as prunable excess. Oldest on + // purpose — if the marker were not required, this directory would + // be the first pruning candidate. + t.Run("unmarked stamp-shaped directory is preserved and uncounted", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + unmarked := "20200101-000000" + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil, unmarked) + assertStampsSurvive(t, surviving, + append([]string{unmarked}, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7))...) + }) + }) + } +} + +func stampName(i int) string { + return "20260817-0000" + twoDigits(i) +} + +func oldStampName(i int) string { + return "20200101-0000" + twoDigits(i) +} + +func twoDigits(i int) string { + if i < 10 { + return "0" + string(rune('0'+i)) + } + return string(rune('0'+i/10)) + string(rune('0'+i%10)) +} + +// TestInstallShellBackupNeverAdoptsUnprovenStampRoots pins the ownership +// contract at creation time: a stamp-shaped directory whose marker does not +// verify must never receive the DWS marker or a payload — the backup moves +// to a fresh suffixed root instead, and the foreign data survives untouched. +func TestInstallShellBackupNeverAdoptsUnprovenStampRoots(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + 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.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + // install.sh and install-skills.sh name the payload by basename; + // the standalone event/devapp publishers mangle the full path. + payloadName := "victim" + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + // Mirror the scripts' own mangling: full path, slashes and + // backslashes to dashes, leading dash dropped. + payloadName = strings.NewReplacer("/", "-", "\\", "-").Replace(filepath.Join(home, "victim")) + payloadName = strings.TrimPrefix(payloadName, "-") + } + harness := `. "$DWS_TEST_LIBRARY" +date() { printf '%s\n' 20260101-120000; } +ROOT="$HOME/.dws/skill-backups" +mkdir -p "$ROOT/20260101-120000" || exit 9 +printf 'must-survive\n' > "$ROOT/20260101-120000/user-data.txt" || exit 9 +mkdir -p "$HOME/victim" || exit 9 +printf 'payload\n' > "$HOME/victim/SKILL.md" || exit 9 +if ! backup_fn "$HOME/victim" >/dev/null 2>&1; then + if [ -e "$HOME/victim/SKILL.md" ]; then + exit 0 # a refused backup that keeps the source is contract-compliant + fi + exit 9 +fi +[ -f "$ROOT/20260101-120000-1/` + "`printf '%s' \"" + payloadName + "\"`" + `/SKILL.md" ] || exit 3 +[ ! -e "$HOME/victim" ] || exit 4 +[ -f "$ROOT/20260101-120000/user-data.txt" ] || exit 5 +[ "$(cat "$ROOT/20260101-120000/user-data.txt")" = "must-survive" ] || exit 6 +[ ! -e "$ROOT/20260101-120000/.dws-skill-backup" ] || exit 7 +[ "$(cat "$ROOT/20260101-120000-1/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || exit 8 +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "backup-adoption.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", "-c", "backup_fn() { "+backupFnName(scriptName)+" \"$1\"; }\n. \"$0\"", harnessPath) + cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s must not adopt an unproven stamp root: %v\n%s", scriptName, err, string(output)) + } + if backup := findSkillBackupContent(home, "payload\n"); !strings.Contains(backup, "20260101-120000-1") { + t.Fatalf("%s payload must land in the suffixed root, got %s", scriptName, backup) + } + }) + } +} + +func backupFnName(scriptName string) string { + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + return "backup_skill_dir" + } + return "backup_and_remove_skill_dir" +}