Compare commits

..
Author SHA1 Message Date
github-actions[bot] b2cbca2762 chore: update beta formula for v1.0.56-beta.3 [skip ci] 2026-08-03 12:55:19 +00:00
chichuan 9ce95db08e Merge pull request #855 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.3
docs: seal v1.0.56-beta.3 changelog
2026-08-03 20:39:44 +08:00
chichuan e99c20a0a1 docs: seal v1.0.56-beta.3 changelog 2026-08-03 20:34:17 +08:00
github-actions[bot] 96bfae079a Merge pull request #846 from wxianfeng/fix/event-unix-socket-tmpdir
fix(event): use secure Unix bus runtime directory
2026-08-03 16:04:41 +08:00
wxianfeng bb18cdba3b Merge upstream/main into fix/event-unix-socket-tmpdir 2026-08-03 15:45:38 +08:00
wxianfeng 015a1f85ca fix(event): satisfy platform coverage gate 2026-08-03 15:42:17 +08:00
github-actions[bot] 8854e0d1d4 Merge pull request #851 from abucraft/codex/aitable-workflow-docs
feat: add aitable workflow edit example command
2026-08-03 07:01:27 +00:00
镜玄 22862508b8 feat: add aitable workflow edit example command 2026-08-03 14:47:59 +08:00
wxianfeng 5459bcc524 fix(event): secure Unix bus runtime directory 2026-08-03 11:40:01 +08:00
wxianfeng 029c665029 fix(event): place Unix bus sockets in temp dir 2026-07-31 18:01:34 +08:00
github-actions[bot] 187787040b chore: update beta formula for v1.0.56-beta.2 [skip ci] 2026-07-30 15:00:34 +00:00
chichuan cd6e854bf1 Merge pull request #843 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission-final
docs(release): clarify v1.0.56-beta.2 skill routing
2026-07-30 22:48:59 +08:00
chichuan 61124f8768 docs(release): clarify v1.0.56-beta.2 skill routing 2026-07-30 22:44:52 +08:00
github-actions[bot] 6cfeac3179 Merge pull request #842 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission
docs(release): complete v1.0.56-beta.2 notes
2026-07-30 22:43:08 +08:00
chichuan acd293cc83 docs(release): complete v1.0.56-beta.2 notes 2026-07-30 22:40:59 +08:00
github-actions[bot] d2045c3441 Merge pull request #841 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2
docs(release): seal v1.0.56-beta.2 changelog
2026-07-30 22:38:42 +08:00
chichuan 4de41c27c7 docs(release): add v1.0.56-beta.2 notes 2026-07-30 22:36:34 +08:00
github-actions[bot] 5df2860e66 Merge pull request #831 from wxianfeng/fix/agent-product-header-separation
fix: separate Agent Product from claw-type
2026-07-30 14:35:55 +00:00
chichuan f3390b6875 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 22:16:13 +08:00
github-actions[bot] 55f25d8d48 Merge pull request #835 from DingTalk-Real-AI/codex/skill-token-shallow-water
perf(skills): reduce common-path context loading
2026-07-30 14:04:51 +00:00
chichuan 67fbf65916 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:54:11 +08:00
chichuan 1fb1ae3e23 Merge remote-tracking branch 'origin/main' into codex/pr-831-conflict-fix
# Conflicts:
#	CHANGELOG.md
2026-07-30 21:47:14 +08:00
chichuan 1d1c06aaae Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:17:23 +08:00
chichuan a54ee24acb Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 20:21:34 +08:00
chichuan 320582f98c Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 19:04:13 +08:00
wxianfeng c569def067 docs: clarify disabled AI tag argument shape 2026-07-30 16:55:46 +08:00
wxianfeng 125a101487 fix: separate Agent Product from claw-type 2026-07-30 14:34:22 +08:00
42 changed files with 1551 additions and 215 deletions
+33
View File
@@ -6,6 +6,39 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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
Agent Product observability and IM display identity from the stable
edition-owned PAT and routing identity, and reduces common-path Skill context
loading without changing the public command or Runtime Schema surface.
### Changed
- **Agent Product identity separation** (#831) — sends `DWS_AGENT_PRODUCT` through the new `x-dws-agent-product` observability Header and uses a valid non-empty value for the IM `clawType` display label whenever `--ai-tag` is enabled. Because `--ai-tag` defaults to `true`, callers that set `DWS_AGENT_PRODUCT` change the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls preserve their existing wire shape by sending an empty IM `clawType`, while shortcut calls omit the argument. Unset or empty Product values omit the Header and preserve the active edition's IM display default.
- **Agent Host dimension convention** (#831) — new integrations should send the runtime form (`cloud` or `desktop`) through `DWS_AGENT_HOST` and report the product separately through `DWS_AGENT_PRODUCT`. Legacy combined labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
- **Reduced common-path Skill context** (#835) — keeps the complete 97-command Chat Shortcut inventory in Runtime Catalog and leaf Schema while routing common intents through compact Skill tables and references. When an exact command path is already known, the mono Skill no longer requires eager loading of a complete product reference. The generated Skill policy now detects drift, forced full-reference loading, and context-budget regressions; the common Chat plus shared activation estimate drops from 7,301 to 4,771 `o200k_base` tokens without changing the 845-tool Schema surface.
### Fixed
- **Stable PAT/routing identity** (#831) — restores the CLI-emitted open-source HTTP `claw-type` and PAT `hostControl.clawType` to the edition-fixed `openClaw` value. `DWS_AGENT_PRODUCT` no longer changes those wire values, and the client continues to derive PAT, authentication, routing, and Discovery behaviour from the existing independent signals.
- **Portable generated Skill validation** (#835) — resolves the mono Skill name by scanning upward from the generated target, keeping `--check` independent of the repository checkout path and preventing false drift failures when an ancestor directory resembles a Skill name.
## [1.0.56-beta.1] - 2026-07-30
This beta starts the v1.0.56 line on top of v1.0.55 and packages PRs #817,
+11 -11
View File
@@ -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.1"
version "1.0.56-beta.3"
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.1/dws-darwin-arm64.tar.gz"
sha256 "26b696a7807f87cf9c6a24634e08008bac0501f2664a85a8355904309068d55e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz"
sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.1/dws-darwin-amd64.tar.gz"
sha256 "28daa9fa1fc8131e3567a39042d5f34efc61d5901bf7d88a67f075a4ff366be6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz"
sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b"
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.1/dws-linux-arm64.tar.gz"
sha256 "5f9ead9b939d2db31354797cc7b7431f27aaca41f171d256d4d4e9269cc66d51"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz"
sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.1/dws-linux-amd64.tar.gz"
sha256 "06b950676cefdd8e054c76af32cd9758c4c7786908d654281d9c65e7a3d575cf"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz"
sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.1/dws-skills.zip"
sha256 "7448ccfc8e189b3d76b3e8f11f5dedc4f5a5b53380f9059e6af4c63a38e38f28"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip"
sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2"
end
def install
+47 -25
View File
@@ -5,8 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,23 +14,36 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
signals. They are not credentials, attestations, or proof of the calling
host's identity. The CLI validates and emits `x-dws-agent-product` and
`x-dws-agent-host`, but does not use either value to derive its authentication,
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
services own and must document their own contracts for these caller-declared
Headers.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
Service integrators should treat both Headers as untrusted input, allowlist
expected values, and should not grant access, bypass authentication, or skip
authorization solely because a Header claims a particular Product or Host.
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
however, a valid non-empty Product value is used as the `clawType` tool
argument so the delivered message carries the matching “Send from AI” label.
Because `--ai-tag` defaults to `true`, this display change is enabled by
default for callers that set Product. With `--ai-tag=false`, native
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
calls omit the argument; this client does not assume downstream services treat
an empty value and an absent key as equivalent. The display-value precedence
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
edition's `ClawTypeValue`, then `openClaw`.
Do not set arbitrary Product values that the target downstream and IM services
have not explicitly enabled; an unknown value may be ignored or may not render
the expected label.
For QwenWork, report the dimensions separately:
@@ -39,16 +52,25 @@ DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
Older combined Host labels such as `qwenwork_cloud` still satisfy the generic
syntax for compatibility, but new integrations should use the two-dimensional
convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
## Exit Codes / 退出码
+1 -1
View File
@@ -33,7 +33,7 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
Example: "cloud",
})
}
+19 -18
View File
@@ -24,8 +24,8 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
Example: "qwenwork",
})
}
@@ -47,25 +47,26 @@ func invalidAgentProductError() error {
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
func resolveEditionClawType(headers map[string]string) string {
if value := headers["claw-type"]; value != "" {
return value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
return edition.DefaultOSSClawType
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
// applyAgentProductHeader injects only a valid, non-empty caller-declared
// product. Invalid values are omitted on library paths that bypass root
// validation; normal CLI execution rejects them before network access.
func applyAgentProductHeader(headers map[string]string) map[string]string {
value, err := agentproduct.ResolveFromEnv("")
if err != nil || value == "" {
if headers != nil {
delete(headers, agentproduct.HeaderName)
}
return headers
}
if headers == nil {
headers = make(map[string]string)
}
+100 -34
View File
@@ -26,13 +26,16 @@ import (
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
@@ -59,38 +62,46 @@ func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "credential-must-not-win"
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Run("unset omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Run("valid Product is final without changing claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
@@ -102,21 +113,48 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Run("invalid library input omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
func TestApplyAgentProductHeader(t *testing.T) {
t.Run("valid value allocates headers", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
headers := applyAgentProductHeader(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
})
for _, tc := range []struct {
name string
value string
}{
{name: "empty value", value: ""},
{name: "invalid value", value: "qwen work"},
} {
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, tc.value)
headers := applyAgentProductHeader(map[string]string{
agentproduct.HeaderName: "must-not-leak",
"x-preserved": "yes",
})
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
}
if got := headers["x-preserved"]; got != "yes" {
t.Fatalf("x-preserved = %q, want yes", got)
}
})
}
}
@@ -167,17 +205,17 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
@@ -186,7 +224,7 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -195,20 +233,24 @@ func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("HTTP claw-type = %q, want wukong", got)
}
if got := edition.ClawType(); got != "qwenwork" {
t.Fatalf("message clawType = %q, want qwenwork", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -218,7 +260,7 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
@@ -234,25 +276,49 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(map[string]string) map[string]string {
return nil
},
})
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
func TestEffectiveClawTypeIgnoresAgentProduct(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
t.Fatalf("hostControl.clawType = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "")
+3 -3
View File
@@ -569,9 +569,9 @@ func handlePatAuthCheck(
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
// used for this decision — its edition-fixed value is forwarded on the
// wire and surfaced in hostControl for traceability. DWS_AGENT_PRODUCT
// does not affect this PAT contract.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+3 -3
View File
@@ -1007,11 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
// hostControl.clawType. DWS_AGENT_PRODUCT is also set to demonstrate
// that Product does not change the open-source fixed "openClaw" value.
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
+3 -4
View File
@@ -29,9 +29,8 @@ import (
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// its fixed value; DWS_AGENT_PRODUCT is a separate observability and IM
// message-display signal and never affects this PAT value.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -59,5 +58,5 @@ func effectiveClawType() string {
headers = h.MergeHeaders(headers)
}
}
return resolveEffectiveAgentProduct(headers)
return resolveEditionClawType(headers)
}
+20 -13
View File
@@ -1005,9 +1005,8 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// x-dingtalk-agent header. It does NOT influence the edition-fixed
// claw-type or the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
@@ -1068,22 +1067,30 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if headers == nil {
headers = make(map[string]string)
}
// claw-type is the edition-fixed routing/PAT identity. Agent Product is a
// separate caller-declared observability and IM-display dimension.
clawType := resolveEditionClawType(headers)
headers["claw-type"] = clawType
headers = applyAgentProductHeader(headers)
agentProduct, hasAgentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
// Credential hooks cannot alter either identity dimension. Restore the
// fixed claw-type and the validated Product Header (or its absence).
headers["claw-type"] = clawType
if hasAgentProduct {
headers[agentproduct.HeaderName] = agentProduct
} else {
delete(headers, agentproduct.HeaderName)
}
return headers
}
@@ -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:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
"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
},
+10 -10
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
"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
},
+36 -9
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:862734fb976a4c9c78d010b7391c74822f37805f5acc650551f4ac60f701c13a",
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
"source_hash": "sha256:1b4f0e6e6fe49115137ff81717462374b2fdd05ad4ca1bfb1b2dd8272aa6bad8",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
"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": [
@@ -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": [
+3 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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)
}
+19 -1
View File
@@ -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)
}
}
+57 -5
View File
@@ -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
}
+98 -5
View File
@@ -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)
}
+138 -5
View File
@@ -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)
+13 -1
View File
@@ -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 {
@@ -22,6 +22,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -217,6 +218,73 @@ func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
}
}
func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
if got := caller.calls[0].args["clawType"]; got != "qwenwork" {
t.Fatalf("clawType = %#v, want qwenwork", got)
}
})
}
}
func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
got, present := caller.calls[0].args["clawType"]
if !present || got != "" {
t.Fatalf("clawType = %#v, present = %v; want present empty string", got, present)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+9 -6
View File
@@ -55,12 +55,15 @@ Host-owned PAT 开关:
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
CLI 侧不再拉起任何本地浏览器 / 轮询。
服务端 Agent 产品标签 claw-type:
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
服务端 PAT / 路由标签 claw-type:
开源构建固定在出站 MCP 请求中注入 claw-type: openClaw,
hostControl.clawType 会回填相同值。DWS_AGENT_PRODUCT 不会修改它。
Agent 产品标识 DWS_AGENT_PRODUCT:
合法非空值作为 x-dws-agent-product 请求头发送,供下游日志 / BI 使用;
本客户端不使用该值派生或改变 PAT、鉴权或路由,下游使用契约由对应
服务自行定义。同时该值作为启用 --ai-tag 时 IM 消息小尾巴的 clawType
参数。未设置或为空时请求头省略,小尾巴回退发行版默认值。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
@@ -24,6 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -99,6 +100,8 @@ func newPlatformCoverageRoot() *cobra.Command {
}
func TestCrossPlatformCoverageAIMessageTag(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
argv []string
@@ -125,8 +128,8 @@ func TestCrossPlatformCoverageAIMessageTag(t *testing.T) {
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("last call = %s/%s, want chat/send_personal_message", send.product, send.tool)
}
if got := send.args["clawType"]; got != edition.ClawType() {
t.Fatalf("clawType = %#v, want %q", got, edition.ClawType())
if got := send.args["clawType"]; got != "qwenwork" {
t.Fatalf("clawType = %#v, want qwenwork", got)
}
})
}
+4 -3
View File
@@ -12,7 +12,7 @@
// limitations under the License.
// Package agentproduct defines the caller-provided Agent product identity
// carried on outbound DWS requests.
// used for outbound observability and IM message display.
package agentproduct
import (
@@ -25,8 +25,9 @@ import (
const (
// EnvName is the runtime override for the Agent product identity.
EnvName = "DWS_AGENT_PRODUCT"
// HeaderName is the existing wire header used for the Agent product.
HeaderName = "claw-type"
// HeaderName is the observability header used for the Agent product.
// It is intentionally separate from the routing/PAT claw-type header.
HeaderName = "x-dws-agent-product"
// MaxValueBytes bounds the value because it is attached to every outbound
// MCP request. Supported values are ASCII, so bytes and characters match.
MaxValueBytes = 64
+9
View File
@@ -103,3 +103,12 @@ func TestResolveFromEnv(t *testing.T) {
}
})
}
func TestHeaderNameIsSeparateFromClawType(t *testing.T) {
if HeaderName != "x-dws-agent-product" {
t.Fatalf("HeaderName = %q, want x-dws-agent-product", HeaderName)
}
if HeaderName == "claw-type" {
t.Fatal("Agent Product observability Header must not reuse claw-type")
}
}
+6 -6
View File
@@ -15,18 +15,18 @@ package edition
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
// DefaultOSSClawType is the default wire value for request header claw-type
// in the open-source build. DWS_AGENT_PRODUCT may explicitly override the
// request identity; unrelated caller inputs such as DINGTALK_AGENT do not.
// DefaultOSSClawType is the fixed wire value for request header claw-type in
// the open-source build. It is intentionally independent of caller-provided
// environment variables so PAT and routing behaviour stays predictable.
const DefaultOSSClawType = "openClaw"
// defaultHooks returns the open-source edition defaults.
//
// MergeHeaders is the only hook that ships with behaviour: it supplies
// DefaultOSSClawType so every open-source MCP request has a stable default.
// The core applies an optional DWS_AGENT_PRODUCT override after edition hooks.
// All other fields are nil — the internal code interprets nil as "use
// standard open-source behaviour".
// DWS_AGENT_PRODUCT is sent through a separate observability Header and never
// changes claw-type. All other fields are nil — the internal code interprets
// nil as "use standard open-source behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
+21 -14
View File
@@ -21,6 +21,7 @@ import (
"context"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/spf13/cobra"
)
@@ -87,10 +88,10 @@ type Hooks struct {
// ClawTypeValue is the display identity carried only in message-send tool
// arguments (parameter clawType) so the IM server can render the
// "Send from AI" indicator on delivered messages. It is intentionally
// separate from the HTTP claw-type Agent Product header and is not
// overridden by DWS_AGENT_PRODUCT. Empty → DefaultOSSClawType; overlays
// set their own message-display value (e.g. "wukong").
// "Send from AI" indicator on delivered messages. A valid non-empty
// DWS_AGENT_PRODUCT overrides this display default, but never the separate
// HTTP claw-type routing/PAT header. Empty → DefaultOSSClawType; overlays
// set their own message-display default (e.g. "wukong").
ClawTypeValue string
// PersonalEventSourceID identifies the personal-event source channel
@@ -115,8 +116,8 @@ type Hooks struct {
MergeHeaders func(base map[string]string) map[string]string
// --- EnterpriseCredential HTTP headers ---
// This hook is only for credential material. It must not set claw-type;
// the core reasserts that Header after the hook returns.
// This hook is only for credential material. It must not set claw-type or
// x-dws-agent-product; the core reasserts both after the hook returns.
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth ---
@@ -206,16 +207,22 @@ func Override(h *Hooks) {
current = h
}
// ClawType returns the message-display identity for the active edition,
// falling back to DefaultOSSClawType when the overlay does not set one.
// Message-send helpers attach this value as the clawType tool argument so the
// IM server can label delivered messages as sent via AI. It is a separate axis
// from the HTTP claw-type header and is not affected by DWS_AGENT_PRODUCT.
// ClawType returns the message-display identity for the active edition.
// A valid non-empty DWS_AGENT_PRODUCT wins; otherwise the active edition's
// ClawTypeValue (or DefaultOSSClawType) is used. Message-send helpers attach
// this value as the clawType tool argument so the IM server can label delivered
// messages as sent via AI. It never changes the HTTP claw-type routing/PAT
// header.
func ClawType() string {
if v := Get().ClawTypeValue; v != "" {
return v
fallback := Get().ClawTypeValue
if fallback == "" {
fallback = DefaultOSSClawType
}
return DefaultOSSClawType
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
// PersonalEventSourceID returns the source channel for user-level events.
+29 -1
View File
@@ -13,9 +13,14 @@
package edition
import "testing"
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestClawTypeDefaultsToOSSValue(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
prev := Get()
defer Override(prev)
@@ -26,6 +31,7 @@ func TestClawTypeDefaultsToOSSValue(t *testing.T) {
}
func TestClawTypeUsesOverlayValue(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
prev := Get()
defer Override(prev)
@@ -35,6 +41,28 @@ func TestClawTypeUsesOverlayValue(t *testing.T) {
}
}
func TestClawTypeUsesValidAgentProduct(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
prev := Get()
defer Override(prev)
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
if got := ClawType(); got != "qwenwork" {
t.Fatalf("ClawType() = %q, want qwenwork", got)
}
}
func TestClawTypeInvalidAgentProductFallsBackToOverlay(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
prev := Get()
defer Override(prev)
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
if got := ClawType(); got != "wukong" {
t.Fatalf("ClawType() = %q, want overlay fallback wukong", got)
}
}
func TestOpenStaticServersIncludesCoreProducts(t *testing.T) {
servers := openStaticServers()
byID := make(map[string]ServerInfo, len(servers))
@@ -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
@@ -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