fix: address agent product review feedback
This commit is contained in:
@@ -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
@@ -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 / 描述 |
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user