From 81f130c48331b8cb55df5302ade59c0c5e1e616a Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Wed, 29 Jul 2026 13:56:25 +0800 Subject: [PATCH] fix: address agent product review feedback --- CHANGELOG.md | 8 +++ docs/reference.md | 40 +++++++++++++- internal/app/agent_host.go | 17 +++--- internal/app/agent_host_test.go | 19 +++++-- internal/app/agent_product.go | 33 +++++++----- internal/app/agent_product_test.go | 70 ++++++++++++++++++++++--- internal/app/app_small_coverage_test.go | 3 +- internal/app/pat_auth_retry_test.go | 3 +- internal/app/pat_hostcontrol_wire.go | 18 ++----- internal/app/runner.go | 20 ++++--- internal/pat/pat.go | 2 + pkg/agentproduct/agent_product.go | 15 ++++-- pkg/agentproduct/agent_product_test.go | 9 +++- pkg/edition/edition.go | 23 +++++--- 14 files changed, 214 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f40617df..2a4d5ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/reference.md b/docs/reference.md index 0e93487c..1cf4555e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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__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 / 描述 | diff --git a/internal/app/agent_host.go b/internal/app/agent_host.go index 80e75308..37413cc3 100644 --- a/internal/app/agent_host.go +++ b/internal/app/agent_host.go @@ -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"), ) } diff --git a/internal/app/agent_host_test.go b/internal/app/agent_host_test.go index e5452844..38c5e6a0 100644 --- a/internal/app/agent_host_test.go +++ b/internal/app/agent_host_test.go @@ -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") diff --git a/internal/app/agent_product.go b/internal/app/agent_product.go index 1e824e64..78a0b91f 100644 --- a/internal/app/agent_product.go +++ b/internal/app/agent_product.go @@ -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) } diff --git a/internal/app/agent_product_test.go b/internal/app/agent_product_test.go index 6e6a8c5f..939553d5 100644 --- a/internal/app/agent_product_test.go +++ b/internal/app/agent_product_test.go @@ -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) { diff --git a/internal/app/app_small_coverage_test.go b/internal/app/app_small_coverage_test.go index 0c2b3ca7..2da69c02 100644 --- a/internal/app/app_small_coverage_test.go +++ b/internal/app/app_small_coverage_test.go @@ -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) diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index ac29d21d..07696f81 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -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) { diff --git a/internal/app/pat_hostcontrol_wire.go b/internal/app/pat_hostcontrol_wire.go index 1ae9c2b7..8b21a472 100644 --- a/internal/app/pat_hostcontrol_wire.go +++ b/internal/app/pat_hostcontrol_wire.go @@ -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) } diff --git a/internal/app/runner.go b/internal/app/runner.go index f4bd8ef3..0468e35f 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -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 } diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 365fcf3d..c01217fc 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -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, diff --git a/pkg/agentproduct/agent_product.go b/pkg/agentproduct/agent_product.go index 792b268f..db01f6c8 100644 --- a/pkg/agentproduct/agent_product.go +++ b/pkg/agentproduct/agent_product.go @@ -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 } diff --git a/pkg/agentproduct/agent_product_test.go b/pkg/agentproduct/agent_product_test.go index ba1a8e6b..cacb440b 100644 --- a/pkg/agentproduct/agent_product_test.go +++ b/pkg/agentproduct/agent_product_test.go @@ -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) { diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index fbdf52f3..987c3109 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -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