fix: address agent product review feedback

This commit is contained in:
wxianfeng
2026-07-29 13:56:25 +08:00
parent 86bce64cc8
commit 81f130c483
14 changed files with 214 additions and 66 deletions
+8
View File
@@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
## [1.0.55-beta.5] - 2026-07-28
This beta validates expanded personal event consumption, complete Agent-visible
+38 -2
View File
@@ -5,8 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional Agent product identity sent through the existing `claw-type` header (for example `qwenwork`). Values are trimmed and must match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the current edition default (`openClaw` for the open-source build). / 可选 Agent 产品标识,经裁剪和校验后覆盖现有 `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional Agent runtime host observation label sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 运行形态观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
| `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_<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,6 +14,42 @@
| `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 产品与运行形态的信任模型
`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` 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.
For QwenWork, report the dimensions separately:
```bash
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.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
+11 -6
View File
@@ -24,6 +24,7 @@ import (
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
@@ -32,23 +33,27 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-provided observation
// label. CR/LF is rejected before trimming so it can never be hidden at the
// edge of a value. An unset or whitespace-only value means "do not emit".
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.TrimSpace(raw)
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
@@ -59,7 +64,7 @@ func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+14 -5
View File
@@ -21,6 +21,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -32,12 +33,14 @@ func TestParseAgentHost(t *testing.T) {
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
@@ -64,6 +67,12 @@ func TestParseAgentHost(t *testing.T) {
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
@@ -91,7 +100,7 @@ func TestParseAgentHost(t *testing.T) {
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv("DWS_AGENT_PRODUCT", "")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
+21 -12
View File
@@ -14,18 +14,17 @@
package app
import (
"os"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 产品标识;覆盖请求中的 claw-type",
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Example: "qwenwork",
})
@@ -43,20 +42,30 @@ func parseAgentProduct(raw string) (string, error) {
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// applyAgentProductOverride applies a valid non-empty runtime override after
// edition hooks have supplied their existing default. Invalid values are
// ignored here to preserve the best-effort contract of library callers that
// bypass root command validation.
func applyAgentProductOverride(headers map[string]string) map[string]string {
value, err := agentproduct.Parse(os.Getenv(agentproduct.EnvName))
if err != nil || value == "" {
return headers
// 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
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
if headers == nil {
headers = make(map[string]string)
}
+62 -8
View File
@@ -80,8 +80,8 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
t.Run("unset keeps edition default", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "enterprise-default" {
t.Fatalf("%s = %q, want enterprise-default", agentproduct.HeaderName, got)
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
@@ -105,8 +105,8 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "enterprise-default" {
t.Fatalf("%s = %q, want enterprise-default", agentproduct.HeaderName, got)
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
}
})
}
@@ -160,12 +160,16 @@ func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
}
}
func TestEffectiveClawTypeUsesEnterpriseCredentialHeaders(t *testing.T) {
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
@@ -174,12 +178,62 @@ func TestEffectiveClawTypeUsesEnterpriseCredentialHeaders(t *testing.T) {
})
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "enterprise-default" {
t.Fatalf("effectiveClawType() = %q, want enterprise-default", got)
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if !hookCalled {
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[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)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -346,7 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
t.Setenv("DWS_AGENT_PRODUCT", "")
t.Setenv(agentproduct.EnvName, "")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
+2 -1
View File
@@ -30,6 +30,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -1010,7 +1011,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
// open-source edition default remains "openClaw".
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv("DWS_AGENT_PRODUCT", "")
t.Setenv(agentproduct.EnvName, "")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
+5 -13
View File
@@ -17,7 +17,6 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -49,23 +48,16 @@ func hostControlProviderFromEnv() string {
return effectiveClawType()
}
// effectiveClawType returns the literal value injected into outbound
// `claw-type` headers. It follows the same edition-hook and environment
// override order as resolveIdentityHeaders so PAT hostControl cannot drift
// from the request wire identity.
// effectiveClawType resolves the literal value injected into outbound
// `claw-type` headers without invoking credential hooks from PAT error
// serialization. MergeHeaders implementations that set claw-type must satisfy
// the edition contract that this value is independent of the base map.
func effectiveClawType() string {
headers := make(map[string]string)
if h := edition.Get(); h != nil {
if h.MergeHeaders != nil {
headers = h.MergeHeaders(headers)
}
if h.EnterpriseCredentialHeaders != nil {
headers = h.EnterpriseCredentialHeaders(headers)
}
}
headers = applyAgentProductOverride(headers)
if v, ok := headers[agentproduct.HeaderName]; ok && v != "" {
return v
}
return edition.DefaultOSSClawType
return resolveEffectiveAgentProduct(headers)
}
+14 -6
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -1038,7 +1039,7 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
@@ -1049,15 +1050,22 @@ 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 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. Apply it after edition hooks so unset/empty input
// preserves each edition's current default, while a valid value wins
// consistently. Invalid values are ignored on this best-effort library
// path; root command execution rejects them before network access.
headers = applyAgentProductOverride(headers)
// 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
return headers
}
+2
View File
@@ -59,6 +59,8 @@ Host-owned PAT 开关:
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
+11 -4
View File
@@ -27,6 +27,9 @@ const (
EnvName = "DWS_AGENT_PRODUCT"
// HeaderName is the existing wire header used for the Agent product.
HeaderName = "claw-type"
// MaxValueBytes bounds the value because it is attached to every outbound
// MCP request. Supported values are ASCII, so bytes and characters match.
MaxValueBytes = 64
)
// ErrInvalid is returned when an Agent product value is unsafe or does not
@@ -36,18 +39,22 @@ var ErrInvalid = errors.New("invalid agent product")
var valuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// Parse normalizes and validates a caller-provided Agent product. CR/LF is
// rejected before trimming so it cannot be hidden at the edge of a value. An
// unset or whitespace-only value means "use the edition default".
// Parse normalizes and validates a caller-provided Agent product. Only
// surrounding ASCII spaces and tabs are trimmed; other control or Unicode
// whitespace remains visible to validation and is rejected. An unset or
// ASCII-whitespace-only value means "use the edition default".
func Parse(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", ErrInvalid
}
value := strings.TrimSpace(raw)
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > MaxValueBytes {
return "", ErrInvalid
}
if !valuePattern.MatchString(value) {
return "", ErrInvalid
}
+8 -1
View File
@@ -26,12 +26,13 @@ func TestParse(t *testing.T) {
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "qwenwork", raw: "qwenwork", want: "qwenwork"},
{name: "legacy open claw", raw: "openClaw", want: "openClaw"},
{name: "trim", raw: " \tqwenwork\t ", want: "qwenwork"},
{name: "generic", raw: "agent-2_alpha", want: "agent-2_alpha"},
{name: "leading digit", raw: "2nd_product", want: "2nd_product"},
{name: "maximum length", raw: strings.Repeat("a", MaxValueBytes), want: strings.Repeat("a", MaxValueBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
@@ -57,6 +58,12 @@ func TestParse(t *testing.T) {
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vqwenwork"},
{name: "form feed", raw: "qwenwork\f"},
{name: "next line", raw: "qwenwork\u0085"},
{name: "non-breaking space", raw: "\u00a0qwenwork"},
{name: "ideographic space", raw: "qwenwork\u3000"},
{name: "too long", raw: strings.Repeat("a", MaxValueBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
+16 -7
View File
@@ -72,10 +72,12 @@ type Hooks struct {
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// ClawTypeValue is the claw identity carried in message-send tool
// 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. Empty → falls back
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
// "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").
ClawTypeValue string
// PersonalEventSourceID identifies the personal-event source channel
@@ -93,9 +95,15 @@ type Hooks struct {
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
// MergeHeaders must preserve base headers. If it sets claw-type, that value
// must be deterministic and independent of the supplied base map because
// PAT error serialization resolves it with an empty map. The hook must not
// perform network, keychain, credential-refresh, or other blocking work.
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.
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth ---
@@ -185,10 +193,11 @@ func Override(h *Hooks) {
current = h
}
// ClawType returns the claw 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.
// 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.
func ClawType() string {
if v := Get().ClawTypeValue; v != "" {
return v