Files
dingtalk-workspace-cli/internal/auth/agent_code_detect_test.go
T
修雨 ece91bfa3c feat(agent): accurate agent_code detection + per-channel agentId for stats (#467)
Tag each MCP request with which agent host is driving dws (agent_code) and a
per-(machine × agent_code) instance id, so usage can be sliced by channel and
instance in the data warehouse. Root cause it fixes: agent_code was only sent
when the host injected DINGTALK_DWS_AGENTCODE (~99.98% empty), so the gateway
logged none.

Detection ladder (every signature observed on a real host / official docs, not
guessed; unknown -> custom):
  T0 explicit DINGTALK_DWS_AGENTCODE
  T1 verified env signatures: claudecode (CLAUDECODE), codex (CODEX_SANDBOX),
     openclaw (OPENCLAW_BUNDLE_ROOT), hermes (HERMES_HOME)
  T2 VSCODE_BRAND value (covers the whole VS Code fork family)
  T3 macOS __CFBundleIdentifier map (qoder/cursor/vscode/workbuddy)
  T4 custom fallback

identity.json v2 (machineId + per-agent_code agents map), deterministic
dwsa_<base62> derivation, transparent v1 migration. Backward-compatible wiring:
x-dws-agent-id stays machine-level; new x-dws-agent-instance-id carries the
per-channel id; X-Cli-Version emitted so old/new clients are distinguishable.

Trust boundary (docs/agent-code.md): agent_code and the ids are self-reported
and spoofable — fit for statistics ONLY, never for auth/limit/billing.

Includes unit tests for every tier and the integration doc.
2026-06-16 17:55:29 +08:00

188 lines
5.5 KiB
Go

// 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 auth
import (
"strings"
"testing"
)
// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
// all so each case starts clean (the suite itself runs under a real host).
var agentCodeSignalEnvs = []string{
AgentCodeEnv,
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
"HERMES_HOME", "CODEX_SANDBOX",
"VSCODE_BRAND", "__CFBundleIdentifier",
"TERM_PROGRAM", "DWS_CHANNEL",
}
func clearAgentCodeEnv(t *testing.T) {
t.Helper()
for _, k := range agentCodeSignalEnvs {
t.Setenv(k, "")
}
}
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(AgentCodeEnv, "Qoder")
code, sig := DetectAgentCode()
if code != "qoder" {
t.Fatalf("want qoder, got %q", code)
}
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
t.Fatalf("want env signal, got %q", sig)
}
}
func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
cases := []struct {
env, val, want string
}{
{"CLAUDECODE", "1", "claudecode"},
{"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
{"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
{"HERMES_HOME", "/Users/x/.hermes", "hermes"},
{"CODEX_SANDBOX", "seatbelt", "codex"},
}
for _, c := range cases {
t.Run(c.env, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(c.env, c.val)
code, sig := DetectAgentCode()
if code != c.want {
t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
}
if !strings.HasPrefix(sig, "sig:") {
t.Fatalf("want sig:* signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
cases := map[string]string{
"Qoder": "qoder",
"Cursor": "cursor",
"Visual Studio Code": "vscode",
"Windsurf": "windsurf",
"Trae": "trae",
"SomeNewFork": "somenewfork", // generic coverage of future forks
}
for brand, want := range cases {
t.Run(brand, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("VSCODE_BRAND", brand)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
}
if sig != "env:VSCODE_BRAND" {
t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
}
})
}
}
func TestDetectAgentCode_BundleID_T3(t *testing.T) {
cases := map[string]string{
"com.qoder.ide": "qoder",
"com.todesktop.230313mzl4w4u92": "cursor",
"com.microsoft.VSCode": "vscode",
"com.workbuddy.workbuddy": "workbuddy",
}
for id, want := range cases {
t.Run(id, func(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", id)
code, sig := DetectAgentCode()
if code != want {
t.Fatalf("bundle %q: want %q, got %q", id, want, code)
}
if !strings.HasPrefix(sig, "bundle:") {
t.Fatalf("want bundle:* signal, got %q", sig)
}
})
}
}
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
// custom.
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("unknown bundle must be custom, got %q", code)
}
}
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
clearAgentCodeEnv(t)
code, sig := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("want custom, got %q", code)
}
if sig != "fallback" {
t.Fatalf("want fallback, got %q", sig)
}
}
// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("TERM_PROGRAM", "iTerm.app")
t.Setenv("DWS_CHANNEL", "Qoderwork")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
}
}
// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
// (T2). A CLI agent running inside an IDE reports the CLI agent.
func TestDetectAgentCode_Precedence(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("CLAUDECODE", "1") // T1
t.Setenv("VSCODE_BRAND", "Qoder") // T2
if code, _ := DetectAgentCode(); code != "claudecode" {
t.Fatalf("T1 must beat T2, got %q", code)
}
t.Setenv(AgentCodeEnv, "workbuddy") // T0
if code, _ := DetectAgentCode(); code != "workbuddy" {
t.Fatalf("T0 must beat all, got %q", code)
}
}
func TestNormalizeAgentCode(t *testing.T) {
cases := map[string]string{
"claude": "claudecode",
"Claude-Code": "claudecode",
"CLAUDECODE": "claudecode",
"Qoderwork": "qoder",
"WorkBuddy": "workbuddy",
"Visual Studio Code": "vscode",
"Cursor": "cursor",
"": AgentCodeCustom,
"some-new-ide": "some-new-ide",
}
for in, want := range cases {
if got := normalizeAgentCode(in); got != want {
t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
}
}
}