feat: add agent host observation metadata (Aone 84760010)
This commit is contained in:
1 parent
196bf929c1
commit
3985c4c98f
5 files changed
+277
No files matched your search
@@ -5,6 +5,7 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). 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_<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) |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
envDWSAgentHost = "DWS_AGENT_HOST"
|
||||
headerDWSAgentHost = "x-dws-agent-host"
|
||||
)
|
||||
|
||||
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentHost,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
|
||||
Example: "qwenwork_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".
|
||||
func parseAgentHost(raw string) (string, error) {
|
||||
if strings.ContainsAny(raw, "\r\n") {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !agentHostPattern.MatchString(value) {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
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_-]*$",
|
||||
apperrors.WithReason("invalid_agent_host"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseAgentHost(t *testing.T) {
|
||||
valid := []struct {
|
||||
name string
|
||||
raw string
|
||||
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: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
|
||||
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAgentHost() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "carriage return", raw: "qwenwork_cloud\r"},
|
||||
{name: "line feed", raw: "\nqwenwork_cloud"},
|
||||
{name: "uppercase", raw: "Qwenwork_cloud"},
|
||||
{name: "internal space", raw: "qwenwork cloud"},
|
||||
{name: "internal tab", raw: "qwenwork\tcloud"},
|
||||
{name: "unicode", raw: "千问办公"},
|
||||
{name: "leading dash", raw: "-qwenwork"},
|
||||
{name: "leading underscore", raw: "_qwenwork"},
|
||||
{name: "control character", raw: "qwenwork\x00cloud"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
|
||||
}
|
||||
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
|
||||
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
t.Setenv(envDingtalkAgent, "agent-test")
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
mergeSawAgentHost := ""
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
mergeSawAgentHost = headers[headerDWSAgentHost]
|
||||
headers["claw-type"] = "test-claw"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
|
||||
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
|
||||
}
|
||||
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
|
||||
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
|
||||
t.Fatalf("agentCode header = %q, want agent-code-test", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "test-claw" {
|
||||
t.Fatalf("claw-type = %q, want test-claw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
|
||||
t.Setenv(envDWSAgentHost, raw)
|
||||
headers := resolveIdentityHeaders()
|
||||
if _, ok := headers[headerDWSAgentHost]; ok {
|
||||
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT_ECHO"
|
||||
t.Setenv(envDWSAgentHost, invalidValue)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
hookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("root error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("root error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -353,6 +353,13 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Validate the optional observation label before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
if flags.ClientID != "" {
|
||||
|
||||
@@ -1038,6 +1038,14 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
// DWS_AGENT_HOST is a caller-provided observation label only. 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.
|
||||
if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" {
|
||||
headers[headerDWSAgentHost] = agentHost
|
||||
}
|
||||
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user