From f4f113bdb670bbb6d75f9b09283962fb1f0447bd Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 09:23:25 +0800 Subject: [PATCH 1/2] fix(auth): omit unresolved agent code --- CHANGELOG.md | 4 ++++ docs/agent-code.md | 13 ++++++----- internal/app/runner.go | 15 ++++++------ internal/app/runner_test.go | 15 ++++++------ internal/auth/agent_code_detect.go | 17 +++++++------- internal/auth/agent_code_detect_test.go | 31 ++++++++++++++----------- internal/auth/identity.go | 6 ++--- internal/auth/identity_agentid_test.go | 12 ++++++---- 8 files changed, 61 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f52a5a..cde8314b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Changed + +- **Agent attribution no longer invents the `custom` fallback** (`internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `internal/app/runner.go`, `docs/agent-code.md`) — when no explicit declaration or verified host signal resolves an `agent_code`, dws now leaves `x-dingtalk-dws-agent-code` and `x-dws-agent-instance-id` unset instead of sending `custom` and a derived custom instance id. Explicitly declared custom-like agent codes still pass through normally. + ## [1.0.42] - 2026-06-25 This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds. diff --git a/docs/agent-code.md b/docs/agent-code.md index f993c9fe..0083fa3b 100644 --- a/docs/agent-code.md +++ b/docs/agent-code.md @@ -1,15 +1,16 @@ # Agent identification (agent_code & agentId) -dws tags every MCP request with **which agent host is driving it** and a -**per-instance id**, so usage can be sliced by channel/instance in the data -warehouse. This page is the integration contract. +dws tags MCP requests with **which agent host is driving it** and a +**per-instance id** when the agent host can be resolved, so usage can be sliced +by channel/instance in the data warehouse. This page is the integration +contract. ## What dws sends on the wire | Header | Meaning | Granularity | |--------|---------|-------------| -| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel | -| `x-dws-agent-instance-id` | `dwsa_` derived from `machineId + agent_code` | machine × channel | +| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / explicit custom codes …) | channel | +| `x-dws-agent-instance-id` | `dwsa_` derived from `machineId + agent_code`; omitted when agent_code is empty | machine × channel | | `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine | | `X-Cli-Version` | dws CLI version (segments old vs new clients) | — | @@ -26,7 +27,7 @@ clients send no `agent_code` / instance id — treat their absence as 3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks. 4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles. -5. **T4 — `custom`:** unknown host. Never guessed. +5. **T4 — empty:** unknown host. No `custom` fallback is invented. ## Declaring your agent (recommended — the only fully-general path) diff --git a/internal/app/runner.go b/internal/app/runner.go index 11162162..b8cdae4e 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -696,20 +696,19 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } - // Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the - // per-(machine × agent_code) instance id. This is what makes agent_code - // actually report a value: previously it was sent only when the host - // injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the - // gateway logged no agent_code at all. DetectAgentCode always yields a code. + // Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the + // per-(machine × agent_code) instance id when a code is known. // // Backward-compat by design (additive, not breaking): // - x-dws-agent-id keeps its v1 meaning = machine-level install UUID // (set by id.Headers() above), so old/new clients stay comparable. - // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id. - // Old clients don't send it, which is itself a clean old/new signal. + // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id, + // sent only when x-dingtalk-dws-agent-code is non-empty. // Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched. agentCode, agentCodeSig := authpkg.DetectAgentCode() - headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig) + if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" { + headers["x-dws-agent-instance-id"] = agentInstanceID + } // Emit the CLI version on the wire so the gateway can segment old vs new // clients (and scope agent_code coverage / adoption). The header constant diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index c6598477..200e9c5e 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -377,14 +377,13 @@ func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) { headers := resolveIdentityHeaders() // The reversed env name must never be consumed. With no canonical - // declaration and no host signature, agent_code resolves to the honest - // "custom" fallback — and crucially is NOT the reversed value. - got := headers["x-dingtalk-dws-agent-code"] - if got == "compat" { - t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got) - } - if got != authpkg.AgentCodeCustom { - t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom) + // declaration and no host signature, agent_code stays empty rather than + // falling back to "custom". + if got, ok := headers["x-dingtalk-dws-agent-code"]; ok { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got) + } + if got, ok := headers["x-dws-agent-instance-id"]; ok { + t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got) } } diff --git a/internal/auth/agent_code_detect.go b/internal/auth/agent_code_detect.go index 5ffd9a65..f5ff6d70 100644 --- a/internal/auth/agent_code_detect.go +++ b/internal/auth/agent_code_detect.go @@ -23,7 +23,7 @@ // family (VSCODE_BRAND covers every VS Code fork, present and future). // - Every per-host signature below is OBSERVED on a real host (live process // env via `ps eww`, or the app bundle Info.plist), not guessed. -// - Anything unidentified falls back to AgentCodeCustom — never guess. +// - Anything unidentified stays empty — never guess or invent "custom". // - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm, // not the agent host) and fuzzy parent-process name matching. package auth @@ -33,7 +33,8 @@ import ( "strings" ) -// AgentCodeCustom is the honest fallback for any host we cannot identify. +// AgentCodeCustom is the literal code a host may explicitly declare for a +// custom integration. It is not used as an implicit fallback. const AgentCodeCustom = "custom" // hostSignature is a verified env fingerprint for a known agent host. EnvKeys @@ -66,7 +67,7 @@ var knownSignatures = []hostSignature{ // crush, goose, kimi, amazon-q, continue, ...) expose NO reliable // self-identifying env marker — only user-set API-key/config vars, which we // must not key off (a user setting GEMINI_API_KEY is not "running under -// gemini"). They therefore resolve to custom unless they declare themselves. +// gemini"). They therefore resolve to empty unless they declare themselves. // // The authoritative, fully-general path to 100% coverage is the T0 declaration // contract: a host sets DINGTALK_DWS_AGENTCODE= when it launches dws. @@ -78,7 +79,7 @@ var knownSignatures = []hostSignature{ // id is exposed via __CFBundleIdentifier and inherited by child processes the // IDE spawns (including dws), so it identifies the host even from an integrated // terminal. Verified from each app's Info.plist (2026-06-16). Only known agent -// bundles map; everything else (iTerm, Terminal, ...) falls through to custom. +// bundles map; everything else (iTerm, Terminal, ...) falls through to empty. // // macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so // this map is simply a no-op there (os.Getenv returns ""). @@ -96,7 +97,7 @@ var bundleIDToCode = map[string]string{ // T1 verified per-agent env signature (CLI/daemon agents) // T2 VSCODE_BRAND value (every VS Code fork declares its brand) // T3 macOS app bundle id (known agent bundles only) -// T4 fallback -> custom (never guess) +// T4 unresolved -> empty (never guess) func DetectAgentCode() (code string, signal string) { // T0: host explicitly declares its agent_code — highest confidence. if v, name := AgentCodeFromEnv(); v != "" { @@ -127,8 +128,8 @@ func DetectAgentCode() (code string, signal string) { } } - // T4: unknown host — honest fallback, no guessing. - return AgentCodeCustom, "fallback" + // T4: unknown host — leave agent_code empty, no guessing. + return "", "" } // normalizeAgentCode maps host-declared names/brands to canonical agent_code @@ -140,7 +141,7 @@ func normalizeAgentCode(raw string) string { s = strings.ReplaceAll(s, " ", "") switch s { case "": - return AgentCodeCustom + return "" case "claude", "claude-code", "claude_code", "claudecode": return "claudecode" case "qoder", "qoderwork": diff --git a/internal/auth/agent_code_detect_test.go b/internal/auth/agent_code_detect_test.go index 0c1274ca..f5fd8faf 100644 --- a/internal/auth/agent_code_detect_test.go +++ b/internal/auth/agent_code_detect_test.go @@ -119,25 +119,27 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) { } } -// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to -// custom. -func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) { +// An unknown bundle id (e.g. a plain terminal) must NOT be labeled. +func TestDetectAgentCode_UnknownBundleIsEmpty(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) + code, sig := DetectAgentCode() + if code != "" { + t.Fatalf("unknown bundle must stay empty, got %q", code) + } + if sig != "" { + t.Fatalf("unknown bundle signal must stay empty, got %q", sig) } } -func TestDetectAgentCode_Fallback_Custom(t *testing.T) { +func TestDetectAgentCode_FallbackEmpty(t *testing.T) { clearAgentCodeEnv(t) code, sig := DetectAgentCode() - if code != AgentCodeCustom { - t.Fatalf("want custom, got %q", code) + if code != "" { + t.Fatalf("want empty, got %q", code) } - if sig != "fallback" { - t.Fatalf("want fallback, got %q", sig) + if sig != "" { + t.Fatalf("want empty signal, got %q", sig) } } @@ -147,8 +149,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.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) + if code != "" { + t.Fatalf("noise must not decide agent_code; want empty, got %q", code) } } @@ -176,7 +178,8 @@ func TestNormalizeAgentCode(t *testing.T) { "WorkBuddy": "workbuddy", "Visual Studio Code": "vscode", "Cursor": "cursor", - "": AgentCodeCustom, + "": "", + "custom": AgentCodeCustom, "some-new-ide": "some-new-ide", } for in, want := range cases { diff --git a/internal/auth/identity.go b/internal/auth/identity.go index d93abede..6e6059d5 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string { // ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and // persisting it on first sight of an agentCode. Idempotent: the same machine // and agentCode always yields the same id, which is what makes cumulative -// per-agent_code statistics possible. An empty agentCode is treated as the -// custom bucket. +// per-agent_code statistics possible. An empty agentCode has no per-agent +// identity and returns empty. func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string { if agentCode == "" { - agentCode = AgentCodeCustom + return "" } if id.Agents == nil { id.Agents = make(map[string]*AgentEntry) diff --git a/internal/auth/identity_agentid_test.go b/internal/auth/identity_agentid_test.go index b2f2cb54..1abb99f6 100644 --- a/internal/auth/identity_agentid_test.go +++ b/internal/auth/identity_agentid_test.go @@ -74,13 +74,15 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) { } } -func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) { +func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) { dir := t.TempDir() id := EnsureExists(dir) - got := id.ResolveAgentID(dir, "", "fallback") - want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback") - if got != want { - t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want) + got := id.ResolveAgentID(dir, "", "") + if got != "" { + t.Fatalf("empty agent_code must not derive an instance id, got %q", got) + } + if len(id.Agents) != 0 { + t.Fatalf("empty agent_code must not be persisted: %+v", id.Agents) } } From b8b2d82c4d6441b2c211903f0527c451d8fdb4ca Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 18:14:25 +0800 Subject: [PATCH 2/2] Revert "fix(auth): omit unresolved agent code" This reverts commit f4f113bdb670bbb6d75f9b09283962fb1f0447bd. --- CHANGELOG.md | 4 ---- docs/agent-code.md | 13 +++++------ internal/app/runner.go | 15 ++++++------ internal/app/runner_test.go | 15 ++++++------ internal/auth/agent_code_detect.go | 17 +++++++------- internal/auth/agent_code_detect_test.go | 31 +++++++++++-------------- internal/auth/identity.go | 6 ++--- internal/auth/identity_agentid_test.go | 12 ++++------ 8 files changed, 52 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15283b65..f7b3ee98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app..` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory. -### Changed - -- **Agent attribution no longer invents the `custom` fallback** (`internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `internal/app/runner.go`, `docs/agent-code.md`) — when no explicit declaration or verified host signal resolves an `agent_code`, dws now leaves `x-dingtalk-dws-agent-code` and `x-dws-agent-instance-id` unset instead of sending `custom` and a derived custom instance id. Explicitly declared custom-like agent codes still pass through normally. - ## [1.0.43] - 2026-06-26 This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config. diff --git a/docs/agent-code.md b/docs/agent-code.md index 0083fa3b..f993c9fe 100644 --- a/docs/agent-code.md +++ b/docs/agent-code.md @@ -1,16 +1,15 @@ # Agent identification (agent_code & agentId) -dws tags MCP requests with **which agent host is driving it** and a -**per-instance id** when the agent host can be resolved, so usage can be sliced -by channel/instance in the data warehouse. This page is the integration -contract. +dws tags every MCP request with **which agent host is driving it** and a +**per-instance id**, so usage can be sliced by channel/instance in the data +warehouse. This page is the integration contract. ## What dws sends on the wire | Header | Meaning | Granularity | |--------|---------|-------------| -| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / explicit custom codes …) | channel | -| `x-dws-agent-instance-id` | `dwsa_` derived from `machineId + agent_code`; omitted when agent_code is empty | machine × channel | +| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel | +| `x-dws-agent-instance-id` | `dwsa_` derived from `machineId + agent_code` | machine × channel | | `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine | | `X-Cli-Version` | dws CLI version (segments old vs new clients) | — | @@ -27,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as 3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks. 4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles. -5. **T4 — empty:** unknown host. No `custom` fallback is invented. +5. **T4 — `custom`:** unknown host. Never guessed. ## Declaring your agent (recommended — the only fully-general path) diff --git a/internal/app/runner.go b/internal/app/runner.go index 5cf50a01..100d6bf7 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -714,19 +714,20 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } - // Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the - // per-(machine × agent_code) instance id when a code is known. + // Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the + // per-(machine × agent_code) instance id. This is what makes agent_code + // actually report a value: previously it was sent only when the host + // injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the + // gateway logged no agent_code at all. DetectAgentCode always yields a code. // // Backward-compat by design (additive, not breaking): // - x-dws-agent-id keeps its v1 meaning = machine-level install UUID // (set by id.Headers() above), so old/new clients stay comparable. - // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id, - // sent only when x-dingtalk-dws-agent-code is non-empty. + // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id. + // Old clients don't send it, which is itself a clean old/new signal. // Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched. agentCode, agentCodeSig := authpkg.DetectAgentCode() - if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" { - headers["x-dws-agent-instance-id"] = agentInstanceID - } + headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig) // Emit the CLI version on the wire so the gateway can segment old vs new // clients (and scope agent_code coverage / adoption). The header constant diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 200e9c5e..c6598477 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -377,13 +377,14 @@ func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) { headers := resolveIdentityHeaders() // The reversed env name must never be consumed. With no canonical - // declaration and no host signature, agent_code stays empty rather than - // falling back to "custom". - if got, ok := headers["x-dingtalk-dws-agent-code"]; ok { - t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got) - } - if got, ok := headers["x-dws-agent-instance-id"]; ok { - t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got) + // declaration and no host signature, agent_code resolves to the honest + // "custom" fallback — and crucially is NOT the reversed value. + got := headers["x-dingtalk-dws-agent-code"] + if got == "compat" { + t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got) + } + if got != authpkg.AgentCodeCustom { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom) } } diff --git a/internal/auth/agent_code_detect.go b/internal/auth/agent_code_detect.go index f5ff6d70..5ffd9a65 100644 --- a/internal/auth/agent_code_detect.go +++ b/internal/auth/agent_code_detect.go @@ -23,7 +23,7 @@ // family (VSCODE_BRAND covers every VS Code fork, present and future). // - Every per-host signature below is OBSERVED on a real host (live process // env via `ps eww`, or the app bundle Info.plist), not guessed. -// - Anything unidentified stays empty — never guess or invent "custom". +// - Anything unidentified falls back to AgentCodeCustom — never guess. // - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm, // not the agent host) and fuzzy parent-process name matching. package auth @@ -33,8 +33,7 @@ import ( "strings" ) -// AgentCodeCustom is the literal code a host may explicitly declare for a -// custom integration. It is not used as an implicit fallback. +// AgentCodeCustom is the honest fallback for any host we cannot identify. const AgentCodeCustom = "custom" // hostSignature is a verified env fingerprint for a known agent host. EnvKeys @@ -67,7 +66,7 @@ var knownSignatures = []hostSignature{ // crush, goose, kimi, amazon-q, continue, ...) expose NO reliable // self-identifying env marker — only user-set API-key/config vars, which we // must not key off (a user setting GEMINI_API_KEY is not "running under -// gemini"). They therefore resolve to empty unless they declare themselves. +// gemini"). They therefore resolve to custom unless they declare themselves. // // The authoritative, fully-general path to 100% coverage is the T0 declaration // contract: a host sets DINGTALK_DWS_AGENTCODE= when it launches dws. @@ -79,7 +78,7 @@ var knownSignatures = []hostSignature{ // id is exposed via __CFBundleIdentifier and inherited by child processes the // IDE spawns (including dws), so it identifies the host even from an integrated // terminal. Verified from each app's Info.plist (2026-06-16). Only known agent -// bundles map; everything else (iTerm, Terminal, ...) falls through to empty. +// bundles map; everything else (iTerm, Terminal, ...) falls through to custom. // // macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so // this map is simply a no-op there (os.Getenv returns ""). @@ -97,7 +96,7 @@ var bundleIDToCode = map[string]string{ // T1 verified per-agent env signature (CLI/daemon agents) // T2 VSCODE_BRAND value (every VS Code fork declares its brand) // T3 macOS app bundle id (known agent bundles only) -// T4 unresolved -> empty (never guess) +// T4 fallback -> custom (never guess) func DetectAgentCode() (code string, signal string) { // T0: host explicitly declares its agent_code — highest confidence. if v, name := AgentCodeFromEnv(); v != "" { @@ -128,8 +127,8 @@ func DetectAgentCode() (code string, signal string) { } } - // T4: unknown host — leave agent_code empty, no guessing. - return "", "" + // T4: unknown host — honest fallback, no guessing. + return AgentCodeCustom, "fallback" } // normalizeAgentCode maps host-declared names/brands to canonical agent_code @@ -141,7 +140,7 @@ func normalizeAgentCode(raw string) string { s = strings.ReplaceAll(s, " ", "") switch s { case "": - return "" + return AgentCodeCustom case "claude", "claude-code", "claude_code", "claudecode": return "claudecode" case "qoder", "qoderwork": diff --git a/internal/auth/agent_code_detect_test.go b/internal/auth/agent_code_detect_test.go index f5fd8faf..0c1274ca 100644 --- a/internal/auth/agent_code_detect_test.go +++ b/internal/auth/agent_code_detect_test.go @@ -119,27 +119,25 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) { } } -// An unknown bundle id (e.g. a plain terminal) must NOT be labeled. -func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) { +// 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, sig := DetectAgentCode() - if code != "" { - t.Fatalf("unknown bundle must stay empty, got %q", code) - } - if sig != "" { - t.Fatalf("unknown bundle signal must stay empty, got %q", sig) + code, _ := DetectAgentCode() + if code != AgentCodeCustom { + t.Fatalf("unknown bundle must be custom, got %q", code) } } -func TestDetectAgentCode_FallbackEmpty(t *testing.T) { +func TestDetectAgentCode_Fallback_Custom(t *testing.T) { clearAgentCodeEnv(t) code, sig := DetectAgentCode() - if code != "" { - t.Fatalf("want empty, got %q", code) + if code != AgentCodeCustom { + t.Fatalf("want custom, got %q", code) } - if sig != "" { - t.Fatalf("want empty signal, got %q", sig) + if sig != "fallback" { + t.Fatalf("want fallback, got %q", sig) } } @@ -149,8 +147,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.T) { t.Setenv("TERM_PROGRAM", "iTerm.app") t.Setenv("DWS_CHANNEL", "Qoderwork") code, _ := DetectAgentCode() - if code != "" { - t.Fatalf("noise must not decide agent_code; want empty, got %q", code) + if code != AgentCodeCustom { + t.Fatalf("noise must not decide agent_code; want custom, got %q", code) } } @@ -178,8 +176,7 @@ func TestNormalizeAgentCode(t *testing.T) { "WorkBuddy": "workbuddy", "Visual Studio Code": "vscode", "Cursor": "cursor", - "": "", - "custom": AgentCodeCustom, + "": AgentCodeCustom, "some-new-ide": "some-new-ide", } for in, want := range cases { diff --git a/internal/auth/identity.go b/internal/auth/identity.go index 6e6059d5..d93abede 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string { // ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and // persisting it on first sight of an agentCode. Idempotent: the same machine // and agentCode always yields the same id, which is what makes cumulative -// per-agent_code statistics possible. An empty agentCode has no per-agent -// identity and returns empty. +// per-agent_code statistics possible. An empty agentCode is treated as the +// custom bucket. func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string { if agentCode == "" { - return "" + agentCode = AgentCodeCustom } if id.Agents == nil { id.Agents = make(map[string]*AgentEntry) diff --git a/internal/auth/identity_agentid_test.go b/internal/auth/identity_agentid_test.go index 1abb99f6..b2f2cb54 100644 --- a/internal/auth/identity_agentid_test.go +++ b/internal/auth/identity_agentid_test.go @@ -74,15 +74,13 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) { } } -func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) { +func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) { dir := t.TempDir() id := EnsureExists(dir) - got := id.ResolveAgentID(dir, "", "") - if got != "" { - t.Fatalf("empty agent_code must not derive an instance id, got %q", got) - } - if len(id.Agents) != 0 { - t.Fatalf("empty agent_code must not be persisted: %+v", id.Agents) + got := id.ResolveAgentID(dir, "", "fallback") + want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback") + if got != want { + t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want) } }