Compare commits

...
8 Commits
Author SHA1 Message Date
修雨 f1a68f2424 docs: cut 1.0.38 changelog (#469) 2026-06-16 20:20:20 +08:00
patrickmen 497e4f87d8 feat: support to disable auto open browser (#365) 2026-06-16 20:08:29 +08:00
Jackjin a8d009aec8 fix(upgrade): honor --dry-run as preview-only instead of silently upgrading (#416)
dws upgrade registered no --dry-run flag and never read the global one,
so --dry-run fell through to runUpgrade and performed a real, irreversible
upgrade (download + replace binary). This contradicts the flag's documented
contract (预览操作内容,不实际执行).

Resolve the target release and platform asset (so a missing build / 'already
latest' is still reported), then render the planned 1-5 steps and return
before any side effect: no backup, no download, no replace. Advertise
--dry-run in the command examples.

Fixes #364
2026-06-16 20:08:25 +08:00
thisred 1f413fa322 fix: pipeline PollUntil case-insensitive comparison to fix sheet export hang (#462) 2026-06-16 20:08:17 +08:00
修雨 ece91bfa3c feat(agent): accurate agent_code detection + per-channel agentId for stats (#467)
Tag each MCP request with which agent host is driving dws (agent_code) and a
per-(machine × agent_code) instance id, so usage can be sliced by channel and
instance in the data warehouse. Root cause it fixes: agent_code was only sent
when the host injected DINGTALK_DWS_AGENTCODE (~99.98% empty), so the gateway
logged none.

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

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

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

Includes unit tests for every tier and the integration doc.
2026-06-16 17:55:29 +08:00
修雨 ff33114b2c feat(doc): strip server-rejected unsafe chars on markdown write path (#465)
The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).

- Rename stripDocDangerousUnicode -> stripDocInputUnsafe and extend it to drop
  C0 controls (except \t and \n) and DEL, matching apiclient.rejectDangerousChars.
- Add U+200D, U+2028, U+2029 to the dangerous-Unicode set so it covers the
  full server-rejected range.
- Apply the strip on the markdown write path (doc create/update) and the JSONML
  node path, not just the JSONML body.
- Add unit tests for stripDocInputUnsafe.

Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
2026-06-16 10:56:53 +08:00
修雨 cdd8414891 docs: cut 1.0.37 changelog (#457) 2026-06-11 19:53:26 +08:00
xuanandshangguanxuan.sgx 2a82d07311 fix(pat): support batch agentCode and guarded grants (#455)
* fix(pat): carry agentCode in batch auth args

* fix(pat): let core default missing agent code

* fix(pat): require yes for batch grants

* test(pat): cover cli authorization matrix

* fix(pat): keep canonical agent code env only

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-11 19:21:25 +08:00
24 changed files with 2042 additions and 119 deletions
+23
View File
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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).
+93
View File
@@ -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).
+2 -1
View File
@@ -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
View File
@@ -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),
+59
View File
@@ -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")
+42
View File
@@ -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] ")
+54
View File
@@ -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 ---
+161
View File
@@ -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
}
}
+187
View File
@@ -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)
}
}
}
+28 -9
View File
@@ -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()
}
+2 -1
View File
@@ -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
View File
@@ -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)
}
+111
View File
@@ -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)
}
}
+5 -2
View File
@@ -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(), "")
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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)
+18 -3
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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
}
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 开关:
+20 -2
View File
@@ -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)
}
}
})
}
}