Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f44a1efd | ||
|
|
1a7ba01e36 | ||
|
|
6310dcc39e | ||
|
|
f1a68f2424 | ||
|
|
497e4f87d8 | ||
|
|
a8d009aec8 | ||
|
|
1f413fa322 | ||
|
|
ece91bfa3c | ||
|
|
ff33114b2c | ||
|
|
cdd8414891 | ||
|
|
2a82d07311 |
@@ -6,6 +6,41 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.39] - 2026-06-18
|
||||
|
||||
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
|
||||
|
||||
## [1.0.38] - 2026-06-16
|
||||
|
||||
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
|
||||
|
||||
### Added
|
||||
|
||||
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
|
||||
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
|
||||
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
|
||||
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
|
||||
|
||||
## [1.0.37] - 2026-06-11
|
||||
|
||||
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
|
||||
|
||||
## [1.0.36] - 2026-06-10
|
||||
|
||||
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
|
||||
## 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_<base62>` 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) | — |
|
||||
|
||||
`x-dws-agent-id` keeps its original machine-level meaning for backward
|
||||
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
|
||||
clients send no `agent_code` / instance id — treat their absence as
|
||||
"legacy/unknown", not an error.
|
||||
|
||||
## How `agent_code` is resolved (confidence ladder)
|
||||
|
||||
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
|
||||
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
|
||||
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
|
||||
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.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
Auto-detection cannot cover every agent: most terminal agents (gemini/
|
||||
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
|
||||
continue, …) expose **no reliable self-identifying env var** — only user-set
|
||||
API keys, which must not be used as identity. The robust answer is: **the host
|
||||
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
|
||||
server.** This is accurate for any agent, on any OS, and is future-proof.
|
||||
|
||||
MCP server config example (JSON-style hosts):
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"dingtalk-workspace": {
|
||||
"command": "dws",
|
||||
"args": ["mcp", "..."],
|
||||
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical codes
|
||||
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
|
||||
self-reported, best-effort signals, NOT an authenticated identity.**
|
||||
|
||||
- `agent_code`: every declaration/auto-detect signal is an env var the
|
||||
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
|
||||
`claudecode`).
|
||||
- The ids are **even easier to forge**: they are generated, stored, and sent
|
||||
entirely client-side. `machineId` is a random UUID in the plaintext
|
||||
`~/.dws/identity.json` (which the user owns), and the instance id is just
|
||||
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
|
||||
header — lets anyone mint, split, rotate, or impersonate ids at will. The
|
||||
`dwsa_` prefix does NOT make it a secure identifier.
|
||||
|
||||
- ✅ **Fit for statistics / observability** (the intended use): there is no
|
||||
incentive to misreport one's own agent, and real hosts emit real signals, so
|
||||
aggregate per-channel metrics are reliable in practice.
|
||||
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
|
||||
revocation.** Anything where a party benefits from lying must not trust this
|
||||
field. For control-plane use you need a gateway-issued **authoritative**
|
||||
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
|
||||
heavier mechanism, deliberately out of scope here.
|
||||
|
||||
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
|
||||
|
||||
## Gateway side (required for the data to land)
|
||||
|
||||
dws sending the headers is necessary but not sufficient. The gateway must:
|
||||
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
|
||||
to the upstream-header pass-through allowlist (otherwise they are stripped);
|
||||
2. log them as fields, and deliver them to the warehouse (alongside the
|
||||
existing flow-control / execution logs).
|
||||
@@ -116,6 +116,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
tokenData, err = provider.Login(loginCtx)
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||
@@ -126,6 +127,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
if err != nil {
|
||||
@@ -186,7 +188,6 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
_ = cmd.Flags().MarkHidden("token-url")
|
||||
_ = cmd.Flags().MarkHidden("refresh-url")
|
||||
_ = cmd.Flags().MarkHidden("login-timeout")
|
||||
_ = cmd.Flags().MarkHidden("no-browser")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
"agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -687,9 +687,31 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
// existed but was never set; wire it here.
|
||||
if version != "" {
|
||||
headers[transport.HeaderVersion] = version
|
||||
}
|
||||
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": sessionID,
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
|
||||
@@ -328,6 +328,65 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "qoder")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
|
||||
// x-dws-agent-id stays machine-level (v1 install UUID): non-empty and NOT
|
||||
// the dwsa_ instance form — this is the cross-version continuity anchor.
|
||||
machineID := headers["x-dws-agent-id"]
|
||||
if machineID == "" {
|
||||
t.Fatal("x-dws-agent-id must stay populated (machine-level)")
|
||||
}
|
||||
if strings.HasPrefix(machineID, "dwsa_") {
|
||||
t.Fatalf("x-dws-agent-id must remain machine-level, got instance form %q", machineID)
|
||||
}
|
||||
|
||||
// x-dws-agent-instance-id is the NEW per-(machine × agent_code) id.
|
||||
instID := headers["x-dws-agent-instance-id"]
|
||||
if !strings.HasPrefix(instID, "dwsa_") {
|
||||
t.Fatalf("x-dws-agent-instance-id must be a derived instance id, got %q", instID)
|
||||
}
|
||||
if instID == machineID {
|
||||
t.Fatal("instance id must differ from machine id")
|
||||
}
|
||||
|
||||
// CLI version must now be on the wire so the gateway can segment old/new.
|
||||
if headers[transport.HeaderVersion] == "" {
|
||||
t.Fatalf("%s must be emitted", transport.HeaderVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
|
||||
// Isolate from ambient agent-host detection signals so this test asserts
|
||||
// only the reversed-env-name behavior (the suite itself may run under
|
||||
// Claude Code / Qoder / VS Code, whose signals would otherwise be detected).
|
||||
for _, k := range []string{
|
||||
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
|
||||
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE", "HERMES_HOME",
|
||||
"CODEX_SANDBOX", "VSCODE_BRAND", "__CFBundleIdentifier",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(envDingtalkSessionID, "ding-session")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -56,6 +57,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -68,6 +70,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
@@ -88,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -108,6 +112,7 @@ type upgradeOptions struct {
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
@@ -298,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
|
||||
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
|
||||
// If anything fails in Phase 1, no files on disk are modified.
|
||||
|
||||
// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
|
||||
// touching the filesystem. Kept side-effect-free and writer-injectable so the
|
||||
// --dry-run contract can be asserted in tests.
|
||||
func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
|
||||
fmt.Fprintf(w, " 将执行以下操作:\n")
|
||||
fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
|
||||
fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
|
||||
if hasSkills {
|
||||
fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
|
||||
}
|
||||
fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
|
||||
fmt.Fprintf(w, " [4/5] 解压并验证\n")
|
||||
replaceStep := "替换二进制"
|
||||
if hasSkills {
|
||||
replaceStep += " 并安装技能包"
|
||||
}
|
||||
fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
|
||||
}
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
|
||||
@@ -339,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
|
||||
// --dry-run: preview only. Resolve the platform asset so a missing build is
|
||||
// still reported, then describe the steps that *would* run and return before
|
||||
// any side effect (no backup, no download, no replace). Matches the global
|
||||
// flag's contract: "预览操作内容,不实际执行".
|
||||
if opts.dryRun {
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否升级? [y/N] ")
|
||||
|
||||
@@ -430,6 +430,60 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
// Regression for #364: --dry-run must be discoverable from upgrade help so
|
||||
// users know it is supported (and is now actually honored).
|
||||
if !strings.Contains(help, "--dry-run") {
|
||||
t.Error("help should advertise --dry-run for upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeDryRunPlan (#364) ---
|
||||
//
|
||||
// Regression for #364: `dws upgrade --dry-run` previously performed a real
|
||||
// upgrade because the flag was silently ignored. The dry-run path must now be
|
||||
// preview-only — it describes the steps without downloading or replacing
|
||||
// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
|
||||
|
||||
func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dry-run") {
|
||||
t.Errorf("output should be marked as dry-run, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "不会下载或修改任何文件") {
|
||||
t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
|
||||
t.Errorf("output should name the resolved platform asset, got:\n%s", out)
|
||||
}
|
||||
// All five steps should be previewed, including the (skipped) replace step.
|
||||
for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
|
||||
if !strings.Contains(out, step) {
|
||||
t.Errorf("output missing step %s, got:\n%s", step, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRunPlan_WithSkills(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dws-skills.zip") {
|
||||
t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "安装技能包") {
|
||||
t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Without skills, neither should appear.
|
||||
var buf2 bytes.Buffer
|
||||
writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
|
||||
if strings.Contains(buf2.String(), "dws-skills.zip") {
|
||||
t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- isLikelyAMFIKill ---
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// 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.
|
||||
|
||||
// agent_code_detect.go resolves the agent_code — which agent HOST is driving
|
||||
// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
|
||||
// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
|
||||
//
|
||||
// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
|
||||
// the two are never conflated here.
|
||||
//
|
||||
// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
|
||||
// - Prefer generalizable, host-declared signals so one rule covers a whole
|
||||
// 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.
|
||||
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
|
||||
// not the agent host) and fuzzy parent-process name matching.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
// match when any listed key is present and non-empty.
|
||||
type hostSignature struct {
|
||||
Code string
|
||||
EnvKeys []string
|
||||
}
|
||||
|
||||
// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
|
||||
// the dws subprocess they spawn inherits. All verified on a real machine
|
||||
// (2026-06-16) via live process env / launch env — not guessed.
|
||||
var knownSignatures = []hostSignature{
|
||||
// Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
|
||||
{Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
|
||||
// OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
|
||||
{Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
|
||||
// Hermes — verified on the running gateway: HERMES_HOME.
|
||||
{Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
|
||||
// OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
|
||||
// spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
|
||||
// CODEX_-prefixed name out of user .env to prevent spoofing — so its
|
||||
// presence reliably means "running under Codex".
|
||||
// Source: developers.openai.com/codex/concepts/sandboxing
|
||||
{Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
|
||||
}
|
||||
|
||||
// NOTE on coverage limits (honest, not a TODO to silently ignore):
|
||||
// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
|
||||
// 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.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
|
||||
// That is accurate for ANY agent (present or future) on ANY OS, and is what an
|
||||
// integrating host should wire up. Auto-detection (signatures / VSCODE_BRAND /
|
||||
// bundle id) is a best-effort supplement for hosts that have not declared.
|
||||
|
||||
// bundleIDToCode maps macOS app bundle identifiers to agent codes. The bundle
|
||||
// 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.
|
||||
//
|
||||
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
|
||||
// this map is simply a no-op there (os.Getenv returns "").
|
||||
var bundleIDToCode = map[string]string{
|
||||
"com.qoder.ide": "qoder",
|
||||
"com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
|
||||
"com.microsoft.VSCode": "vscode",
|
||||
"com.workbuddy.workbuddy": "workbuddy",
|
||||
}
|
||||
|
||||
// DetectAgentCode resolves the agent_code via a confidence ladder and returns
|
||||
// the normalized code plus the signal that decided it:
|
||||
//
|
||||
// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
|
||||
// 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)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return normalizeAgentCode(v), "env:" + name
|
||||
}
|
||||
|
||||
// T1: verified per-agent env signature (most specific — wins over the IDE
|
||||
// it may be running inside).
|
||||
for _, sig := range knownSignatures {
|
||||
for _, k := range sig.EnvKeys {
|
||||
if strings.TrimSpace(os.Getenv(k)) != "" {
|
||||
return sig.Code, "sig:" + k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2: VS Code fork family. The brand value IS the host's self-declaration,
|
||||
// so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
|
||||
// including forks that don't exist yet.
|
||||
if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
|
||||
return normalizeAgentCode(b), "env:VSCODE_BRAND"
|
||||
}
|
||||
|
||||
// T3: macOS app bundle id (known agent bundles only).
|
||||
if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
|
||||
if c, ok := bundleIDToCode[id]; ok {
|
||||
return c, "bundle:" + id
|
||||
}
|
||||
}
|
||||
|
||||
// T4: unknown host — honest fallback, no guessing.
|
||||
return AgentCodeCustom, "fallback"
|
||||
}
|
||||
|
||||
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
|
||||
// values. Unrecognized but non-empty input is lowercased, space-stripped and
|
||||
// kept as-is — still a host declaration, so still accurate (this is what gives
|
||||
// automatic coverage of new VS Code forks via VSCODE_BRAND).
|
||||
func normalizeAgentCode(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
switch s {
|
||||
case "":
|
||||
return AgentCodeCustom
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder", "qoderwork":
|
||||
return "qoder"
|
||||
case "workbuddy", "work-buddy":
|
||||
return "workbuddy"
|
||||
case "visualstudiocode", "code", "code-oss", "vscode":
|
||||
return "vscode"
|
||||
case "cursor":
|
||||
return "cursor"
|
||||
case "windsurf":
|
||||
return "windsurf"
|
||||
case "trae", "traecn":
|
||||
return "trae"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,38 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// AgentCodeEnv is the sole per-spawn environment variable the host injects
|
||||
// to declare "this process is driven by a third-party Agent host, render
|
||||
// authorization UI yourselves".
|
||||
// AgentCodeEnv is the primary per-spawn environment variable the host
|
||||
// injects to declare "this process is driven by a third-party Agent host,
|
||||
// render authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// AgentCodeFromEnv returns the effective host agent code and the env name that
|
||||
// supplied it.
|
||||
//
|
||||
// Keep the public env surface intentionally single-spelled. The reversed
|
||||
// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
|
||||
// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
|
||||
// the same stable signal: DINGTALK_DWS_AGENTCODE.
|
||||
func AgentCodeFromEnv() (string, string) {
|
||||
if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
|
||||
return value, AgentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func AgentCodeEnvPresent() bool {
|
||||
value, _ := AgentCodeFromEnv()
|
||||
return value != ""
|
||||
}
|
||||
|
||||
// HostOwnsPATFlow reports whether the current process is running under a
|
||||
// third-party Agent host that will render the PAT authorization card
|
||||
// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
|
||||
// non-empty. The CLI deliberately does not consult any other signal
|
||||
// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
|
||||
// decision so that server-side routing tags and the host-owned UI contract
|
||||
// remain independent concerns.
|
||||
// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
|
||||
// deliberately does not consult any other signal (DINGTALK_AGENT /
|
||||
// DWS_CHANNEL / the wire claw-type header) for this decision so that
|
||||
// server-side routing tags and the host-owned UI contract remain independent
|
||||
// concerns.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
return AgentCodeEnvPresent()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type DeviceFlowProvider struct {
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -205,7 +206,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
}
|
||||
dfPrintDeviceCodeBox(p.output(), authResp)
|
||||
|
||||
if authResp.VerificationURIComplete != "" {
|
||||
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
|
||||
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
|
||||
p.logger.Debug("could not open browser", "error", bErr)
|
||||
}
|
||||
|
||||
+151
-14
@@ -13,17 +13,27 @@
|
||||
|
||||
// identity.go manages agent instance identification for tracking.
|
||||
//
|
||||
// Each agent installation gets a unique agentId (UUID v4) that persists across
|
||||
// version upgrades but regenerates on reinstall. This identity is transparently
|
||||
// injected into MCP HTTP headers for gateway-side data collection.
|
||||
// Identity has two granularities, both injected into MCP HTTP headers for
|
||||
// gateway-side statistics:
|
||||
//
|
||||
// - machineId: a stable per-install UUID v4 (persists across upgrades,
|
||||
// regenerates on reinstall). Non-PII.
|
||||
// - agentId: a per-(machine × agentCode) id derived deterministically from
|
||||
// machineId + agent_code, so one machine running multiple agent hosts
|
||||
// (e.g. claudecode + cursor) yields a distinct, idempotent agentId per
|
||||
// agent_code. Computed client-side — no gateway round-trip required.
|
||||
//
|
||||
// The agent_code itself is resolved by DetectAgentCode (agent_code_detect.go).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -31,14 +41,34 @@ import (
|
||||
|
||||
const identityFile = "identity.json"
|
||||
|
||||
// identityVersion is the current on-disk schema version. v1 files (no
|
||||
// machineId/agents) are migrated transparently on load.
|
||||
const identityVersion = 2
|
||||
|
||||
// AgentEntry records the derived agentId for a single agent_code on this
|
||||
// machine.
|
||||
type AgentEntry struct {
|
||||
AgentID string `json:"agentId"`
|
||||
FirstSeen string `json:"firstSeen,omitempty"`
|
||||
Detect string `json:"detect,omitempty"` // signal that decided the agent_code
|
||||
}
|
||||
|
||||
// Identity holds the agent instance identification fields.
|
||||
//
|
||||
// AgentID is retained for backward compatibility with v1 readers: on a fresh
|
||||
// install it is written equal to MachineID, and a v1 file's agentId is migrated
|
||||
// into MachineID on load.
|
||||
type Identity struct {
|
||||
AgentID string `json:"agentId"` // UUID v4, generated at install time
|
||||
Source string `json:"source"` // data source, default "dws"
|
||||
Version int `json:"version,omitempty"`
|
||||
AgentID string `json:"agentId"` // v1 install UUID; == MachineID on v2 installs
|
||||
MachineID string `json:"machineId,omitempty"` // stable per-install machine seed
|
||||
Source string `json:"source"` // data source, default "dws"
|
||||
Agents map[string]*AgentEntry `json:"agents,omitempty"` // agent_code -> derived agentId
|
||||
}
|
||||
|
||||
// Load reads the identity from <configDir>/identity.json.
|
||||
// Returns nil if the file does not exist or cannot be parsed.
|
||||
// v1 files are migrated in-memory (machineId backfilled from agentId).
|
||||
func Load(configDir string) *Identity {
|
||||
path := filepath.Join(configDir, identityFile)
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -49,21 +79,43 @@ func Load(configDir string) *Identity {
|
||||
if err := json.Unmarshal(data, &id); err != nil {
|
||||
return nil
|
||||
}
|
||||
if id.AgentID == "" {
|
||||
if id.AgentID == "" && id.MachineID == "" {
|
||||
return nil
|
||||
}
|
||||
id.migrate()
|
||||
return &id
|
||||
}
|
||||
|
||||
// migrate backfills v2 fields from a v1 file in-memory (does not persist).
|
||||
func (id *Identity) migrate() {
|
||||
if id.MachineID == "" {
|
||||
id.MachineID = id.AgentID // v1 install UUID becomes the machine seed
|
||||
}
|
||||
if id.AgentID == "" {
|
||||
id.AgentID = id.MachineID
|
||||
}
|
||||
if id.Source == "" {
|
||||
id.Source = "dws"
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
}
|
||||
id.Version = identityVersion
|
||||
}
|
||||
|
||||
// EnsureExists loads existing identity or creates a new one if not present.
|
||||
func EnsureExists(configDir string) *Identity {
|
||||
if id := Load(configDir); id != nil {
|
||||
return id
|
||||
}
|
||||
|
||||
u := generateUUID()
|
||||
id := &Identity{
|
||||
AgentID: generateUUID(),
|
||||
Source: "dws",
|
||||
Version: identityVersion,
|
||||
AgentID: u, // kept == MachineID for backward-compat
|
||||
MachineID: u,
|
||||
Source: "dws",
|
||||
Agents: make(map[string]*AgentEntry),
|
||||
}
|
||||
|
||||
// Best-effort persist — don't fail the CLI if write fails.
|
||||
@@ -71,14 +123,51 @@ func EnsureExists(configDir string) *Identity {
|
||||
return id
|
||||
}
|
||||
|
||||
// Headers returns the identity as HTTP header key-value pairs.
|
||||
// machineSeed returns the stable seed used to derive per-channel agentIds.
|
||||
func (id *Identity) machineSeed() string {
|
||||
if id.MachineID != "" {
|
||||
return id.MachineID
|
||||
}
|
||||
return id.AgentID
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
agentCode = AgentCodeCustom
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
}
|
||||
if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
|
||||
return e.AgentID
|
||||
}
|
||||
aid := deriveAgentID(id.machineSeed(), agentCode)
|
||||
id.Agents[agentCode] = &AgentEntry{
|
||||
AgentID: aid,
|
||||
FirstSeen: time.Now().UTC().Format(time.RFC3339),
|
||||
Detect: signal,
|
||||
}
|
||||
_ = save(configDir, id) // best-effort cache; recomputable if it fails
|
||||
return aid
|
||||
}
|
||||
|
||||
// Headers returns the identity as static HTTP header key-value pairs.
|
||||
// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
|
||||
// continuous across versions. The per-(machine × agent_code) instance id is a
|
||||
// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
|
||||
// ResolveAgentID — it does not override x-dws-agent-id.
|
||||
func (id *Identity) Headers() map[string]string {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
h := make(map[string]string, 5)
|
||||
if id.AgentID != "" {
|
||||
h["x-dws-agent-id"] = id.AgentID
|
||||
if seed := id.machineSeed(); seed != "" {
|
||||
h["x-dws-agent-id"] = seed
|
||||
}
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
|
||||
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
|
||||
}
|
||||
|
||||
// deriveAgentID computes a stable, client-side agentId for a (machine,
|
||||
// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
|
||||
// Deterministic and idempotent; no gateway allocation needed for statistics.
|
||||
func deriveAgentID(seed, agentCode string) string {
|
||||
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
|
||||
enc := base62Encode(sum[:])
|
||||
for len(enc) < 12 {
|
||||
enc = "0" + enc
|
||||
}
|
||||
return "dwsa_" + enc[:12]
|
||||
}
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func base62Encode(b []byte) string {
|
||||
n := new(big.Int).SetBytes(b)
|
||||
if n.Sign() == 0 {
|
||||
return "0"
|
||||
}
|
||||
base := big.NewInt(62)
|
||||
mod := new(big.Int)
|
||||
var out []byte
|
||||
for n.Sign() > 0 {
|
||||
n.DivMod(n, base, mod)
|
||||
out = append(out, base62Alphabet[mod.Int64()])
|
||||
}
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// generateUUID produces a UUID v4 string.
|
||||
func generateUUID() string {
|
||||
var u [16]byte
|
||||
@@ -113,6 +234,22 @@ func generateUUID() string {
|
||||
}
|
||||
u[6] = (u[6] & 0x0f) | 0x40 // version 4
|
||||
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
|
||||
return fmtUUID(u)
|
||||
}
|
||||
|
||||
func fmtUUID(u [16]byte) string {
|
||||
const hexdig = "0123456789abcdef"
|
||||
// 8-4-4-4-12 with dashes => 36 bytes
|
||||
buf := make([]byte, 36)
|
||||
pos := 0
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
buf[pos] = '-'
|
||||
pos++
|
||||
}
|
||||
buf[pos] = hexdig[u[i]>>4]
|
||||
buf[pos+1] = hexdig[u[i]&0x0f]
|
||||
pos += 2
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveAgentID_Format(t *testing.T) {
|
||||
id := deriveAgentID("machine-abc", "claudecode")
|
||||
if !strings.HasPrefix(id, "dwsa_") {
|
||||
t.Fatalf("want dwsa_ prefix, got %q", id)
|
||||
}
|
||||
if len(id) != len("dwsa_")+12 {
|
||||
t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_Deterministic(t *testing.T) {
|
||||
a := deriveAgentID("seed", "claudecode")
|
||||
b := deriveAgentID("seed", "claudecode")
|
||||
if a != b {
|
||||
t.Fatalf("derivation must be deterministic: %q != %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
|
||||
m1c1 := deriveAgentID("machine1", "claudecode")
|
||||
m1c2 := deriveAgentID("machine1", "cursor")
|
||||
m2c1 := deriveAgentID("machine2", "claudecode")
|
||||
if m1c1 == m1c2 {
|
||||
t.Errorf("same machine, different channel must differ: %q", m1c1)
|
||||
}
|
||||
if m1c1 == m2c1 {
|
||||
t.Errorf("different machine, same channel must differ: %q", m1c1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
|
||||
first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
if first != second {
|
||||
t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
|
||||
}
|
||||
|
||||
// Reload from disk — the channel entry must have persisted.
|
||||
reloaded := Load(dir)
|
||||
if reloaded == nil {
|
||||
t.Fatal("expected identity to persist")
|
||||
}
|
||||
e, ok := reloaded.Agents["claudecode"]
|
||||
if !ok || e.AgentID != first {
|
||||
t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
|
||||
}
|
||||
if e.Detect != "sig:CLAUDECODE" {
|
||||
t.Errorf("want detect signal recorded, got %q", e.Detect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_EmptyAgentCodeGoesCustom(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)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
|
||||
// legacy agentId, and per-channel derivation keyed off that stable seed.
|
||||
func TestLoad_MigratesV1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := Load(dir)
|
||||
if id == nil {
|
||||
t.Fatal("v1 file should load")
|
||||
}
|
||||
if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
|
||||
t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
|
||||
}
|
||||
if id.machineSeed() != id.MachineID {
|
||||
t.Fatalf("seed should be machineId, got %q", id.machineSeed())
|
||||
}
|
||||
// Derivation is stable against the migrated seed.
|
||||
want := deriveAgentID(id.MachineID, "claudecode")
|
||||
if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
|
||||
t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type OAuthProvider struct {
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -393,8 +394,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
if !p.NoBrowser {
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
@@ -185,7 +185,7 @@ func executePipelineCall(
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
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/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -333,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
|
||||
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
|
||||
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
|
||||
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
|
||||
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
|
||||
func attachAITag(cmd *cobra.Command, params map[string]any) {
|
||||
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
|
||||
params["clawType"] = edition.ClawType()
|
||||
}
|
||||
}
|
||||
|
||||
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
|
||||
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
|
||||
// content 内携带 title)。
|
||||
@@ -442,6 +453,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
|
||||
}
|
||||
params := map[string]any{"msgType": msgType, "content": contentJSON}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -481,6 +493,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
@@ -493,6 +506,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return params, "send_personal_message", nil
|
||||
case hasUser:
|
||||
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
|
||||
attachAITag(cmd, params)
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
params := map[string]any{
|
||||
@@ -500,6 +514,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -975,8 +990,10 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
|
||||
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
|
||||
attachAITag(cmd, params)
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -1000,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -308,6 +310,104 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
|
||||
// by default NO user-identity send carries the clawType tool argument (so the IM
|
||||
// server renders no AI badge). Only when --ai-tag is passed does each path attach
|
||||
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
|
||||
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
|
||||
// dws does not surprise users by branding every message they send.
|
||||
func TestChatMessageAITagControlsClawType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
make func(runner executor.Runner) *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group-markdown",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "group-rich-media-image",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
make: newChatMessageReplyCommand,
|
||||
args: []string{
|
||||
"--conversation-id", "cid-xyz",
|
||||
"--ref-msg-id", "msg-1",
|
||||
"--ref-sender", "op-1",
|
||||
"--text", "got it",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// Default: no --ai-tag → must omit clawType entirely (no badge).
|
||||
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if v, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("default send must omit clawType, got %#v", v)
|
||||
}
|
||||
})
|
||||
// Opt-in: --ai-tag → attach the edition claw identity.
|
||||
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
got, ok := runner.last.Params["clawType"]
|
||||
if !ok {
|
||||
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
if got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Robot sends are rendered as bot messages already; they must NOT carry the
|
||||
// user-identity clawType argument.
|
||||
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendByBotCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if _, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func equalAny(a, b any) bool {
|
||||
switch av := a.(type) {
|
||||
case []any:
|
||||
|
||||
@@ -362,7 +362,7 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
content = stripped
|
||||
}
|
||||
if content != "" {
|
||||
params["markdown"] = content
|
||||
params["markdown"] = stripDocInputUnsafe(content)
|
||||
}
|
||||
}
|
||||
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
|
||||
@@ -473,7 +473,7 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
if sniffJsonMLLike(content) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
|
||||
}
|
||||
params["markdown"] = content
|
||||
params["markdown"] = stripDocInputUnsafe(content)
|
||||
addDocIntParam(cmd, params, "index", "index")
|
||||
}
|
||||
return runDocTool(cmd, runner, "doc", "update_document", params)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
var docDangerousUnicode = [...]rune{
|
||||
0x200B,
|
||||
0x200C,
|
||||
0x200D,
|
||||
0x200E,
|
||||
0x200F,
|
||||
0x202A,
|
||||
@@ -33,6 +34,8 @@ var docDangerousUnicode = [...]rune{
|
||||
0x202C,
|
||||
0x202D,
|
||||
0x202E,
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x2066,
|
||||
0x2067,
|
||||
0x2068,
|
||||
@@ -49,8 +52,20 @@ var docDangerousSet = func() map[rune]bool {
|
||||
return m
|
||||
}()
|
||||
|
||||
func stripDocDangerousUnicode(s string) string {
|
||||
// stripDocInputUnsafe removes characters that the server-side RejectControlChars
|
||||
// validator (mirrored by apiclient.rejectDangerousChars) would reject:
|
||||
//
|
||||
// 1. C0 control characters (except tab and newline) and DEL (0x7F)
|
||||
// 2. Dangerous Unicode (zero-width, Bidi controls, line/paragraph separators, BOM)
|
||||
//
|
||||
// It is applied at the write boundary so document content passes server
|
||||
// validation instead of being rejected. Tab and newline are preserved because
|
||||
// they are legitimate in document text.
|
||||
func stripDocInputUnsafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7F) {
|
||||
return -1
|
||||
}
|
||||
if docDangerousSet[r] {
|
||||
return -1
|
||||
}
|
||||
@@ -194,7 +209,7 @@ func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
|
||||
}
|
||||
return stripDocDangerousUnicode(string(out)), nil
|
||||
return stripDocInputUnsafe(string(out)), nil
|
||||
}
|
||||
|
||||
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
||||
@@ -247,7 +262,7 @@ func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
return stripDocInputUnsafe(string(out)), nil
|
||||
}
|
||||
|
||||
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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 helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestStripDocInputUnsafe verifies that stripDocInputUnsafe removes exactly the
|
||||
// characters the server-side RejectControlChars validator rejects (C0 controls
|
||||
// except tab/newline, DEL, and the dangerous-Unicode set), while leaving all
|
||||
// legitimate text untouched. Offending codepoints use explicit \u / \x escapes
|
||||
// so they are unambiguous in source.
|
||||
func TestStripDocInputUnsafe(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "preserves plain text",
|
||||
in: "正常的文档内容 with ASCII",
|
||||
want: "正常的文档内容 with ASCII",
|
||||
},
|
||||
{
|
||||
name: "keeps tab and newline",
|
||||
in: "标题\n\t正文",
|
||||
want: "标题\n\t正文",
|
||||
},
|
||||
{
|
||||
name: "drops C0 controls (null, SOH, CR) and DEL",
|
||||
in: "正文\x00内容\x01段落\x0d结尾\x7f",
|
||||
want: "正文内容段落结尾",
|
||||
},
|
||||
{
|
||||
name: "drops zero-width space/non-joiner/joiner",
|
||||
in: "正文\u200b内容\u200c段落\u200d结尾",
|
||||
want: "正文内容段落结尾",
|
||||
},
|
||||
{
|
||||
name: "drops bidi overrides and isolates",
|
||||
in: "Bidi\u202a测试\u202e结束\u2066左\u2069右",
|
||||
want: "Bidi测试结束左右",
|
||||
},
|
||||
{
|
||||
name: "drops line and paragraph separators",
|
||||
in: "\u2028\u2029行段",
|
||||
want: "行段",
|
||||
},
|
||||
{
|
||||
name: "drops BOM / ZWNBSP",
|
||||
in: "BOM\ufeff尾",
|
||||
want: "BOM尾",
|
||||
},
|
||||
{
|
||||
name: "drops mixed control and dangerous unicode",
|
||||
in: "混合\x00测试\u200b结尾\x7f",
|
||||
want: "混合测试结尾",
|
||||
},
|
||||
{
|
||||
name: "empty string stays empty",
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := stripDocInputUnsafe(tc.in); got != tc.want {
|
||||
t.Fatalf("stripDocInputUnsafe(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
|
||||
}
|
||||
|
||||
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode, false)
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package pat
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stdout)
|
||||
cmd.SetArgs([]string{"--agentCode", "agt-command"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("browser-policy Execute() error = nil, want missing --enabled error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--enabled is required") {
|
||||
t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error())
|
||||
}
|
||||
|
||||
policy, loadErr := LoadBrowserPolicy(configDir)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", loadErr)
|
||||
}
|
||||
if policy.Default != nil || len(policy.Agents) != 0 {
|
||||
t.Fatalf("policy was modified despite missing --enabled: %#v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
+179
-51
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -58,23 +59,24 @@ func resolveSessionIDFromEnv() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// agentCodeEnv is the canonical (and only) environment variable name
|
||||
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
|
||||
// commands.
|
||||
// agentCodeEnv is the canonical environment variable name used as a
|
||||
// per-shell fallback for the --agentCode flag on `dws pat *` commands.
|
||||
//
|
||||
// Why: agent hosts may set their business agent code once when spawning
|
||||
// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets
|
||||
// the host export the code once and let the CLI resolve it on every pat
|
||||
// subcommand. The flag always wins when both are set so scripted one-offs
|
||||
// remain deterministic. When neither flag nor env is set, the request is
|
||||
// sent without agentCode and lippi-pat-core applies its default agentCode.
|
||||
// remain deterministic. When neither flag nor env is set, `pat chmod` omits
|
||||
// agentCode and lets the PAT server apply its open-source default. Batch PAT
|
||||
// tools receive the resolved agentCode in arguments when present, while the CLI
|
||||
// also keeps exporting it through env for older gateway paths.
|
||||
//
|
||||
// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
|
||||
// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
|
||||
// hard-removed once the public integration surface landed on
|
||||
// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
|
||||
// silent fallback.
|
||||
const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
// Namespace note: keep this as a single-spelled public contract. The reversed
|
||||
// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE /
|
||||
// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed.
|
||||
const (
|
||||
agentCodeEnv = authpkg.AgentCodeEnv
|
||||
)
|
||||
|
||||
// agentCodePattern is the validation regex for any --agentCode value
|
||||
// resolved from either the flag or the agent-code env var. It matches
|
||||
@@ -84,16 +86,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
// argument.
|
||||
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the
|
||||
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
|
||||
// reports the env name that was consumed (for error attribution); it
|
||||
// is "" when the env is unset or blank. No legacy aliases are honored.
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the canonical
|
||||
// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name
|
||||
// that was consumed (for error attribution); it is "" when the env var is unset
|
||||
// or blank.
|
||||
func resolveAgentCodeFromEnv() (string, string) {
|
||||
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
|
||||
if primary != "" {
|
||||
return primary, agentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
return authpkg.AgentCodeFromEnv()
|
||||
}
|
||||
|
||||
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
|
||||
@@ -111,34 +109,34 @@ func validateAgentCode(code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentCode implements the canonical two-tier lookup for
|
||||
// --agentCode:
|
||||
// resolveAgentCode implements the canonical optional lookup for --agentCode:
|
||||
//
|
||||
// 1. explicit --agentCode flag value (highest priority; wins over env)
|
||||
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
|
||||
// 3. empty ("") when required=false; typed error when required=true.
|
||||
// 3. empty ("") so PAT-core can apply its open-source default.
|
||||
//
|
||||
// Any non-empty resolved value is validated via validateAgentCode, so
|
||||
// callers never have to re-validate.
|
||||
func resolveAgentCode(flagVal string, required bool) (string, error) {
|
||||
func resolveAgentCode(flagVal string) (string, error) {
|
||||
code := strings.TrimSpace(flagVal)
|
||||
envSource := ""
|
||||
if code == "" {
|
||||
code, envSource = resolveAgentCodeFromEnv()
|
||||
}
|
||||
if code == "" {
|
||||
if required {
|
||||
return "", fmt.Errorf(
|
||||
"flag --agentCode is required (or set env %s)\n hint: dws pat chmod <scope>... --agentCode <id>\n hint: export %s=<id>",
|
||||
agentCodeEnv, agentCodeEnv)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if err := validateAgentCode(code); err != nil {
|
||||
if envSource != "" {
|
||||
return "", fmt.Errorf("%s env: %w", envSource, err)
|
||||
return "", apperrors.NewValidation(
|
||||
fmt.Sprintf("%s env: %v", envSource, err),
|
||||
apperrors.WithReason("invalid_agent_code"),
|
||||
)
|
||||
}
|
||||
return "", err
|
||||
return "", apperrors.NewValidation(
|
||||
err.Error(),
|
||||
apperrors.WithReason("invalid_agent_code"),
|
||||
)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -159,8 +157,25 @@ const (
|
||||
|
||||
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
|
||||
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
|
||||
patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD"
|
||||
patForgedIdentityCodeLower = "pat_forged_identity_field"
|
||||
)
|
||||
|
||||
var patBatchMetadataContractCodes = map[string]bool{
|
||||
"pat_batch_auth_metadata_required": true,
|
||||
"pat_batch_scope_not_declared": true,
|
||||
"pat_batch_product_not_declared": true,
|
||||
}
|
||||
|
||||
var patBatchIdentityArgumentKeys = map[string]bool{
|
||||
"agentCode": true,
|
||||
"sessionId": true,
|
||||
"orgId": true,
|
||||
"uid": true,
|
||||
"source": true,
|
||||
"caller": true,
|
||||
}
|
||||
|
||||
var validGrantTypes = map[string]bool{
|
||||
"once": true,
|
||||
"session": true,
|
||||
@@ -189,7 +204,21 @@ scope 格式: <product>.<entity>:<permission>
|
||||
grantType 规则:
|
||||
once 一次性,执行一次后自动失效
|
||||
session 当前会话有效(默认),需要 --session-id
|
||||
permanent 永久有效`,
|
||||
permanent 永久有效
|
||||
|
||||
批量授权:
|
||||
dws pat chmod 支持一次传多个 scope 直接批量授予。
|
||||
也支持 --products / --product 按产品编码批量展开 scope 模板,
|
||||
--domains / --domain 按产品域批量展开 scope 模板,
|
||||
--recommend 使用服务端推荐 scope 集合。
|
||||
使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认
|
||||
selected / skipped / pending,再对 selected scopes 执行 batch grant;
|
||||
--dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式
|
||||
添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。
|
||||
|
||||
agentCode 配置:
|
||||
可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE
|
||||
指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
|
||||
if len(args) > 0 || recommend || len(productCodes) > 0 {
|
||||
@@ -199,12 +228,14 @@ grantType 规则:
|
||||
},
|
||||
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
|
||||
dws pat chmod chat.message:list --grant-type once
|
||||
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent
|
||||
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx
|
||||
dws pat chmod --recommend --grant-type session --session-id session-xxx`,
|
||||
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
|
||||
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
|
||||
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
|
||||
dws pat chmod --domain calendar --domain chat --grant-type once --yes
|
||||
dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
flagVal, _ := cmd.Flags().GetString("agentCode")
|
||||
agentCode, err := resolveAgentCode(flagVal, false)
|
||||
agentCode, err := resolveAgentCode(flagVal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,8 +270,6 @@ grantType 规则:
|
||||
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
|
||||
if agentCode != "" {
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
|
||||
} else {
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)")
|
||||
}
|
||||
fmt.Printf("%-16s%v\n", "Scope:", scopes)
|
||||
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
|
||||
@@ -267,6 +296,11 @@ grantType 规则:
|
||||
if len(scopes) == 0 {
|
||||
return handleToolResult(cmd, c, planResult)
|
||||
}
|
||||
if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
batchArgs := map[string]any{
|
||||
"scopes": scopes,
|
||||
@@ -315,18 +349,37 @@ grantType 规则:
|
||||
}
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "",
|
||||
"Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)")
|
||||
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价")
|
||||
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔")
|
||||
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权")
|
||||
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔")
|
||||
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权")
|
||||
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes")
|
||||
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
|
||||
|
||||
return chmodCmd
|
||||
}
|
||||
|
||||
func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error {
|
||||
if !usesPlan && len(scopes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
if commandBoolFlag(cmd, "yes") {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
"batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant",
|
||||
apperrors.WithReason("pat_batch_requires_yes"),
|
||||
apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"),
|
||||
apperrors.WithActions(
|
||||
"dws pat chmod <scope1> <scope2> ... --grant-type once --yes",
|
||||
"dws pat chmod --products <product1,product2> --grant-type once --yes",
|
||||
"dws pat chmod --recommend --grant-type once --yes",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func collectChmodProductCodes(groups ...[]string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0)
|
||||
@@ -388,13 +441,11 @@ func callPATBatchGrantWithLegacyFallback(
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs)
|
||||
})
|
||||
if err == nil && !isPATBatchUnsupportedResult(result) {
|
||||
result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
|
||||
if err == nil && !isPATBatchFallbackResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) {
|
||||
if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
@@ -414,8 +465,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
|
||||
}
|
||||
|
||||
func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", toolName, args)
|
||||
})
|
||||
if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
|
||||
return result, err
|
||||
}
|
||||
compatArgs := cloneWithoutPATIdentityArgs(args)
|
||||
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
|
||||
return c.CallTool(ctx, "pat", toolName, compatArgs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -485,6 +547,25 @@ func firstToolResultText(result *edition.ToolResult) string {
|
||||
}
|
||||
|
||||
func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
return strings.EqualFold(code, patBatchUnsupportedCode)
|
||||
})
|
||||
}
|
||||
|
||||
func isPATBatchFallbackResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(code))
|
||||
return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized]
|
||||
})
|
||||
}
|
||||
|
||||
func isPATForgedIdentityResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
return strings.EqualFold(code, patForgedIdentityCode)
|
||||
})
|
||||
}
|
||||
|
||||
func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool {
|
||||
text := firstToolResultText(result)
|
||||
if text == "" {
|
||||
return false
|
||||
@@ -494,7 +575,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"code", "errorCode", "error_code"} {
|
||||
if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
|
||||
if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -505,6 +586,53 @@ func isPATBatchUnsupportedError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
|
||||
}
|
||||
|
||||
func isPATBatchFallbackError(err error) bool {
|
||||
if isPATBatchUnsupportedError(err) {
|
||||
return true
|
||||
}
|
||||
text := normalizedPATErrorText(err)
|
||||
for code := range patBatchMetadataContractCodes {
|
||||
if strings.Contains(text, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPATForgedIdentityError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
|
||||
}
|
||||
|
||||
func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
|
||||
if !hasPATIdentityArgs(args) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return isPATForgedIdentityError(err)
|
||||
}
|
||||
return isPATForgedIdentityResult(result)
|
||||
}
|
||||
|
||||
func hasPATIdentityArgs(args map[string]any) bool {
|
||||
for key := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
|
||||
// then silently retries the legacy Chinese alias when the server has not
|
||||
// registered the canonical tool yet. The retry intentionally emits no stderr
|
||||
|
||||
+759
-29
@@ -29,8 +29,8 @@ import (
|
||||
)
|
||||
|
||||
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
|
||||
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
|
||||
// resolver feeds into the outgoing MCP argv.
|
||||
// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
|
||||
// into the outgoing batch request.
|
||||
type fakeToolCaller struct {
|
||||
mu sync.Mutex
|
||||
dryRun bool
|
||||
@@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" }
|
||||
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
|
||||
|
||||
type recordedToolCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
tool string
|
||||
args map[string]any
|
||||
agentEnv string
|
||||
sessionEnv string
|
||||
dingSessionEnv string
|
||||
}
|
||||
|
||||
type fallbackToolCaller struct {
|
||||
@@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
|
||||
type sequenceToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
responses []string
|
||||
errs []error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
@@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
|
||||
copied[k] = v
|
||||
}
|
||||
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
|
||||
s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
|
||||
s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
|
||||
if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
|
||||
return nil, s.errs[len(s.calls)-1]
|
||||
}
|
||||
response := `{"success":true,"data":{}}`
|
||||
if len(s.responses) >= len(s.calls) {
|
||||
response = s.responses[len(s.calls)-1]
|
||||
@@ -257,6 +267,37 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
|
||||
return newChmodCommand(fake)
|
||||
}
|
||||
|
||||
func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
root.AddCommand(cmd)
|
||||
if yes {
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set root --yes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
root.AddCommand(cmd)
|
||||
if yes {
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set root --yes: %v", err)
|
||||
}
|
||||
}
|
||||
if formatChanged {
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("set root --format: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
@@ -279,6 +320,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
|
||||
patCmd, _, err := root.Find([]string{"pat"})
|
||||
if err != nil {
|
||||
t.Fatalf("pat command not found: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
patCmd.SetOut(&out)
|
||||
patCmd.SetErr(&out)
|
||||
if err := patCmd.Help(); err != nil {
|
||||
t.Fatalf("pat help error = %v", err)
|
||||
}
|
||||
patHelp := out.String()
|
||||
for _, want := range []string{
|
||||
"支持批量授权",
|
||||
"--products / --product",
|
||||
"--domains / --domain",
|
||||
"--recommend",
|
||||
"DINGTALK_DWS_AGENTCODE",
|
||||
"未传 agentCode 时由服务端默认兜底",
|
||||
} {
|
||||
if !strings.Contains(patHelp, want) {
|
||||
t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
|
||||
}
|
||||
}
|
||||
|
||||
chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
|
||||
if err != nil {
|
||||
t.Fatalf("pat chmod command not found: %v", err)
|
||||
}
|
||||
out.Reset()
|
||||
chmodCmd.SetOut(&out)
|
||||
chmodCmd.SetErr(&out)
|
||||
if err := chmodCmd.Help(); err != nil {
|
||||
t.Fatalf("pat chmod help error = %v", err)
|
||||
}
|
||||
chmodHelp := out.String()
|
||||
for _, want := range []string{
|
||||
"批量授权:",
|
||||
"一次传多个 scope",
|
||||
"batch plan",
|
||||
"--dry-run 只返回授权计划",
|
||||
"执行批量授权必须显式",
|
||||
"由服务端默认兜底",
|
||||
"aitable.record:read aitable.record:write --grant-type permanent --yes",
|
||||
"dws pat chmod --product calendar --product aitable",
|
||||
"dws pat chmod --domain calendar --domain chat",
|
||||
} {
|
||||
if !strings.Contains(chmodHelp, want) {
|
||||
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
@@ -288,6 +385,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -305,8 +403,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
if got := fake.calls[0].args["recommend"]; got != false {
|
||||
t.Fatalf("recommend = %#v, want false", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
|
||||
@@ -314,12 +415,56 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
|
||||
t.Fatalf("grant scopes = %#v, want selected scopes", got)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("grant agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want plan only", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) {
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"})
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
|
||||
@@ -328,6 +473,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
_ = cmd.Flags().Set("session-id", "session-123")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -339,20 +485,423 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
if got := fake.calls[0].args["grantType"]; got != "session" {
|
||||
t.Fatalf("plan grantType = %#v, want session", got)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
|
||||
t.Fatalf("plan sessionId = %#v, want session-123", got)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[0].dingSessionEnv != "session-123" {
|
||||
t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
|
||||
}
|
||||
if got := fake.calls[1].args["grantType"]; got != "session" {
|
||||
t.Fatalf("grant grantType = %#v, want session", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
|
||||
t.Fatalf("grant sessionId = %#v, want session-123", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("grant agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[1].dingSessionEnv != "session-123" {
|
||||
t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, []string{"contact.user:get-self"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
|
||||
t.Fatalf("summary output missing server default agentCode:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "envshouldlose")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, []string{"chat.bot:search"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") {
|
||||
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlanProducts []string
|
||||
wantRecommend bool
|
||||
wantCallCount int
|
||||
}{
|
||||
{
|
||||
name: "direct multi scope",
|
||||
args: []string{"calendar.event:list", "calendar.event:create"},
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
},
|
||||
wantCallCount: 1,
|
||||
},
|
||||
{
|
||||
name: "product repeated",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("product", "calendar")
|
||||
_ = cmd.Flags().Set("product", "aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "products comma list",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "domain repeated",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domain", "calendar")
|
||||
_ = cmd.Flags().Set("domain", "chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "domains comma list",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domains", "calendar,chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantRecommend: true,
|
||||
wantCallCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
responses := []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}
|
||||
if tc.wantCallCount == 2 {
|
||||
responses = []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}
|
||||
}
|
||||
fake := &sequenceToolCaller{responses: responses}
|
||||
cmd := newChmodCommand(fake)
|
||||
tc.setFlags(cmd)
|
||||
attachRootPATFlags(t, cmd, true, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, tc.args)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != tc.wantCallCount {
|
||||
t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
|
||||
}
|
||||
if tc.wantCallCount == 1 {
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
|
||||
t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
|
||||
}
|
||||
} else {
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
|
||||
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
|
||||
}
|
||||
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
|
||||
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
|
||||
}
|
||||
if fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
|
||||
t.Fatalf("grant scopes = %#v, want selected scopes", got)
|
||||
}
|
||||
}
|
||||
last := fake.calls[len(fake.calls)-1]
|
||||
if got := last.args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") {
|
||||
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlanProducts []string
|
||||
wantRecommend bool
|
||||
}{
|
||||
{
|
||||
name: "product",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
},
|
||||
{
|
||||
name: "domain",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("domains", "calendar,chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantRecommend: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
dryRun: true,
|
||||
responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
tc.setFlags(cmd)
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
|
||||
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
|
||||
}
|
||||
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
|
||||
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
|
||||
t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlan bool
|
||||
}{
|
||||
{
|
||||
name: "direct multi scope",
|
||||
args: []string{"calendar.event:list", "calendar.event:create"},
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "product",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
{
|
||||
name: "domain",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domains", "calendar")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
tc.setFlags(cmd)
|
||||
|
||||
err := cmd.RunE(cmd, tc.args)
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if tc.wantPlan {
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
grantType string
|
||||
sessionFlag string
|
||||
sessionEnv string
|
||||
wantSessionID string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "once no session", grantType: "once"},
|
||||
{name: "permanent no session", grantType: "permanent"},
|
||||
{name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
|
||||
{name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
|
||||
{name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
|
||||
{name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
if tc.sessionEnv != "" {
|
||||
t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
|
||||
}
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", tc.grantType)
|
||||
if tc.sessionFlag != "" {
|
||||
_ = cmd.Flags().Set("session-id", tc.sessionFlag)
|
||||
}
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if got := fake.calls[0].args["grantType"]; got != tc.grantType {
|
||||
t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
|
||||
}
|
||||
if tc.wantSessionID == "" {
|
||||
if _, ok := fake.calls[0].args["sessionId"]; ok {
|
||||
t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
|
||||
t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
|
||||
}
|
||||
if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
|
||||
t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,11 +927,98 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
|
||||
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[0].dingSessionEnv != "env-session-123" {
|
||||
t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
errs: []error{
|
||||
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: patForgedIdentityCode,
|
||||
}),
|
||||
),
|
||||
nil,
|
||||
},
|
||||
responses: []string{
|
||||
"",
|
||||
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
|
||||
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
errs: []error{
|
||||
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: patForgedIdentityCode,
|
||||
}),
|
||||
),
|
||||
nil,
|
||||
},
|
||||
responses: []string{
|
||||
"",
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +1043,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
@@ -416,6 +1053,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
|
||||
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
|
||||
}
|
||||
@@ -428,6 +1068,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
t.Setenv(sessionIDEnvDingtalk, "stale-session")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
@@ -438,6 +1079,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
|
||||
t.Fatalf("sessionId arg = %#v, want flag-session", got)
|
||||
}
|
||||
@@ -458,6 +1102,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -523,7 +1168,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
|
||||
|
||||
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
|
||||
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
|
||||
// the env value up and forwards it verbatim in the MCP argv.
|
||||
// the env value up for both batch arguments and gateway-compatible env.
|
||||
func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
|
||||
@@ -543,7 +1188,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
|
||||
}
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch argv agentCode = %#v, want qoderwork", got)
|
||||
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
|
||||
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
|
||||
@@ -553,8 +1198,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
|
||||
func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
@@ -566,14 +1212,35 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
|
||||
if fake.gotTool != patBatchGrantToolName {
|
||||
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("agent env = %q, want empty so server default agentCode is used", got)
|
||||
t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
|
||||
}
|
||||
if fake.callN != 1 {
|
||||
t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
|
||||
}
|
||||
if fake.gotTool != patBatchGrantToolName {
|
||||
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs)
|
||||
t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
|
||||
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,6 +1453,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
|
||||
fake := &sequenceToolCaller{
|
||||
responses: []string{
|
||||
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
|
||||
`{"success":true,"data":{"authRequestId":"req-ok"}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolName {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
|
||||
t.Fatalf("fallback scopes = %#v, want mail:send", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
|
||||
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
if !isToolNotRegisteredError(err) {
|
||||
@@ -813,6 +1514,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
|
||||
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
|
||||
if !isPATBatchFallbackResult(result) {
|
||||
t.Fatal("isPATBatchFallbackResult() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
@@ -825,6 +1533,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
|
||||
}),
|
||||
)
|
||||
if !isPATBatchFallbackError(err) {
|
||||
t.Fatal("isPATBatchFallbackError() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
|
||||
err := handleToolResult(nil, nil, &edition.ToolResult{})
|
||||
if err == nil {
|
||||
@@ -978,34 +1698,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
|
||||
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
|
||||
}
|
||||
if got := fake.gotArgs["agentCode"]; got != "flagval" {
|
||||
t.Fatalf("batch argv agentCode = %#v, want flagval", got)
|
||||
t.Fatalf("batch agentCode = %#v, want flagval", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
|
||||
// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
|
||||
// legacy env MUST NOT be consumed. The command is still allowed to run,
|
||||
// omits agentCode, and lets lippi-pat-core write its default agentCode.
|
||||
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
|
||||
// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
|
||||
// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
|
||||
// apply its open-source default.
|
||||
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacyval")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
|
||||
}
|
||||
if fake.callN != 1 {
|
||||
t.Fatalf("CallTool was invoked %d times, want 1", fake.callN)
|
||||
t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1046,8 +1767,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
|
||||
code, src, "qoderwork", agentCodeEnv)
|
||||
}
|
||||
|
||||
// Reverse-guard: the draft reversed spelling is intentionally ignored.
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
|
||||
code, src)
|
||||
}
|
||||
|
||||
// Empty primary → ("", "").
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
|
||||
}
|
||||
|
||||
+10
-1
@@ -36,8 +36,17 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
|
||||
能力说明:
|
||||
pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时,
|
||||
才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。
|
||||
pat chmod 支持批量授权:可一次传多个 scope,也可通过
|
||||
--products / --product、--domains / --domain 或 --recommend
|
||||
让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。
|
||||
批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划,
|
||||
不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes
|
||||
时 CLI 会阻断并提示 agent 先确认。
|
||||
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
|
||||
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE;
|
||||
CLI 会把显式 agentCode 放入 batch 请求参数,
|
||||
并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。
|
||||
浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
|
||||
|
||||
Host-owned PAT 开关:
|
||||
|
||||
@@ -68,6 +68,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
|
||||
// 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").
|
||||
ClawTypeValue string
|
||||
|
||||
// --- runtime mode ---
|
||||
IsEmbedded bool // true when running inside a host application
|
||||
HideAuthLogin bool // true suppresses the "dws auth login" command
|
||||
@@ -165,3 +171,14 @@ func Override(h *Hooks) {
|
||||
defer mu.Unlock()
|
||||
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.
|
||||
func ClawType() string {
|
||||
if v := Get().ClawTypeValue; v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultOSSClawType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 edition
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClawTypeDefaultsToOSSValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(defaultHooks())
|
||||
if got := ClawType(); got != DefaultOSSClawType {
|
||||
t.Fatalf("ClawType() = %q, want %q", got, DefaultOSSClawType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClawTypeUsesOverlayValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
|
||||
if got := ClawType(); got != "wukong" {
|
||||
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
|
||||
// "custom authorization card" contract: the CLI switches to host-owned
|
||||
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
|
||||
// claw-type is purely a server-side routing tag and must NOT influence
|
||||
// the decision, in either direction.
|
||||
// claw-type is purely a server-side routing tag and must NOT influence the
|
||||
// decision, in either direction.
|
||||
//
|
||||
// Regression guard: several earlier drafts conflated the two signals,
|
||||
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
|
||||
@@ -33,6 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
agentCode string
|
||||
reversed string
|
||||
agentEnv string
|
||||
want bool
|
||||
}{
|
||||
@@ -48,6 +49,13 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
agentEnv: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reversed draft agent code only → CLI-owned",
|
||||
agentCode: "",
|
||||
reversed: "agt-compat",
|
||||
agentEnv: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=default → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
@@ -84,6 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed)
|
||||
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
|
||||
// influence the host-owned decision. The literal env name is
|
||||
// used here because the auth package no longer exports a
|
||||
@@ -97,6 +106,15 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
got, tc.want, tc.agentCode, tc.agentEnv,
|
||||
)
|
||||
}
|
||||
if tc.want {
|
||||
gotCode, gotSource := authpkg.AgentCodeFromEnv()
|
||||
wantCode := tc.agentCode
|
||||
wantSource := authpkg.AgentCodeEnv
|
||||
if gotCode != wantCode || gotSource != wantSource {
|
||||
t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)",
|
||||
gotCode, gotSource, wantCode, wantSource)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user