Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f62193e7 | ||
|
|
f26df04679 | ||
|
|
d02b03436d | ||
|
|
bc7b96ba5f | ||
|
|
9539c887f6 | ||
|
|
6607f44724 | ||
|
|
837a96fe3d | ||
|
|
fdcd44f9e3 | ||
|
|
34c0c86a59 | ||
|
|
b2cbca2762 | ||
|
|
9ce95db08e | ||
|
|
e99c20a0a1 | ||
|
|
96bfae079a | ||
|
|
bb18cdba3b | ||
|
|
015a1f85ca | ||
|
|
8854e0d1d4 | ||
|
|
22862508b8 | ||
|
|
5459bcc524 | ||
|
|
029c665029 |
@@ -6,6 +6,49 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
|
||||
It includes PR #852's resilient multipart Drive download implementation,
|
||||
together with the v1.0.56 beta-line command, Schema, Skill, and runtime
|
||||
improvements already validated through the prerelease channel.
|
||||
|
||||
### Added
|
||||
|
||||
- **Resilient multipart Drive downloads** (#852) — `drive download` and
|
||||
`drive download-version` support parallel chunk transfer, Range probing,
|
||||
fingerprint-validated checkpoint resume, automatic 401/403 credential
|
||||
refresh, and graceful interruption with checkpoint preservation.
|
||||
|
||||
## [1.0.56-beta.4] - 2026-08-04
|
||||
|
||||
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
|
||||
resilient for large files through parallel transfer, validated resumable
|
||||
checkpoints, and automatic credential refresh.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
|
||||
`--no-resume` to `drive download` and `drive download-version`. Files above
|
||||
the part-size threshold use a Range probe and parallel chunks, resume from a
|
||||
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
|
||||
the checkpoint when Ctrl+C interrupts a transfer.
|
||||
|
||||
## [1.0.56-beta.3] - 2026-08-03
|
||||
|
||||
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
|
||||
service-provided Aitable workflow-editing reference command and makes local
|
||||
event-bus IPC reliable on shared filesystems by placing Unix sockets in a
|
||||
validated private runtime directory.
|
||||
|
||||
### Added
|
||||
|
||||
- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`.
|
||||
|
||||
## [1.0.56-beta.2] - 2026-07-30
|
||||
|
||||
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
|
||||
|
||||
@@ -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.56-beta.2"
|
||||
version "1.0.56-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.56-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip"
|
||||
sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -37075,6 +37075,265 @@
|
||||
"用户明确要求停止某自动化工作流时"
|
||||
]
|
||||
},
|
||||
"aitable workflow edit-example": {
|
||||
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws aitable workflow edit-example"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aitable workflow edit-example"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aitable workflow edit-example"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"cobra-help:dws aitable workflow edit-example --help",
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
|
||||
"internal/cli/schema_hints/metadata/aitable.json",
|
||||
"internal/cli/schema_hints/selection/aitable.json",
|
||||
"mcp-contract:aitable/edit_workflow_example",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
],
|
||||
"use_when": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
]
|
||||
},
|
||||
"aitable workflow enable": {
|
||||
"agent_summary": "启用工作流。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 845,
|
||||
"tools_with_metadata": 845,
|
||||
"tools_with_agent_summary": 845,
|
||||
"tools_with_use_when": 845,
|
||||
"tools_with_avoid_when": 845,
|
||||
"tools_with_examples": 845,
|
||||
"tools_with_interface_mode": 845,
|
||||
"surface_tools": 846,
|
||||
"tools_with_metadata": 846,
|
||||
"tools_with_agent_summary": 846,
|
||||
"tools_with_use_when": 846,
|
||||
"tools_with_avoid_when": 846,
|
||||
"tools_with_examples": 846,
|
||||
"tools_with_interface_mode": 846,
|
||||
"unmatched_skill_tools": 122,
|
||||
"unreviewed_skill_tools": 11
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"source_files": 160,
|
||||
"hint_files": 54,
|
||||
"hint_products": 43,
|
||||
"hint_tools": 1842,
|
||||
"hint_tools": 1844,
|
||||
"interface_metadata": {
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
|
||||
@@ -29,13 +29,13 @@
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 845,
|
||||
"tools_with_metadata": 845,
|
||||
"tools_with_agent_summary": 845,
|
||||
"tools_with_use_when": 845,
|
||||
"tools_with_avoid_when": 845,
|
||||
"tools_with_examples": 845,
|
||||
"tools_with_interface_mode": 845,
|
||||
"surface_tools": 846,
|
||||
"tools_with_metadata": 846,
|
||||
"tools_with_agent_summary": 846,
|
||||
"tools_with_use_when": 846,
|
||||
"tools_with_avoid_when": 846,
|
||||
"tools_with_examples": 846,
|
||||
"tools_with_interface_mode": 846,
|
||||
"unmatched_skill_tools": 122,
|
||||
"unreviewed_skill_tools": 11
|
||||
},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_hash": "sha256:862734fb976a4c9c78d010b7391c74822f37805f5acc650551f4ac60f701c13a",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"source_hash": "sha256:00285e7f05c4ed9e9e3aa7464c7114990187dd7ffdcc9e83e64f3ec33d22d0a8",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 26,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
|
||||
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
|
||||
"surface_products": 26,
|
||||
"surface_tools": 845,
|
||||
"tools_with_agent_summary": 845,
|
||||
"tools_with_metadata": 845,
|
||||
"surface_tools": 846,
|
||||
"tools_with_agent_summary": 846,
|
||||
"tools_with_metadata": 846,
|
||||
"unmatched_skill_tools": 122,
|
||||
"version": 1
|
||||
},
|
||||
@@ -288,7 +288,7 @@
|
||||
"id": "aitable",
|
||||
"name": "AI 表格操作",
|
||||
"runtime": true,
|
||||
"tool_count": 145,
|
||||
"tool_count": 146,
|
||||
"tools": [
|
||||
{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
@@ -4449,6 +4449,33 @@
|
||||
"用户明确要求停止某自动化工作流时"
|
||||
]
|
||||
},
|
||||
{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"canonical_path": "aitable.workflow_edit_example",
|
||||
"cli_name": "edit-example",
|
||||
"cli_path": "aitable workflow edit-example",
|
||||
"confirmation": "not_required",
|
||||
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
|
||||
"effect": "read",
|
||||
"group": "workflow",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"name": "workflow_edit_example",
|
||||
"primary_cli_path": "aitable workflow edit-example",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"title": "获取工作流编辑文档与示例",
|
||||
"use_when": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
]
|
||||
},
|
||||
{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_summary": "启用工作流。",
|
||||
@@ -26644,6 +26671,6 @@
|
||||
}
|
||||
],
|
||||
"source": "embedded-command-catalog",
|
||||
"tool_count": 845
|
||||
"tool_count": 846
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97777,6 +97777,326 @@
|
||||
"用户明确要求停止某自动化工作流时"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_edit_example": {
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": [
|
||||
"cobra-help:dws aitable workflow edit-example --help",
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
|
||||
"internal/cli/schema_hints/metadata/aitable.json",
|
||||
"internal/cli/schema_hints/selection/aitable.json",
|
||||
"mcp-contract:aitable/edit_workflow_example",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
],
|
||||
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"canonical_path": "aitable.workflow_edit_example",
|
||||
"cli_name": "edit-example",
|
||||
"cli_path": "aitable workflow edit-example",
|
||||
"confirmation": "not_required",
|
||||
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
|
||||
"display": "AI 表格操作",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws aitable workflow edit-example"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "available"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "available"
|
||||
},
|
||||
"avoid_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
]
|
||||
},
|
||||
"canonical_path": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "command_registry",
|
||||
"selected": true,
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "aitable workflow edit-example",
|
||||
"value": "aitable.workflow_edit_example"
|
||||
}
|
||||
],
|
||||
"precedence": "command_registry",
|
||||
"resolution": "registry_identity",
|
||||
"source": "reviewed_command_registry",
|
||||
"source_ref": "aitable workflow edit-example",
|
||||
"value": "aitable.workflow_edit_example"
|
||||
},
|
||||
"confirmation": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "not_required"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "not_required"
|
||||
},
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
|
||||
},
|
||||
"effect": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "read"
|
||||
},
|
||||
"examples": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"dws aitable workflow edit-example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"dws aitable workflow edit-example"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "idempotent"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "idempotent"
|
||||
},
|
||||
"interface_mode": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "composite"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "composite"
|
||||
},
|
||||
"interface_reason": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
},
|
||||
"interface_ref": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": null
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": null
|
||||
},
|
||||
"reviewed": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"selected": false,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": true
|
||||
},
|
||||
"risk": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "low"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"value": "low"
|
||||
},
|
||||
"title": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "获取工作流编辑文档与示例"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "获取工作流编辑文档与示例"
|
||||
},
|
||||
"use_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_explicit",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
]
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"value": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
]
|
||||
}
|
||||
},
|
||||
"group": "workflow",
|
||||
"has_parameters": false,
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"is_alias": false,
|
||||
"name": "workflow_edit_example",
|
||||
"parameter_count": 0,
|
||||
"parameters": {},
|
||||
"path": "aitable.workflow_edit_example",
|
||||
"primary_cli_path": "aitable workflow edit-example",
|
||||
"product_id": "aitable",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source": "reviewed_command_registry",
|
||||
"title": "获取工作流编辑文档与示例",
|
||||
"use_when": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_enable": {
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_source_refs": [
|
||||
|
||||
@@ -5312,8 +5312,99 @@
|
||||
"is_alias": false,
|
||||
"metadata_source": "embedded-mcp-metadata",
|
||||
"name": "download_file",
|
||||
"parameter_count": 3,
|
||||
"parameter_count": 6,
|
||||
"parameters": {
|
||||
"no-resume": {
|
||||
"description": "关闭断点续传 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "noResume"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"node": {
|
||||
"description": "文件 ID (dentryUuid) (必填)",
|
||||
"field_provenance": {
|
||||
@@ -5520,6 +5611,202 @@
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
"parallel": {
|
||||
"default": "4",
|
||||
"description": "分片下载并发数,范围 1-8 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "parallel"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "integer"
|
||||
},
|
||||
"part-size": {
|
||||
"default": "16MB",
|
||||
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "partSize"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"space-id": {
|
||||
"description": "文件所属空间 ID (可选)",
|
||||
"field_provenance": {
|
||||
@@ -5937,8 +6224,99 @@
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"is_alias": false,
|
||||
"name": "download_file_version",
|
||||
"parameter_count": 3,
|
||||
"parameter_count": 6,
|
||||
"parameters": {
|
||||
"no-resume": {
|
||||
"description": "关闭断点续传 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "关闭断点续传 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "noResume"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"node": {
|
||||
"description": "文件 ID (dentryUuid) 或 URL (必填)",
|
||||
"field_provenance": {
|
||||
@@ -6133,6 +6511,202 @@
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
"parallel": {
|
||||
"default": "4",
|
||||
"description": "分片下载并发数,范围 1-8 (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载并发数,范围 1-8 (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "parallel"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "integer"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "integer"
|
||||
},
|
||||
"part-size": {
|
||||
"default": "16MB",
|
||||
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"selected": true,
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": false,
|
||||
"source": "flag_name_inference",
|
||||
"value": "partSize"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_mapping_exclusion",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"source": "reviewed_mapping_exclusion",
|
||||
"value": ""
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_nonzero_default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "string"
|
||||
}
|
||||
},
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "历史版本号 (必填,正整数,从 drive list --versions 获取)",
|
||||
"field_provenance": {
|
||||
|
||||
@@ -452,6 +452,10 @@
|
||||
"canonical_path": "aitable.workflow_disable",
|
||||
"cli_path": "aitable workflow disable"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aitable.workflow_edit_example",
|
||||
"cli_path": "aitable workflow edit-example"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aitable.workflow_enable",
|
||||
"cli_path": "aitable workflow enable"
|
||||
|
||||
@@ -999,6 +999,19 @@
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"aitable.workflow_edit_example": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
|
||||
"cli_path": "aitable workflow edit-example",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.workflow_enable": {
|
||||
"interface_ref": {
|
||||
"product_id": "aitable-helper",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"channel": "open-source"
|
||||
},
|
||||
"coverage": {
|
||||
"source_tools": 845,
|
||||
"source_tools": 846,
|
||||
"matched_tools": 71
|
||||
},
|
||||
"tools": {
|
||||
|
||||
@@ -2250,6 +2250,26 @@
|
||||
"dws-schema-live:none (helper/composite; Skill+Cobra)"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_edit_example": {
|
||||
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
|
||||
"use_when": [
|
||||
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
|
||||
],
|
||||
"examples": [
|
||||
"dws aitable workflow edit-example"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
|
||||
"cobra-help:dws aitable workflow edit-example --help",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md",
|
||||
"mcp-contract:aitable/edit_workflow_example"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_enable": {
|
||||
"agent_summary": "启用工作流。",
|
||||
"use_when": [
|
||||
|
||||
@@ -1790,9 +1790,15 @@
|
||||
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file --no-resume": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
|
||||
"drive.download_file --part-size": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --parallel": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --part-size": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -25,7 +25,9 @@ import (
|
||||
)
|
||||
|
||||
// MetaFileName is the on-disk name of the bus metadata file. It lives
|
||||
// alongside bus.lock and bus.sock inside the bus working directory.
|
||||
// alongside bus.lock inside the bus working directory. Unix bus sockets live
|
||||
// in a private per-user runtime directory so shared config filesystems do not
|
||||
// need socket support.
|
||||
const MetaFileName = "bus.meta"
|
||||
|
||||
// Meta is the JSON document written once at bus startup. Its primary
|
||||
|
||||
@@ -30,9 +30,9 @@ import (
|
||||
type SpawnFunc func(SpawnConfig) (pid int, err error)
|
||||
|
||||
// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and
|
||||
// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path
|
||||
// fallback when WorkDir is too deep; the caller must mkdir it with
|
||||
// pkg/config.DirPerm beforehand.
|
||||
// persistent bus metadata; Unix sockets live in a private per-user runtime
|
||||
// directory so WorkDir may reside on a shared filesystem without socket
|
||||
// support. The caller must mkdir WorkDir with pkg/config.DirPerm beforehand.
|
||||
type DiscoverConfig struct {
|
||||
WorkDir string
|
||||
IPCEndpoint string
|
||||
|
||||
@@ -69,8 +69,8 @@ type BusEntry struct {
|
||||
|
||||
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
|
||||
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
|
||||
// bus daemon bound (including the short-path fallback when WorkDir is too
|
||||
// deep for sun_path).
|
||||
// bus daemon bound (a private per-user runtime path on Unix and a named pipe
|
||||
// on Windows).
|
||||
func (e BusEntry) IPCEndpoint() string {
|
||||
hash := e.ClientIDHash
|
||||
if e.IdentityHash != "" {
|
||||
|
||||
+36
-14
@@ -17,8 +17,16 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const eventRuntimeDirPrefix = "dws-event-"
|
||||
|
||||
func currentUserID() string {
|
||||
return strconv.Itoa(os.Geteuid())
|
||||
}
|
||||
|
||||
// MaxUnixSocketPath returns the longest Unix socket path accepted by
|
||||
// bind/connect on this OS (Go rejects longer names with EINVAL before
|
||||
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
|
||||
@@ -35,17 +43,19 @@ func maxUnixSocketPath(goos string) int {
|
||||
}
|
||||
|
||||
// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe
|
||||
// name on Windows, otherwise bus.sock inside workDir.
|
||||
// name on Windows, otherwise a deterministic Unix socket under a private
|
||||
// per-user runtime directory.
|
||||
//
|
||||
// The canonical Unix location is <workDir>/bus.sock, but workDir derives
|
||||
// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes
|
||||
// use ~/.dwssb/sandboxes/<name>/config/...). When the canonical path would
|
||||
// exceed the OS sun_path limit, the socket falls back to a short
|
||||
// deterministic path under os.TempDir keyed by a hash of workDir, so every
|
||||
// process (consume parent, forked _bus child, status/stop tooling) that
|
||||
// derives the endpoint from the same workDir agrees on the location.
|
||||
// bus.lock / bus.meta / bus.log always stay in workDir — only the socket
|
||||
// moves.
|
||||
// Unix sockets must live on a local filesystem that supports bind(2).
|
||||
// Config directories may reside on NFS, CSI, FUSE, or other shared mounts
|
||||
// that reject Unix socket creation with ENOTSUPP. On Unix, the endpoint uses
|
||||
// XDG_RUNTIME_DIR when it is absolute and short enough; otherwise it falls
|
||||
// back to a per-UID directory under os.TempDir. The transport creates and
|
||||
// validates that directory as owner-only before listening or dialing. The
|
||||
// socket name is keyed by a hash of workDir so every process (consume parent,
|
||||
// forked _bus child, status/stop tooling) that derives the endpoint from the
|
||||
// same workDir agrees on the location. bus.lock / bus.meta / bus.log always
|
||||
// stay in workDir.
|
||||
//
|
||||
// This is the single source of truth for endpoint derivation; the cobra
|
||||
// layer and busctl must not re-implement the shape.
|
||||
@@ -60,9 +70,21 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind,
|
||||
if goos == "windows" {
|
||||
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
|
||||
}
|
||||
sock := filepath.Join(workDir, "bus.sock")
|
||||
if len(sock) <= maxUnixSocketPath(goos) {
|
||||
return sock
|
||||
return unixSocketEndpoint(goos, workDir, strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")), os.TempDir())
|
||||
}
|
||||
|
||||
func unixSocketEndpoint(goos, workDir, runtimeDir, tempDir string) string {
|
||||
socketName := "dws-evt-" + IdentityHash(workDir) + ".sock"
|
||||
userDirName := eventRuntimeDirPrefix + currentUserID()
|
||||
if filepath.IsAbs(runtimeDir) {
|
||||
candidate := filepath.Join(runtimeDir, userDirName, socketName)
|
||||
if len(candidate) <= maxUnixSocketPath(goos) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
fallback := filepath.Join(tempDir, userDirName, socketName)
|
||||
if len(fallback) <= maxUnixSocketPath(goos) {
|
||||
return fallback
|
||||
}
|
||||
return filepath.Join("/tmp", userDirName, socketName)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -33,10 +34,27 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) {
|
||||
if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` {
|
||||
t.Fatalf("Windows endpoint = %q", got)
|
||||
}
|
||||
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join("short", "bus.sock") {
|
||||
|
||||
runtimeRoot := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
|
||||
runtimeDir := filepath.Join(runtimeRoot, "dws-xdg-runtime")
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
|
||||
workDir := "portable-xdg-workdir"
|
||||
wantXDG := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
if got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash"); got != wantXDG {
|
||||
t.Fatalf("XDG Unix endpoint = %q, want %q", got, wantXDG)
|
||||
}
|
||||
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") {
|
||||
t.Fatalf("short Unix endpoint = %q", got)
|
||||
}
|
||||
if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") {
|
||||
t.Fatalf("long Unix endpoint = %q", got)
|
||||
}
|
||||
|
||||
longTempDir := filepath.Join(string(filepath.Separator), strings.Repeat("long-temp-root", 20))
|
||||
wantShortFallback := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
if got := unixSocketEndpoint("darwin", workDir, "", longTempDir); got != wantShortFallback {
|
||||
t.Fatalf("overlong temp endpoint = %q, want %q", got, wantShortFallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
)
|
||||
|
||||
func TestEndpointPlatformVariants(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 {
|
||||
t.Fatal("Unix socket limits changed")
|
||||
}
|
||||
@@ -31,7 +32,7 @@ func TestEndpointPlatformVariants(t *testing.T) {
|
||||
t.Fatalf("Windows pipe = %q", pipe)
|
||||
}
|
||||
short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash")
|
||||
if short != filepath.Join("/tmp/events", "bus.sock") {
|
||||
if short != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") {
|
||||
t.Fatalf("short Unix endpoint = %q", short)
|
||||
}
|
||||
long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash")
|
||||
@@ -40,16 +41,40 @@ func TestEndpointPlatformVariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
|
||||
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
|
||||
func TestIPCEndpointUsesXDGUserRuntimeDir(t *testing.T) {
|
||||
tempRoot, err := filepath.EvalSymlinks("/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
runtimeDir, err := os.MkdirTemp(tempRoot, "dws-xdg-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) })
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
|
||||
workDir := "/shared/events/open/app_stream/aabbccdd00112233"
|
||||
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
|
||||
want := filepath.Join(workDir, "bus.sock")
|
||||
want := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
if got != want {
|
||||
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
|
||||
func TestIPCEndpointWithoutXDGUsesPerUserLocalTempDir(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
|
||||
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
|
||||
want := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
if got != want {
|
||||
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
|
||||
}
|
||||
if strings.HasPrefix(got, workDir) {
|
||||
t.Fatalf("IPCEndpoint = %q, want endpoint outside workDir", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
// Mirrors the dwssb sandbox layout that produced a 111-byte socket
|
||||
// path — over macOS's 103-byte usable sun_path budget.
|
||||
workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52"
|
||||
@@ -66,6 +91,7 @@ func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
long := strings.Repeat("x", 120)
|
||||
a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
|
||||
b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
|
||||
@@ -77,3 +103,29 @@ func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
|
||||
t.Fatalf("different workDirs collided on endpoint %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/"+strings.Repeat("runtime/", 30))
|
||||
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
|
||||
got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash")
|
||||
wantPrefix := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID()) + string(filepath.Separator)
|
||||
if !strings.HasPrefix(got, wantPrefix) {
|
||||
t.Fatalf("IPCEndpoint = %q, want fallback under %q", got, wantPrefix)
|
||||
}
|
||||
if len(got) > maxUnixSocketPath("linux") {
|
||||
t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointLongTempDirUsesShortSystemFallback(t *testing.T) {
|
||||
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
|
||||
longTempDir := "/" + strings.Repeat("long-temp-root/", 20)
|
||||
got := unixSocketEndpoint("linux", workDir, "", longTempDir)
|
||||
want := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
if got != want {
|
||||
t.Fatalf("IPCEndpoint = %q, want short fallback %q", got, want)
|
||||
}
|
||||
if len(got) > maxUnixSocketPath("linux") {
|
||||
t.Fatalf("short fallback path too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,83 @@ func TestCrossPlatformCoverageUnixListenErrorCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnixSocketDirectoryErrorCoverage(t *testing.T) {
|
||||
oldLstat, oldRuntimeStat, oldMkdir := lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir
|
||||
t.Cleanup(func() {
|
||||
lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir = oldLstat, oldRuntimeStat, oldMkdir
|
||||
})
|
||||
|
||||
wantErr := errors.New("synthetic socket directory failure")
|
||||
if err := ensureSocketDir("relative/bus.sock", true); err == nil || !strings.Contains(err.Error(), "must be absolute") {
|
||||
t.Fatalf("relative socket path error = %v", err)
|
||||
}
|
||||
|
||||
root := shortSecureTempDir(t)
|
||||
missingRootPath := filepath.Join(root, "missing-root", "dws-event-test", "bus.sock")
|
||||
if err := ensureSocketDir(missingRootPath, false); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing runtime root error = %v", err)
|
||||
}
|
||||
|
||||
rootInfo, err := oldLstat(root)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat secure root: %v", err)
|
||||
}
|
||||
lstatSocketPath = func(path string) (os.FileInfo, error) {
|
||||
if path == "/tmp" {
|
||||
return fileInfoWithMode{FileInfo: rootInfo, mode: os.ModeSymlink | 0o777}, nil
|
||||
}
|
||||
return oldLstat(path)
|
||||
}
|
||||
statSocketRuntimeRoot = func(path string) (os.FileInfo, error) {
|
||||
if path == "/tmp" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return oldRuntimeStat(path)
|
||||
}
|
||||
if err := ensureSocketDir("/tmp/dws-event-coverage/bus.sock", false); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runtime root resolution error = %v", err)
|
||||
}
|
||||
lstatSocketPath, statSocketRuntimeRoot = oldLstat, oldRuntimeStat
|
||||
|
||||
rootFile := filepath.Join(root, "runtime-root-file")
|
||||
if err := os.WriteFile(rootFile, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("write runtime root file: %v", err)
|
||||
}
|
||||
if err := ensureSocketDir(filepath.Join(rootFile, "dws-event-test", "bus.sock"), false); err == nil || !strings.Contains(err.Error(), "runtime root is not a directory") {
|
||||
t.Fatalf("non-directory runtime root error = %v", err)
|
||||
}
|
||||
|
||||
mkdirSocketDir = func(string, os.FileMode) error { return wantErr }
|
||||
if err := ensureSocketDir(filepath.Join(root, "mkdir-failure", "bus.sock"), true); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("socket directory creation error = %v", err)
|
||||
}
|
||||
mkdirSocketDir = oldMkdir
|
||||
|
||||
if err := ensureSocketDir(filepath.Join(root, "missing-socket-dir", "bus.sock"), false); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing socket directory error = %v", err)
|
||||
}
|
||||
|
||||
withoutOwner := fileInfoWithoutOwner{FileInfo: rootInfo}
|
||||
if err := validateSocketRuntimeRoot(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
|
||||
t.Fatalf("runtime root owner error = %v", err)
|
||||
}
|
||||
if err := validatePrivateSocketDir(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
|
||||
t.Fatalf("socket directory owner error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fileInfoWithMode struct {
|
||||
os.FileInfo
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func (f fileInfoWithMode) Mode() os.FileMode { return f.mode }
|
||||
func (f fileInfoWithMode) IsDir() bool { return f.mode.IsDir() }
|
||||
|
||||
type fileInfoWithoutOwner struct{ os.FileInfo }
|
||||
|
||||
func (fileInfoWithoutOwner) Sys() any { return struct{}{} }
|
||||
|
||||
type stubNetListener struct {
|
||||
close func() error
|
||||
}
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -30,10 +33,13 @@ type unixListener struct {
|
||||
}
|
||||
|
||||
var (
|
||||
statSocket = os.Stat
|
||||
removeSocket = os.Remove
|
||||
listenUnix = net.Listen
|
||||
chmodSocket = os.Chmod
|
||||
statSocket = os.Stat
|
||||
removeSocket = os.Remove
|
||||
listenUnix = net.Listen
|
||||
chmodSocket = os.Chmod
|
||||
lstatSocketPath = os.Lstat
|
||||
statSocketRuntimeRoot = os.Stat
|
||||
mkdirSocketDir = os.Mkdir
|
||||
)
|
||||
|
||||
func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() }
|
||||
@@ -56,11 +62,95 @@ func checkSocketPath(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureSocketDir makes the socket's immediate parent an owner-only
|
||||
// directory and rejects unsafe pre-existing paths. The parent of that
|
||||
// directory must itself either be private to the effective user (for
|
||||
// XDG_RUNTIME_DIR and macOS temporary roots) or sticky (for Linux /tmp), so
|
||||
// another user cannot rename the private directory out from under us.
|
||||
func ensureSocketDir(path string, create bool) error {
|
||||
if !filepath.IsAbs(path) {
|
||||
return fmt.Errorf("transport: unix socket path must be absolute: %s", path)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
root := filepath.Dir(dir)
|
||||
rootInfo, err := lstatSocketPath(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err)
|
||||
}
|
||||
// macOS exposes the system /tmp as a root-owned symlink to /private/tmp.
|
||||
// Follow only that well-known alias, then apply the same ownership/sticky
|
||||
// validation to its target. Arbitrary runtime-root symlinks remain rejected.
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" {
|
||||
rootInfo, err = statSocketRuntimeRoot(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err)
|
||||
}
|
||||
}
|
||||
if err := validateSocketRuntimeRoot(root, rootInfo, uint32(os.Geteuid())); err != nil {
|
||||
return err
|
||||
}
|
||||
if create {
|
||||
if err := mkdirSocketDir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("transport: create socket directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
dirInfo, err := lstatSocketPath(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err)
|
||||
}
|
||||
return validatePrivateSocketDir(dir, dirInfo, uint32(os.Geteuid()))
|
||||
}
|
||||
|
||||
func validateSocketRuntimeRoot(path string, info os.FileInfo, effectiveUID uint32) error {
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("transport: socket runtime root is not a directory: %s", path)
|
||||
}
|
||||
owner, err := fileOwnerUID(info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: inspect socket runtime root owner %s: %w", path, err)
|
||||
}
|
||||
privateOwnerRoot := owner == effectiveUID && info.Mode().Perm()&0o022 == 0
|
||||
stickyRoot := info.Mode()&os.ModeSticky != 0
|
||||
if !privateOwnerRoot && !stickyRoot {
|
||||
return fmt.Errorf("transport: socket runtime root is neither private nor sticky: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrivateSocketDir(path string, info os.FileInfo, effectiveUID uint32) error {
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("transport: socket directory is not a directory: %s", path)
|
||||
}
|
||||
owner, err := fileOwnerUID(info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: inspect socket directory owner %s: %w", path, err)
|
||||
}
|
||||
if owner != effectiveUID {
|
||||
return fmt.Errorf("transport: socket directory %s is owned by uid %d, want %d", path, owner, effectiveUID)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != config.DirPerm {
|
||||
return fmt.Errorf("transport: socket directory %s has permissions %04o, want %04o", path, perm, config.DirPerm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileOwnerUID(info os.FileInfo) (uint32, error) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, errors.New("stat result does not expose an owner uid")
|
||||
}
|
||||
return stat.Uid, nil
|
||||
}
|
||||
|
||||
func listen(path string) (Listener, error) {
|
||||
if err := checkSocketPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Stale socket cleanup. Caller holds bus.lock so this is race-safe.
|
||||
if err := ensureSocketDir(path, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The private per-user parent excludes other users. The caller's bus.lock
|
||||
// serializes stale-socket cleanup for processes using the same WorkDir.
|
||||
if _, err := statSocket(path); err == nil {
|
||||
if err := removeSocket(path); err != nil {
|
||||
return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err)
|
||||
@@ -82,5 +172,8 @@ func dial(path string) (net.Conn, error) {
|
||||
if err := checkSocketPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureSocketDir(path, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.Dial("unix", path)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,32 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
)
|
||||
|
||||
func shortSecureTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
tempRoot, err := filepath.EvalSymlinks("/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
dir, err := os.MkdirTemp(tempRoot, "dws-et-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
t.Fatalf("chmod temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestListen_DialRoundtrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.sock")
|
||||
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
|
||||
l, err := Listen(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
@@ -87,7 +107,7 @@ func TestListen_DialRoundtrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListen_StaleSocketCleanup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.sock")
|
||||
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
|
||||
// Pre-create a stale file at path (not a valid socket).
|
||||
if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
|
||||
t.Fatalf("pre-create: %v", err)
|
||||
@@ -99,8 +119,121 @@ func TestListen_StaleSocketCleanup(t *testing.T) {
|
||||
defer l.Close()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenCreatesPrivateSocketDirectory(t *testing.T) {
|
||||
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
|
||||
path := filepath.Join(dir, "bus.sock")
|
||||
l, err := Listen(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat socket directory: %v", err)
|
||||
}
|
||||
if mode := st.Mode().Perm(); mode != 0o700 {
|
||||
t.Fatalf("socket directory mode = %04o, want 0700", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenRejectsWorldAccessibleSocketDirectory(t *testing.T) {
|
||||
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
|
||||
t.Fatalf("Listen error = %v, want 0700 directory rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDialRejectsWorldAccessibleSocketDirectory(t *testing.T) {
|
||||
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if _, err := Dial(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
|
||||
t.Fatalf("Dial error = %v, want 0700 directory rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenRejectsSymlinkSocketDirectory(t *testing.T) {
|
||||
root := shortSecureTempDir(t)
|
||||
target := filepath.Join(root, "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("mkdir target: %v", err)
|
||||
}
|
||||
link := filepath.Join(root, "dws-event-test")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if _, err := Listen(filepath.Join(link, "bus.sock")); err == nil || !strings.Contains(err.Error(), "not a directory") {
|
||||
t.Fatalf("Listen error = %v, want symlink directory rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) {
|
||||
dir := shortSecureTempDir(t)
|
||||
st, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat: %v", err)
|
||||
}
|
||||
otherUID := uint32(os.Geteuid() + 1)
|
||||
if err := validatePrivateSocketDir(dir, st, otherUID); err == nil || !strings.Contains(err.Error(), "is owned by uid") {
|
||||
t.Fatalf("validatePrivateSocketDir error = %v, want owner mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenRejectsUntrustedRuntimeRoot(t *testing.T) {
|
||||
root := filepath.Join(shortSecureTempDir(t), "untrusted")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatalf("mkdir root: %v", err)
|
||||
}
|
||||
if err := os.Chmod(root, 0o777); err != nil {
|
||||
t.Fatalf("chmod root: %v", err)
|
||||
}
|
||||
dir := filepath.Join(root, "dws-event-test")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir socket dir: %v", err)
|
||||
}
|
||||
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "neither private nor sticky") {
|
||||
t.Fatalf("Listen error = %v, want untrusted runtime root rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenSharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) {
|
||||
root := shortSecureTempDir(t)
|
||||
runtimeDir := filepath.Join(root, "runtime")
|
||||
if err := os.Mkdir(runtimeDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir runtime: %v", err)
|
||||
}
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
|
||||
sharedWorkDir := filepath.Join(root, "simulated-nfs", "events", "open", "personal_stream", "identity")
|
||||
endpoint := dwsevent.IPCEndpoint(sharedWorkDir, "open", dwsevent.SourceKindPersonalStream, "identity")
|
||||
if strings.HasPrefix(endpoint, sharedWorkDir) {
|
||||
t.Fatalf("endpoint = %q, want socket outside shared WorkDir %q", endpoint, sharedWorkDir)
|
||||
}
|
||||
|
||||
l, err := Listen(endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen on local runtime endpoint: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if mode, err := os.Stat(filepath.Dir(endpoint)); err != nil {
|
||||
t.Fatalf("stat runtime socket directory: %v", err)
|
||||
} else if mode.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("runtime socket directory mode = %04o, want 0700", mode.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListen_CloseUnlinksSocket(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.sock")
|
||||
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
|
||||
l, err := Listen(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
@@ -114,7 +247,7 @@ func TestListen_CloseUnlinksSocket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDial_NoServerReturnsError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nonexistent.sock")
|
||||
path := filepath.Join(shortSecureTempDir(t), "nonexistent.sock")
|
||||
if _, err := Dial(path); err == nil {
|
||||
t.Fatal("Dial to nonexistent socket should error")
|
||||
}
|
||||
@@ -124,7 +257,7 @@ func TestDial_NoServerReturnsError(t *testing.T) {
|
||||
// surfaces as io.EOF to the server's Reader — the EOF signal is what bus
|
||||
// uses to unregister dead consumers (plan invariant #5).
|
||||
func TestReader_HandlesPeerCloseEOF(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.sock")
|
||||
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
|
||||
l, err := Listen(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
|
||||
@@ -853,6 +853,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
dws aitable form share [get|update|notify] 表单分享管理
|
||||
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
|
||||
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
|
||||
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
|
||||
dws aitable export data 数据导出
|
||||
@@ -3260,6 +3261,17 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
},
|
||||
}
|
||||
|
||||
workflowEditExampleCmd := &cobra.Command{
|
||||
Use: "edit-example",
|
||||
Short: "获取工作流编辑文档与示例",
|
||||
Long: `返回服务端提供的 AI 表格工作流编辑文档与示例。
|
||||
可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。`,
|
||||
Example: ` dws aitable workflow edit-example`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callAitableTool("edit_workflow_example", map[string]any{})
|
||||
},
|
||||
}
|
||||
|
||||
workflowUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新并发布已有自动化工作流",
|
||||
@@ -4856,7 +4868,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
|
||||
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
|
||||
workflowCmd.AddCommand(
|
||||
workflowCreateCmd, workflowUpdateCmd,
|
||||
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
|
||||
workflowEnableCmd, workflowDisableCmd,
|
||||
workflowGetCmd, workflowListCmd,
|
||||
)
|
||||
|
||||
@@ -94,6 +94,23 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
||||
if err != nil {
|
||||
t.Fatalf("workflow edit-example returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "edit_workflow_example" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName)
|
||||
}
|
||||
if len(call.args) != 0 {
|
||||
t.Fatalf("tool args = %#v, want empty arguments", call.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
|
||||
path := t.TempDir() + "/workflow.json"
|
||||
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
|
||||
|
||||
@@ -276,7 +276,8 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("OSS upload failed: HTTP %d: %s", resp.StatusCode, string(body))
|
||||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||||
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -434,7 +435,8 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
outFile, err := docCreateDestination(destPath)
|
||||
|
||||
+136
-14
@@ -476,7 +476,8 @@ func newDriveCommand() *cobra.Command {
|
||||
--output 指定本地保存路径,可以是文件路径或目录。
|
||||
如果指定目录,文件名从下载 URL 中自动推断。`,
|
||||
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/`,
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fileID := flagOrFallback(cmd, "node", "file-id")
|
||||
if fileID == "" {
|
||||
@@ -492,6 +493,15 @@ func newDriveCommand() *cobra.Command {
|
||||
argsMap["spaceId"] = v
|
||||
}
|
||||
|
||||
// fail-fast:分片下载参数校验
|
||||
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
|
||||
deps.Out.PrintKeyValue("文件ID", fileID)
|
||||
@@ -499,7 +509,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: 获取下载 URL 和签名请求头
|
||||
deps.Out.PrintInfo("[1/2] 获取下载链接...")
|
||||
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -523,9 +533,34 @@ func newDriveCommand() *cobra.Command {
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
// Step 2: HTTP GET 下载文件
|
||||
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = parseDownloadFileVersion(text)
|
||||
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap)
|
||||
if ferr != nil {
|
||||
return "", nil, 0, ferr
|
||||
}
|
||||
u, h, perr := parseDriveDownloadInfo(t)
|
||||
if perr != nil {
|
||||
return "", nil, 0, perr
|
||||
}
|
||||
return u, h, parseDownloadFileVersion(t), nil
|
||||
}
|
||||
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
partSize, _ := cmd.Flags().GetString("part-size")
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
if partSize != "" && !noResume {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -564,6 +599,15 @@ func newDriveCommand() *cobra.Command {
|
||||
return fmt.Errorf("flag --output is required")
|
||||
}
|
||||
|
||||
// fail-fast:分片下载参数校验
|
||||
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
|
||||
deps.Out.PrintKeyValue("节点ID", fileID)
|
||||
@@ -572,16 +616,17 @@ func newDriveCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
dlArgsMap := map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,7 +638,32 @@ func newDriveCommand() *cobra.Command {
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = versionNum
|
||||
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap)
|
||||
if ferr != nil {
|
||||
return "", nil, 0, ferr
|
||||
}
|
||||
u, h, perr := parseDriveDownloadInfo(t)
|
||||
if perr != nil {
|
||||
return "", nil, 0, perr
|
||||
}
|
||||
return u, h, parseDownloadFileVersion(t), nil
|
||||
}
|
||||
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
partSize, _ := cmd.Flags().GetString("part-size")
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
if partSize != "" && !noResume {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
@@ -709,10 +779,16 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
|
||||
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
|
||||
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
@@ -2006,13 +2082,13 @@ func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int6
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, uploadID, headers, err := parseDriveUploadInfo(text)
|
||||
// HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次)
|
||||
uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) {
|
||||
return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args)
|
||||
}, filePath, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitArgs := map[string]any{
|
||||
"fileName": fileName,
|
||||
@@ -2219,3 +2295,49 @@ func isPermissionCLIError(err error) bool {
|
||||
var patErr *PATError
|
||||
return errors.As(err, &patErr)
|
||||
}
|
||||
|
||||
// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。
|
||||
// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、
|
||||
// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token,
|
||||
// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。
|
||||
func parseDriveDownloadInfo(text string) (string, map[string]string, error) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if h, ok := data["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dlURL, _ := data["downloadUrl"].(string)
|
||||
if dlURL == "" {
|
||||
dlURL, _ = data["resourceUrl"].(string)
|
||||
}
|
||||
if dlURL == "" {
|
||||
if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 {
|
||||
if first, ok := arr[0].(map[string]any); ok {
|
||||
dlURL, _ = first["url"].(string)
|
||||
if h, ok := first["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dlURL == "" {
|
||||
return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)")
|
||||
}
|
||||
return dlURL, headers, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
package helpers
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drive 传输增强:中心协议(token 化凭证)+ 分片下载(Range)
|
||||
//
|
||||
// 下载:文件大小 ≥ 2×part-size 时自动分片并发下载(对齐 aws s3 cp /
|
||||
// ossutil 惯例),断点续传默认开启(<dest>.dwspart 临时文件 + checkpoint
|
||||
// 元信息),服务端不支持 Range 时自动回退整流;401/403(凭证过期)自动
|
||||
// 重新调用 MCP 取新凭证后续传。
|
||||
// 上传:中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一 PUT
|
||||
// 路径(URL 服务端拼好、headers 透传、客户端零追加),401/403 重取凭证
|
||||
// 重试一次;服务端超限错误补充可读提示。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
driveDownloadDefaultPartSize = 16 * 1024 * 1024 // --part-size 默认 16MB
|
||||
driveDownloadMinPartSize = 1 * 1024 * 1024
|
||||
driveDownloadMaxPartSize = 1024 * 1024 * 1024
|
||||
driveDownloadDefaultParallel = 4
|
||||
driveDownloadMaxParallel = 8
|
||||
// 单分片常规失败指数退避重试次数(不含 401/403 凭证刷新)。
|
||||
driveDownloadPartRetries = 3
|
||||
// 单分片 401/403 凭证刷新重试上限(防止无效凭证死循环)。
|
||||
driveDownloadPartAuthRetries = 2
|
||||
|
||||
drivePartFileSuffix = ".dwspart"
|
||||
drivePartMetaSuffix = ".dwspart.meta"
|
||||
driveCheckpointVersion = 1
|
||||
uploadTypeCenterToken = "httpToCenterWithToken"
|
||||
driveTransferBodyErrCap = 2048 // 错误响应 body 截断长度
|
||||
)
|
||||
|
||||
// driveRangeClient 分片下载/探测专用 HTTP 客户端。
|
||||
// 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。
|
||||
var driveRangeClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// errCredentialRefreshVersionUnknown 凭证刷新后无法验证文件版本一致性(双方之一
|
||||
// version=0),激进策略要求清空已完成分片从头下载。
|
||||
var errCredentialRefreshVersionUnknown = errors.New("凭证刷新后无法验证文件版本一致性,需从头下载")
|
||||
|
||||
// Testable OS operation hooks (package-level for coverage injection).
|
||||
var (
|
||||
driveJsonMarshal = json.Marshal
|
||||
driveOsRename = os.Rename
|
||||
driveFileTruncate = (*os.File).Truncate
|
||||
driveFileSync = (*os.File).Sync
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// httpStatusError 表示非 2xx 的 HTTP 响应,供上层按状态码分支(401/403 重取凭证等)。
|
||||
type httpStatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// isAuthStatusError 判断错误是否为 401/403(凭证过期/无效)。
|
||||
// 兼容 typed httpStatusError 与文本形态(测试注入或历史包装的 "HTTP 401/403" 错误)。
|
||||
func isAuthStatusError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var se *httpStatusError
|
||||
if errors.As(err, &se) {
|
||||
return se.StatusCode == http.StatusUnauthorized || se.StatusCode == http.StatusForbidden
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 参数解析
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// parsePartSize 解析 --part-size 的人类可读值(16MB、512KB、1GB;纯数字按字节)。
|
||||
func parsePartSize(s string) (int64, error) {
|
||||
v := strings.TrimSpace(strings.ToUpper(s))
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--part-size 不能为空(示例: 16MB、512KB、1GB)")
|
||||
}
|
||||
unit := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(v, "GB"):
|
||||
unit, v = 1<<30, strings.TrimSuffix(v, "GB")
|
||||
case strings.HasSuffix(v, "MB"):
|
||||
unit, v = 1<<20, strings.TrimSuffix(v, "MB")
|
||||
case strings.HasSuffix(v, "KB"):
|
||||
unit, v = 1<<10, strings.TrimSuffix(v, "KB")
|
||||
case strings.HasSuffix(v, "G"):
|
||||
unit, v = 1<<30, strings.TrimSuffix(v, "G")
|
||||
case strings.HasSuffix(v, "M"):
|
||||
unit, v = 1<<20, strings.TrimSuffix(v, "M")
|
||||
case strings.HasSuffix(v, "K"):
|
||||
unit, v = 1<<10, strings.TrimSuffix(v, "K")
|
||||
case strings.HasSuffix(v, "B"):
|
||||
v = strings.TrimSuffix(v, "B")
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("--part-size 格式非法: %q(示例: 16MB、512KB、1GB)", s)
|
||||
}
|
||||
size := n * unit
|
||||
if size < driveDownloadMinPartSize || size > driveDownloadMaxPartSize {
|
||||
return 0, fmt.Errorf("--part-size 取值范围 %s - %s,当前值: %s",
|
||||
formatByteSize(driveDownloadMinPartSize), formatByteSize(driveDownloadMaxPartSize), s)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func formatByteSize(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<30 && n%(1<<30) == 0:
|
||||
return fmt.Sprintf("%dGB", n/(1<<30))
|
||||
case n >= 1<<20 && n%(1<<20) == 0:
|
||||
return fmt.Sprintf("%dMB", n/(1<<20))
|
||||
case n >= 1<<10 && n%(1<<10) == 0:
|
||||
return fmt.Sprintf("%dKB", n/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
}
|
||||
|
||||
// driveDownloadOptions 分片下载选项(由 drive download 的 flag 解析而来)。
|
||||
type driveDownloadOptions struct {
|
||||
partSize int64
|
||||
parallel int
|
||||
resume bool
|
||||
knownSize int64 // MCP 返回的 fileSize;未知(0)或小于阈值时直接整流
|
||||
nodeID string // 节点唯一标识(dentryUuid),用于生成 checkpoint 指纹
|
||||
version int // 文件版本号;0 表示最新版
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// driveDownloadOptionsFromFlags 解析并校验 --part-size / --parallel / --no-resume。
|
||||
func driveDownloadOptionsFromFlags(cmd *cobra.Command) (driveDownloadOptions, error) {
|
||||
raw, _ := cmd.Flags().GetString("part-size")
|
||||
partSize, err := parsePartSize(raw)
|
||||
if err != nil {
|
||||
return driveDownloadOptions{}, err
|
||||
}
|
||||
parallel, _ := cmd.Flags().GetInt("parallel")
|
||||
if parallel < 1 || parallel > driveDownloadMaxParallel {
|
||||
return driveDownloadOptions{}, fmt.Errorf("--parallel 取值范围 1-%d,当前值: %d", driveDownloadMaxParallel, parallel)
|
||||
}
|
||||
noResume, _ := cmd.Flags().GetBool("no-resume")
|
||||
return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: !noResume}, nil
|
||||
}
|
||||
|
||||
// parseDownloadFileSize 从 download_file 返回中提取 fileSize(缺失/非法返回 0)。
|
||||
func parseDownloadFileSize(text string) int64 {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return 0
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
switch v := data["fileSize"].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseDownloadFileVersion 从 MCP download_file 响应中提取文件当前版本号。
|
||||
// 返回 0 表示未获取到(兼容旧版 MCP 不返回 version 的场景)。
|
||||
func parseDownloadFileVersion(text string) int {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return 0
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
switch v := data["version"].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 凭证状态(分片过程共享,401/403 时 single-flight 刷新)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。
|
||||
type driveCredentialFetcher func(ctx context.Context) (url string, headers map[string]string, version int, err error)
|
||||
|
||||
type driveCredentialState struct {
|
||||
mu sync.Mutex
|
||||
fetch driveCredentialFetcher
|
||||
url string
|
||||
headers map[string]string
|
||||
gen int
|
||||
initialVersion int // 首次获取的文件版本号;0 表示未知(兼容旧 MCP)
|
||||
}
|
||||
|
||||
func (cs *driveCredentialState) current() (string, map[string]string, int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.url, cs.headers, cs.gen
|
||||
}
|
||||
|
||||
// refresh 重取凭证。仅当调用方持有的 generation 仍是最新时才真正重取
|
||||
// (其他并发分片已刷新过则直接复用新凭证,避免重复 MCP 调用)。
|
||||
func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if cs.gen > gen {
|
||||
return nil // 已被其他分片刷新
|
||||
}
|
||||
if cs.fetch == nil {
|
||||
return fmt.Errorf("下载凭证已过期且无法自动刷新")
|
||||
}
|
||||
url, headers, version, err := cs.fetch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 版本校验
|
||||
if cs.initialVersion > 0 && version > 0 {
|
||||
if version != cs.initialVersion {
|
||||
// 双方版本已知且不同 → 文件已被覆盖,终止下载防止数据不一致
|
||||
return fmt.Errorf("下载凭证刷新后文件版本已变更(%d → %d),终止下载以防数据不一致", cs.initialVersion, version)
|
||||
}
|
||||
// 版本一致,正常继续
|
||||
} else {
|
||||
// 激进策略:版本不可验证(至少一方为 0),更新凭证但返回特殊错误
|
||||
// 让上层清空已完成分片从头下载
|
||||
cs.url, cs.headers = url, headers
|
||||
cs.gen++
|
||||
return errCredentialRefreshVersionUnknown
|
||||
}
|
||||
cs.url, cs.headers = url, headers
|
||||
cs.gen++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 下载入口:整流 / 分片自动分派
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveTransferDownload 下载入口:按文件大小自动选择整流或分片下载。
|
||||
// - 文件大小未知(MCP 未返回 fileSize)或 < 2×partSize → 直接整流
|
||||
// (复用可注入的 httpGetFile,保持存量行为与测试注入边界不变,
|
||||
// 401/403 时重取凭证重试一次);
|
||||
// - 已知大小 ≥ 2×partSize → 首请求以 Range: bytes=0-0 探测:206 且解析出
|
||||
// 总长 → 分片下载;服务端返回 200(不支持 Range)→ 直接消费该响应整流落盘。
|
||||
func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, rawURL string, headers map[string]string, destPath string, opts driveDownloadOptions) error {
|
||||
if opts.partSize <= 0 {
|
||||
opts.partSize = driveDownloadDefaultPartSize
|
||||
}
|
||||
if opts.parallel <= 0 {
|
||||
opts.parallel = driveDownloadDefaultParallel
|
||||
}
|
||||
threshold := 2 * opts.partSize
|
||||
if opts.knownSize < threshold {
|
||||
// 含 knownSize==0(大小未知):不发起额外探测请求,保持存量整流行为
|
||||
return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath)
|
||||
}
|
||||
|
||||
creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers, initialVersion: opts.version}
|
||||
totalSize, fullResp, err := probeRangeSupport(ctx, creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fullResp != nil {
|
||||
// 服务端不支持 Range(忽略探测请求头返回 200 全量流):直接消费落盘
|
||||
defer fullResp.Body.Close()
|
||||
return writeStreamToFile(fullResp.Body, destPath)
|
||||
}
|
||||
curURL, curHeaders, _ := creds.current()
|
||||
if totalSize <= 0 || totalSize < threshold {
|
||||
// 总长未知(Content-Range 异常)或小于阈值:整流下载
|
||||
return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, int, error) {
|
||||
if fetch == nil {
|
||||
return "", nil, 0, fmt.Errorf("下载凭证已过期且无法自动刷新")
|
||||
}
|
||||
return fetch(fctx)
|
||||
}, curURL, curHeaders, destPath)
|
||||
}
|
||||
return downloadRangedParts(ctx, creds, destPath, totalSize, opts)
|
||||
}
|
||||
|
||||
// downloadSingleWithAuthRetry 整流下载;401/403(凭证过期)时重新调用 MCP
|
||||
// 获取新 URL+token 重试一次,仍失败走既有错误路径。
|
||||
func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetcher, urlStr string, headers map[string]string, destPath string) error {
|
||||
err := httpGetFile(ctx, urlStr, headers, destPath)
|
||||
if err == nil || !isAuthStatusError(err) || fetch == nil {
|
||||
return err
|
||||
}
|
||||
newURL, newHeaders, _, ferr := fetch(ctx)
|
||||
if ferr != nil {
|
||||
return err
|
||||
}
|
||||
return httpGetFile(ctx, newURL, newHeaders, destPath)
|
||||
}
|
||||
|
||||
// probeRangeSupport 发送 Range: bytes=0-0 探测请求验证 206/Content-Range。
|
||||
// 返回 (totalSize, nil, nil) 表示支持 Range 且已知总长;
|
||||
// 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费);
|
||||
// 401/403 时刷新凭证重试一次。
|
||||
func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
urlStr, headers, gen := creds.current()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
resp, err := driveRangeClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusPartialContent:
|
||||
total, perr := parseContentRangeTotal(resp.Header.Get("Content-Range"))
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if perr != nil {
|
||||
return 0, nil, nil // Content-Range 异常:总长未知,回退整流
|
||||
}
|
||||
return total, nil, nil
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
return 0, resp, nil
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
resp.Body.Close()
|
||||
if attempt > 0 {
|
||||
return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败")
|
||||
}
|
||||
if rerr := creds.refresh(ctx, gen); rerr != nil && !errors.Is(rerr, errCredentialRefreshVersionUnknown) {
|
||||
return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)",
|
||||
rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||||
}
|
||||
// errCredentialRefreshVersionUnknown 在探测阶段无需处理(尚无已完成分片),
|
||||
// 凭证已更新,循环继续用新凭证重试探测。
|
||||
default:
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
resp.Body.Close()
|
||||
return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。
|
||||
func parseContentRangeTotal(cr string) (int64, error) {
|
||||
idx := strings.LastIndex(cr, "/")
|
||||
if idx < 0 || idx == len(cr)-1 {
|
||||
return 0, fmt.Errorf("非法 Content-Range: %q", cr)
|
||||
}
|
||||
totalStr := cr[idx+1:]
|
||||
if totalStr == "*" {
|
||||
return 0, fmt.Errorf("Content-Range 总长未知: %q", cr)
|
||||
}
|
||||
total, err := strconv.ParseInt(totalStr, 10, 64)
|
||||
if err != nil || total <= 0 {
|
||||
return 0, fmt.Errorf("非法 Content-Range 总长: %q", cr)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// parseContentRange 解析 "bytes <start>-<end>/<total>" 格式的 Content-Range 头。
|
||||
// 返回值 start、end 为字节偏移(闭区间),total 为文件总长("*" 视为 -1)。
|
||||
func parseContentRange(header string) (start, end, total int64, err error) {
|
||||
if header == "" {
|
||||
return 0, 0, 0, fmt.Errorf("Content-Range 为空")
|
||||
}
|
||||
// 去掉 "bytes " 前缀
|
||||
const prefix = "bytes "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 前缀: %q", header)
|
||||
}
|
||||
rest := header[len(prefix):] // e.g. "0-1048575/104857600"
|
||||
// 按 "/" 分割范围与总长
|
||||
slashIdx := strings.LastIndex(rest, "/")
|
||||
if slashIdx < 0 || slashIdx == len(rest)-1 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 格式: %q", header)
|
||||
}
|
||||
rangePart := rest[:slashIdx] // "0-1048575"
|
||||
totalPart := rest[slashIdx+1:] // "104857600" 或 "*"
|
||||
// 解析 total
|
||||
if totalPart == "*" {
|
||||
total = -1
|
||||
} else {
|
||||
total, err = strconv.ParseInt(totalPart, 10, 64)
|
||||
if err != nil || total <= 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 总长: %q", header)
|
||||
}
|
||||
}
|
||||
// 按 "-" 分割 start 和 end
|
||||
dashIdx := strings.Index(rangePart, "-")
|
||||
if dashIdx < 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range 区间: %q", header)
|
||||
}
|
||||
start, err = strconv.ParseInt(rangePart[:dashIdx], 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range start: %q", header)
|
||||
}
|
||||
end, err = strconv.ParseInt(rangePart[dashIdx+1:], 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, 0, fmt.Errorf("非法 Content-Range end: %q", header)
|
||||
}
|
||||
return start, end, total, nil
|
||||
}
|
||||
|
||||
func writeStreamToFile(r io.Reader, destPath string) error {
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 分片切分与 checkpoint
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
type driveDownloadPart struct {
|
||||
index int
|
||||
offset int64
|
||||
length int64
|
||||
}
|
||||
|
||||
// splitDownloadParts 按 partSize 等长切片,末片为余量。
|
||||
func splitDownloadParts(totalSize, partSize int64) []driveDownloadPart {
|
||||
if totalSize <= 0 || partSize <= 0 {
|
||||
return nil
|
||||
}
|
||||
count := int((totalSize + partSize - 1) / partSize)
|
||||
parts := make([]driveDownloadPart, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
offset := int64(i) * partSize
|
||||
length := partSize
|
||||
if offset+length > totalSize {
|
||||
length = totalSize - offset
|
||||
}
|
||||
parts = append(parts, driveDownloadPart{index: i, offset: offset, length: length})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// driveDownloadCheckpoint 断点续传元信息,随每个分片完成原子落盘。
|
||||
type driveDownloadCheckpoint struct {
|
||||
Version int `json:"version"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
PartSize int64 `json:"partSize"`
|
||||
Completed []bool `json:"completed"`
|
||||
}
|
||||
|
||||
// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 资源 URL 计算指纹。
|
||||
// version>0 时只取 URL path(重签名不影响 checkpoint 复用);version==0(最新版)时
|
||||
// 取完整 path+query——中心协议相同 path 可能对应不同实际版本,query 中的签名/token
|
||||
// 标识了具体资源快照,防止错误复用旧 checkpoint。
|
||||
// resourceURL 为空时不影响其他字段的指纹计算(安全降级)。
|
||||
func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string {
|
||||
urlComponent := ""
|
||||
if resourceURL != "" {
|
||||
if u, err := url.Parse(resourceURL); err == nil && u != nil {
|
||||
if version == 0 {
|
||||
// 最新版:含 query 以区分不同签名(不同实际版本)
|
||||
urlComponent = u.RequestURI()
|
||||
} else {
|
||||
// 指定版本:只取 path,重签名不应废弃 checkpoint
|
||||
urlComponent = u.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlComponent)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// loadDriveDownloadCheckpoint 读取并校验 checkpoint;任一字段不匹配返回 nil(从头下载)。
|
||||
func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSize int64, partCount int) *driveDownloadCheckpoint {
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cp driveDownloadCheckpoint
|
||||
if err := json.Unmarshal(data, &cp); err != nil {
|
||||
return nil
|
||||
}
|
||||
if cp.Version != driveCheckpointVersion || cp.Fingerprint != fingerprint ||
|
||||
cp.TotalSize != totalSize || cp.PartSize != partSize || len(cp.Completed) != partCount {
|
||||
return nil
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
// save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。
|
||||
func (cp *driveDownloadCheckpoint) save(metaPath string) error {
|
||||
data, err := driveJsonMarshal(cp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := metaPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return driveOsRename(tmp, metaPath)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 分片下载引擎
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// downloadRangedParts 并发分片下载到 <dest>.dwspart,全部完成后校验总长并
|
||||
// 原子重命名为 destPath、清理 checkpoint;中途失败保留分片产物供断点续传。
|
||||
func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destPath string, totalSize int64, opts driveDownloadOptions) error {
|
||||
parts := splitDownloadParts(totalSize, opts.partSize)
|
||||
partPath := destPath + drivePartFileSuffix
|
||||
metaPath := destPath + drivePartMetaSuffix
|
||||
// 取首次凭证 URL 用于指纹计算,防止同大小文件覆盖后 checkpoint 错误复用
|
||||
initialURL, _, _ := creds.current()
|
||||
fingerprint := driveDownloadFingerprint(opts.nodeID, opts.version, totalSize, initialURL)
|
||||
|
||||
var cp *driveDownloadCheckpoint
|
||||
if opts.resume {
|
||||
cp = loadDriveDownloadCheckpoint(metaPath, fingerprint, totalSize, opts.partSize, len(parts))
|
||||
// 分片数据文件缺失或长度不符时 checkpoint 作废,从头下载
|
||||
if cp != nil {
|
||||
if fi, err := os.Stat(partPath); err != nil || fi.Size() != totalSize {
|
||||
cp = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --no-resume:清理历史断点产物,从头下载且不写 checkpoint
|
||||
_ = os.Remove(partPath)
|
||||
_ = os.Remove(metaPath)
|
||||
}
|
||||
if cp == nil {
|
||||
cp = &driveDownloadCheckpoint{
|
||||
Version: driveCheckpointVersion,
|
||||
Fingerprint: fingerprint,
|
||||
TotalSize: totalSize,
|
||||
PartSize: opts.partSize,
|
||||
Completed: make([]bool, len(parts)),
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建分片临时文件失败: %w", err)
|
||||
}
|
||||
if err := driveFileTruncate(f, totalSize); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("预分配分片临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
remaining := 0
|
||||
for _, p := range parts {
|
||||
if !cp.Completed[p.index] {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
if opts.logf != nil {
|
||||
if remaining < len(parts) {
|
||||
opts.logf("断点续传: 共 %d 分片(%s/片,并发 %d),已完成 %d,续传 %d",
|
||||
len(parts), formatByteSize(opts.partSize), opts.parallel, len(parts)-remaining, remaining)
|
||||
} else {
|
||||
opts.logf("分片下载: 共 %d 分片(%s/片,并发 %d)", len(parts), formatByteSize(opts.partSize), opts.parallel)
|
||||
}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var (
|
||||
mu sync.Mutex // 保护 cp 与 checkpoint 落盘
|
||||
wg sync.WaitGroup
|
||||
errOnce sync.Once
|
||||
firstErr error
|
||||
)
|
||||
fail := func(err error) {
|
||||
errOnce.Do(func() {
|
||||
firstErr = err
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
jobs := make(chan driveDownloadPart)
|
||||
workers := opts.parallel
|
||||
if workers > remaining {
|
||||
workers = remaining
|
||||
}
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
cp.Completed[part.index] = true
|
||||
var saveErr error
|
||||
if opts.resume {
|
||||
saveErr = cp.save(metaPath)
|
||||
}
|
||||
mu.Unlock()
|
||||
if saveErr != nil {
|
||||
fail(fmt.Errorf("写入下载断点信息失败: %w", saveErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
dispatch:
|
||||
for _, part := range parts {
|
||||
if cp.Completed[part.index] {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- part:
|
||||
case <-runCtx.Done():
|
||||
break dispatch
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
f.Close()
|
||||
if errors.Is(firstErr, errCredentialRefreshVersionUnknown) {
|
||||
// 激进策略:版本不可验证,清空 checkpoint 和分片文件防止错误续传;
|
||||
// 用户重跑时将自然从头下载。
|
||||
_ = os.Remove(metaPath)
|
||||
_ = os.Remove(partPath)
|
||||
}
|
||||
return firstErr // 其他错误保留 .dwspart 与 checkpoint,重跑同一命令自动续传
|
||||
}
|
||||
if err := driveFileSync(f); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
fi, statErr := driveFileStat(f)
|
||||
f.Close()
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
if fi.Size() != totalSize {
|
||||
return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize)
|
||||
}
|
||||
if err := driveOsRename(partPath, destPath); err != nil {
|
||||
return fmt.Errorf("重命名下载文件失败: %w", err)
|
||||
}
|
||||
_ = os.Remove(metaPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次;
|
||||
// 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries),
|
||||
// 刷新后用新凭证续传,不重下其他已完成分片。
|
||||
func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart, totalSize int64) error {
|
||||
attempt := 0
|
||||
authRetries := 0
|
||||
backoff := 500 * time.Millisecond
|
||||
for {
|
||||
urlStr, headers, gen := creds.current()
|
||||
err := fetchRangeInto(ctx, urlStr, headers, f, part, totalSize)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if isAuthStatusError(err) && authRetries < driveDownloadPartAuthRetries {
|
||||
authRetries++
|
||||
if rerr := creds.refresh(ctx, gen); rerr != nil {
|
||||
return fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, err)
|
||||
}
|
||||
continue // 凭证刷新不计常规重试、不退避
|
||||
}
|
||||
attempt++
|
||||
if attempt > driveDownloadPartRetries {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return err
|
||||
}
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。
|
||||
func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart, expectedTotal int64) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", part.offset, part.offset+part.length-1))
|
||||
resp, err := driveRangeClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
// 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据
|
||||
cr := resp.Header.Get("Content-Range")
|
||||
if cr == "" {
|
||||
return fmt.Errorf("分片响应缺少 Content-Range 头,无法验证数据偏移一致性")
|
||||
}
|
||||
{
|
||||
crStart, crEnd, crTotal, crErr := parseContentRange(cr)
|
||||
if crErr != nil {
|
||||
return fmt.Errorf("Content-Range 解析失败: %w", crErr)
|
||||
}
|
||||
wantStart := part.offset
|
||||
wantEnd := part.offset + part.length - 1
|
||||
if crStart != wantStart || crEnd != wantEnd {
|
||||
return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d",
|
||||
crStart, crEnd, wantStart, wantEnd)
|
||||
}
|
||||
if crTotal > 0 && expectedTotal > 0 && crTotal != expectedTotal {
|
||||
return fmt.Errorf("Content-Range 总长不匹配: 响应 %d, 期望 %d", crTotal, expectedTotal)
|
||||
}
|
||||
}
|
||||
n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != part.length {
|
||||
return fmt.Errorf("分片长度不符: got %d, want %d", n, part.length)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 上传:中心协议识别 + 401/403 重试 + 超限可读提示
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// parseDriveUploadType 提取 get_upload_info 返回中的 uploadType(可选字段)。
|
||||
// "httpToCenterWithToken" 表示中心协议;存量 OSS 返回无该字段,返回空串。
|
||||
func parseDriveUploadType(text string) string {
|
||||
var data map[string]any
|
||||
if json.Unmarshal([]byte(text), &data) != nil {
|
||||
return ""
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
t, _ := data["uploadType"].(string)
|
||||
return t
|
||||
}
|
||||
|
||||
// decorateUploadSizeError 为服务端上传超限错误补充可读提示。
|
||||
// 不做本地文件大小上限校验(上限为服务端动态权益值,本地硬编码会漂移)。
|
||||
func decorateUploadSizeError(err error, uploadType string) error {
|
||||
var se *httpStatusError
|
||||
if !errors.As(err, &se) {
|
||||
return err
|
||||
}
|
||||
if se.StatusCode == http.StatusRequestEntityTooLarge ||
|
||||
(uploadType == uploadTypeCenterToken && likelySizeLimitBody(se.Body)) {
|
||||
return fmt.Errorf("%w\n提示: 文件大小可能超出空间容量或上传上限,请确认文件大小、清理空间或联系管理员调整容量后重试", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func likelySizeLimitBody(body string) bool {
|
||||
b := strings.ToLower(body)
|
||||
if strings.Contains(b, "超限") || strings.Contains(b, "超出") || strings.Contains(b, "容量") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(b, "size") && (strings.Contains(b, "limit") || strings.Contains(b, "exceed") || strings.Contains(b, "over"))
|
||||
}
|
||||
|
||||
// driveUploadPut 解析上传凭证并执行 HTTP PUT;401/403(token 过期)时通过 refetch
|
||||
// 重新获取凭证重试一次。返回最终生效凭证的 uploadId(凭证刷新后以新值为准)。
|
||||
// 中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一路径:resourceUrl 为
|
||||
// 服务端拼好的完整 PUT URL(客户端零追加),headers(含 dentry-token)原样透传。
|
||||
func driveUploadPut(ctx context.Context, credText string, refetch func(context.Context) (string, error), filePath string, fileSize int64) (string, error) {
|
||||
resourceURL, uploadID, headers, err := parseDriveUploadInfo(credText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
uploadType := parseDriveUploadType(credText)
|
||||
putErr := httpPutFile(ctx, resourceURL, headers, filePath, fileSize)
|
||||
if putErr != nil && isAuthStatusError(putErr) && refetch != nil {
|
||||
text2, rerr := refetch(ctx)
|
||||
if rerr == nil {
|
||||
if url2, id2, headers2, perr := parseDriveUploadInfo(text2); perr == nil {
|
||||
uploadID = id2
|
||||
uploadType = parseDriveUploadType(text2)
|
||||
putErr = httpPutFile(ctx, url2, headers2, filePath, fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
if putErr != nil {
|
||||
return "", decorateUploadSizeError(putErr, uploadType)
|
||||
}
|
||||
return uploadID, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,7 @@
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
|
||||
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
|
||||
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
|
||||
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 |
|
||||
| `workflow create` | 创建并发布自动化工作流 |
|
||||
| `workflow update` | 更新并发布已有自动化工作流 |
|
||||
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
|
||||
@@ -14,11 +15,11 @@
|
||||
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
|
||||
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
|
||||
|
||||
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
|
||||
## DSL 入参格式与最小 Demo
|
||||
|
||||
`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
|
||||
先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
|
||||
|
||||
复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流:
|
||||
|
||||
@@ -53,6 +54,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`
|
||||
|
||||
## 命令详情
|
||||
|
||||
### workflow edit-example — 获取编辑文档与示例
|
||||
|
||||
```bash
|
||||
dws aitable workflow edit-example --format json
|
||||
```
|
||||
|
||||
该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。
|
||||
|
||||
### workflow create — 创建并发布工作流
|
||||
|
||||
```bash
|
||||
|
||||
@@ -202,14 +202,23 @@ Usage:
|
||||
Example:
|
||||
dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
|
||||
Flags:
|
||||
--node string 文件 ID (dentryUuid) (必填)
|
||||
--output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断
|
||||
--space-id string 文件所属空间 ID (可选)
|
||||
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
|
||||
--parallel int 分片下载并发数,范围 1-8 (默认 4)
|
||||
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
|
||||
```
|
||||
|
||||
> **注意**:`--output` 是必填参数,不传会报错。
|
||||
|
||||
> **大文件分片下载**:
|
||||
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
|
||||
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
|
||||
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
|
||||
|
||||
### 创建文件夹
|
||||
|
||||
```
|
||||
|
||||
@@ -134,6 +134,7 @@ Flags:
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
|
||||
| `workflow create` | 创建并发布自动化工作流 | `--base-id` `--dsl` | 按子文档 Demo 组装 DSL;必须检查返回的 `data.valid` / `issues`;create 不自动重试 |
|
||||
| `workflow update` | 更新并发布已有自动化工作流 | `--base-id` `--workflow-id` `--dsl` | 先 get 留底;提交完整目标 DSL;必须检查 `data.valid` / `issues` |
|
||||
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 |
|
||||
| `workflow create` | 创建并发布自动化工作流 |
|
||||
| `workflow update` | 更新并发布已有自动化工作流 |
|
||||
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
|
||||
@@ -14,15 +15,15 @@
|
||||
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
|
||||
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
|
||||
|
||||
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
|
||||
## DSL 入参格式与最小 Demo
|
||||
|
||||
`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。当前同步范围只包含 create/update,没有新增 DSL 文档子命令;其他 Agent 可以直接使用下面的最小 Demo 理解调用格式。
|
||||
先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。
|
||||
|
||||
复杂工作流还应注意:
|
||||
|
||||
1. 如果 Agent 运行环境直接提供 AI 表格 MCP 的 `get_workflow_dsl_docs`,可用它获取最新 DSL Guide、Schema 和示例。
|
||||
1. 使用 `workflow edit-example` 获取最新 DSL Guide、结构和示例。
|
||||
2. 涉及数据表、字段或视图的节点,先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`。
|
||||
3. create 和 update 都提交完整的 workflow-dsl/v1 JSON object,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。
|
||||
|
||||
@@ -82,6 +83,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`
|
||||
|
||||
## 命令详情
|
||||
|
||||
### workflow edit-example — 获取编辑文档与示例
|
||||
|
||||
```bash
|
||||
dws aitable workflow edit-example --format json
|
||||
```
|
||||
|
||||
该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。
|
||||
|
||||
### workflow create — 创建并发布工作流
|
||||
|
||||
```bash
|
||||
|
||||
@@ -170,14 +170,23 @@ Usage:
|
||||
Example:
|
||||
dws drive download --node <dentryUuid> --output ./report.pdf
|
||||
dws drive download --node <dentryUuid> --output ~/downloads/
|
||||
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
|
||||
Flags:
|
||||
--node string 文件 ID (dentryUuid) (必填)
|
||||
--output string 本地保存路径 (文件路径或目录,不传则保存到当前目录);如果指定目录,文件名从下载 URL 中自动推断 (可选)
|
||||
--space-id string 文件所属空间 ID (可选)
|
||||
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
|
||||
--parallel int 分片下载并发数,范围 1-8 (默认 4)
|
||||
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
|
||||
```
|
||||
|
||||
> **提示**:`--output` 为可选参数,不传则保存到当前目录,文件名从下载 URL 中自动推断。
|
||||
|
||||
> **大文件分片下载**:
|
||||
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
|
||||
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
|
||||
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
|
||||
|
||||
### 创建文件夹
|
||||
|
||||
```
|
||||
@@ -526,6 +535,7 @@ Flags:
|
||||
|
||||
> **两步下载流程**:先调用 MCP 工具获取历史版本下载 URL 和签名头,再 HTTP GET 下载文件内容到本地。
|
||||
> `--output` 指定目录时,优先从文件信息中获取原始文件名,获取不到时从下载 URL 推断。
|
||||
> 历史版本下载同样支持 `--part-size` / `--parallel` / `--no-resume` 分片下载参数,行为与最新版下载一致。
|
||||
|
||||
#### 回滚文件到指定历史版本
|
||||
|
||||
|
||||
Reference in New Issue
Block a user