Compare commits

...
9 Commits
Author SHA1 Message Date
修雨 9e15115ad4 ci(release): add workflow_dispatch trigger as fallback (#261)
GitHub occasionally drops tag push events; this adds a manual trigger
so we can re-run the release job against any tag ref without having to
delete and re-push the tag.
2026-05-11 09:53:23 +08:00
xianfeng wangandgithub-actions[bot] 25bf3d12f2 feat(upgrade): block self-upgrade in embedded distributions (#248)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

* upgrade not in embed

* remove comment

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 20:27:20 +08:00
修雨 f78cc5c846 docs(auth): correct login help to reflect actual default + SSH guidance (#226) (#238)
* docs(auth): correct login help to reflect actual default + SSH guidance (#226)

The --help long description for `dws auth login` claimed the default mode was
"OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback
listener (oauth_provider.go:131-136) and only switches to device flow when
--device is passed. SSH-into-headless-Linux users following the docs hit a
dead end because the local browser cannot reach 127.0.0.1 on the remote box.

Rewrite the description so each method is named after its real flag:
  - OAuth Loopback 流 (默认)
  - OAuth 设备流 (--device)
  - 直接提供 Token (--token)

Add an explicit warning callout and a `--device` example for SSH/headless
environments. Also realign two flagErrorWithSuggestions strings in root.go
that propagated the same misconception ("默认使用设备流" → loopback default,
SSH 用户加 --device).

No behavioural change.

* docs(auth): drop emoji from login help warning
2026-05-09 09:25:57 +08:00
修雨 72fe795f3f docs(changelog): add 1.0.23 release notes (#241)
Backfill 1.0.23 entry covering PR #237 (HTTP_PROXY/HTTPS_PROXY support
restored across the three custom http.Transport instances). Format
follows the 1.0.22 section: narrative + Fixed + Tests.
2026-05-08 20:41:11 +08:00
修雨 0e892c7d75 fix: honour HTTP_PROXY/HTTPS_PROXY in custom HTTP transports (#236) (#237)
The three custom http.Transport instances built by the CLI
(`internal/transport/client.go` MCP transport, `internal/apiclient/client.go`
DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry
client) all set DialContext / TLSClientConfig / timeouts but omit the Proxy
field. Per Go's net/http contract, a non-nil Transport without an explicit
Proxy means "no proxy" — env vars are silently ignored, breaking sandboxed
or air-gapped deployments that route outbound through HTTP_PROXY /
HTTPS_PROXY.

Set Proxy: http.ProxyFromEnvironment on all three. Adds a regression test
per package that pointer-compares the Transport's Proxy func against
http.ProxyFromEnvironment (avoids flakiness from Go's envProxyOnce
memoisation when running alongside tests that read proxy env early).
2026-05-08 16:07:14 +08:00
修雨 8995bf65d6 docs(changelog): backfill 1.0.20 / 1.0.21 / 1.0.22 release notes (#231) 2026-05-07 21:52:09 +08:00
ybc❤️zyrand猷诺 91af2bc3b8 fix(install): add .hermes/skills to AGENT_DIRS (#188) (#221)
* fix(install): add .hermes/skills to AGENT_DIRS (#188)

dws 安装后未自动复制 skill 到 .hermes/skills/。在 4 份硬编码的
AGENT_DIRS 清单(build/npm/install.js、scripts/install.sh、
scripts/install.ps1、scripts/install-skills.sh)末尾追加
.hermes/skills,与现有'父目录守卫'逻辑天然兼容:

- 已安装 Hermes 的用户:自动获得 ~/.hermes/skills/dws
- 未安装 Hermes 的用户:父目录不存在则跳过,零副作用

Closes #188

* fix(install): cover remaining 4 AGENT_DIRS sources for .hermes/skills (review feedback)

Address review feedback from #221: the AGENT_DIRS list actually has 8 sources
in the repo, not 4. Without this commit, dws upgrade users and Homebrew users
would still NOT get .hermes/skills/dws populated even after the previous
4-script fix landed.

Fixes (functional):
- internal/upgrade/paths.go (knownSkillDirs):
    Append .hermes/skills so 'dws upgrade' refreshes ~/.hermes/skills/dws/
    for users who have Hermes installed.
- build/homebrew.rb.tmpl (post_install targets):
    Append .hermes/skills/dws so brew users also get the skill copied to
    ~/.hermes/skills/dws on post_install.

Fixes (test / smoke coverage):
- test/scripts/package_script_test.go (expectedPackagedSkillTargets):
    Append .hermes/skills/dws so future regressions (e.g. someone removes
    .hermes from install.js) are caught by CI.
- scripts/release/verify-package-managers.sh (HOME_AGENT_PARENTS / HOME_SKILL_TARGETS):
    Append .hermes to both lists so the release-time npm/brew smoke test
    actually asserts ~/.hermes/skills/dws/SKILL.md exists.

Documentation:
- internal/upgrade/paths.go: expanded the 'Kept in sync with' comment from
    a single file reference (build/npm/install.js) to all 7 in-sync sources,
    so future maintainers don't have to rediscover this list.

Verification:
- go vet ./internal/upgrade/... ./test/scripts/...   PASS
- go build ./...                                     PASS
- sh -n scripts/install.sh                           PASS
- sh -n scripts/install-skills.sh                    PASS
- sh -n scripts/release/verify-package-managers.sh   PASS
- node --check build/npm/install.js                  PASS

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 11:07:27 +08:00
ybc❤️zyrand猷诺 e2e8b3bf52 fix(attendance): summary 命令新增 --stats-type flag,修复 C0002 报错 (#228)
* fix(attendance): add --stats-type flag to summary to fix C0002 error

The MCP tool get_attendance_summary requires statsType in QueryUserAttendVO
at the server-side business layer (DingTalk schema marks it required:[] but
service rejects with C0002 'statistics type error' when omitted).

Changes:
- internal/helpers/attendance.go: add --stats-type flag (week/month);
  conditionally write statsType into QueryUserAttendVO when provided;
  update Long description and flag help to mark --stats-type as required
- test/cli_compat/attendance_test.go: add execSummaryDryRun helper and
  3 new tests verifying statsType is correctly threaded through VO
- skills/references/products/attendance.md: document --stats-type flag
  with warning about server-side mandatory enforcement

Verification:
- go build ./...                                                  PASS
- go vet ./internal/helpers/... ./test/cli_compat/...              PASS
- go test -run TestAttendanceSummary_should_pass_stats_type_*      PASS (3/3)
- go test -run TestAttendanceSummary_should_not_pass_stats_type_*  PASS
- Real CLI: dws attendance summary --stats-type month              success:true
  (C0002 fully gone; full attendance data returned)

Closes #227

* fix(attendance): enforce --stats-type as required at CLI layer (review feedback)

Address review feedback from #228:

1. Fail-fast validation: --stats-type is now required at the CLI layer
   instead of being conditionally written into VO. Previously the doc
   said 'required' but RunE silently let omission through, causing the
   same C0002 the fix was supposed to prevent.

2. Enum validation: only 'week' or 'month' are accepted; any other value
   is rejected at the CLI layer instead of being forwarded to the server.

3. Cosmetic: split the single-line Long description into multiple lines
   for readability.

Test changes:
- Replaced TestAttendanceSummary_should_not_pass_stats_type_when_omitted
  with two semantically-correct tests:
    * TestAttendanceSummary_should_error_when_stats_type_missing
    * TestAttendanceSummary_should_error_when_stats_type_invalid
- Updated other TestAttendanceSummary_* tests to pass --stats-type=month
  to match the new fail-fast contract.
- Renamed should_pass_only_user_flag to should_pass_user_id_through_vo
  (the old name no longer makes sense now that --stats-type is mandatory).

Skill doc:
- skills/references/products/attendance.md: clarified that the CLI now
  rejects missing/invalid --stats-type at the client side (won't even
  reach the server).

Verification:
- go build ./...                                    PASS
- go vet ./internal/helpers/... ./test/cli_compat/  PASS
- go test -run 'TestAttendanceSummary_should_(pass_stats_type|error_when_stats_type)' -v
                                                    PASS (4/4)
- dws attendance summary --help                     new layout shown

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 10:50:24 +08:00
ybc❤️zyrand猷诺 a652b90fd4 fix: resolve cross-product tool routing collision (issue #219) (#220)
When two MCP servers register tools with the same name (e.g. both drive
and doc have 'create_folder'), the tool-level endpoint map uses
last-writer-wins, causing invocations to route to the wrong server.

Fix: swap Priority 1 (product-level) and Priority 2 (tool-level) in
directRuntimeEndpoint so that when the caller already knows the
productID, the product endpoint is authoritative. Tool-level lookup
remains as a fallback for cases where productID is empty.

This fixes 'dws drive mkdir' and 'dws drive download' being silently
routed to the doc MCP server instead of the drive MCP server.

Closes #219

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-05 16:21:51 +08:00
25 changed files with 568 additions and 30 deletions
+1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
+49
View File
@@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.23] - 2026-05-08
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
### Fixed
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
### Tests
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
## [1.0.22] - 2026-05-07
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
### Fixed
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
### Tests
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
## [1.0.21] - 2026-05-05
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
### Fixed
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
## [1.0.20] - 2026-05-04
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
### Fixed
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
### Docs
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
- **CHANGELOG backfill for 1.0.19** (#204).
## [1.0.19] - 2026-04-30
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
+1
View File
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
+1
View File
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
+2
View File
@@ -210,6 +210,8 @@ func NormalisePath(path, baseURL string) string {
// defaultTransport returns a tuned http.Transport matching the project conventions.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+43
View File
@@ -0,0 +1,43 @@
// 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 apiclient
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
// a custom Transport without an explicit Proxy field silently bypasses
// HTTP_PROXY/HTTPS_PROXY.
//
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
// it, because http.ProxyFromEnvironment memoises the env on first call;
// other tests that read proxy env early would make a value-based assertion
// flaky.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+8 -3
View File
@@ -68,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
Long: `登录钉钉并获取认证凭证。
支持的登录方式:
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
- 直接提供 Token: 通过 --token 参数传入已有 token
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
- 直接提供 Token (--token): 跳过授权,使用已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 扫码登录
dws auth login # 本机扫码登录 (loopback 流)
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
+15 -8
View File
@@ -227,14 +227,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
te := dynamicToolEndpoints
dynamicMu.RUnlock()
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 2: product-level endpoint.
// Priority 1: product-level endpoint.
// When the caller already knows the productID (e.g. "drive"), the product
// endpoint is authoritative. This prevents cross-product tool name
// collisions (e.g. both "drive" and "doc" register "create_folder") from
// routing the request to the wrong MCP server. See issue #219.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
@@ -246,6 +243,16 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
}
}
// Priority 2: tool-level endpoint (fallback for unknown productID).
// This path is used when the caller does not know the productID but has a
// tool name, e.g. in helper invocations or plugin routes where only the
// tool name is available.
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 3: built-in PAT fallback for cold-start paths that run before
// discovery/plugin registration has populated the dynamic registry.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
@@ -181,3 +181,114 @@ func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.
})
}
}
// --- Issue #219 regression tests: cross-product tool name collision ---
//
// When two different products register tools with the same name (e.g. drive
// and doc both have "create_folder"), the product-level endpoint must win
// when the caller already knows the productID. Otherwise the tool-level map
// (last-writer-wins) routes the invocation to the wrong MCP server.
const (
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
)
func driveDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDriveEndpoint,
CLI: market.CLIOverlay{
ID: "drive",
Command: "drive",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "mkdir"},
"list_files": {CLIName: "list"},
"download_file": {CLIName: "download"},
"get_upload_info": {CLIName: "upload-info"},
},
},
}
}
func docDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDocEndpoint,
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "create", Group: "folder"},
"download_file": {CLIName: "download"},
"search_documents": {CLIName: "search"},
"list_nodes": {CLIName: "list"},
},
},
}
}
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
// that when productID is known and has a registered endpoint, the product-level
// endpoint is used even if the tool-level map points to a different server
// (due to same-name tool collision). This is the core fix for issue #219.
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "drive first, doc second",
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
},
{
name: "doc first, drive second",
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Drive tools must always route to drive's endpoint regardless of
// registration order — productID "drive" is known.
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
// Doc tools must always route to doc's endpoint.
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
// Product-level fallback (no tool name) still works.
assertEndpoint(t, "drive", "", testDriveEndpoint)
assertEndpoint(t, "doc", "", testDocEndpoint)
})
}
}
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
// tool-level routing still works as a fallback when productID is empty or has
// no registered endpoint (the original design intent for tool-level Priority 1).
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
// When productID is empty, tool-level endpoint is the only option.
// The actual endpoint depends on registration order (last-writer-wins),
// but the lookup must succeed.
endpoint, ok := directRuntimeEndpoint("", "create_folder")
if !ok {
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
}
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
}
// Unique tools (no collision) still resolve via tool-level.
assertEndpoint(t, "", "search_documents", testDocEndpoint)
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
}
+2
View File
@@ -464,6 +464,8 @@ func ipv4HTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
},
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"net/http"
"reflect"
"testing"
"time"
)
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
// IPv4-forcing client used by the legacy registry / discovery path. The
// custom Transport overrides DialContext to force IPv4 — without an
// explicit Proxy field it would also drop env-var proxy support.
//
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
// tests that read proxy env early would make this flaky. Asserting that the
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
// at process boot.
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
client := ipv4HTTPClient(5 * time.Second)
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+2 -2
View File
@@ -120,8 +120,8 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// Common flag aliases and suggestions
suggestions := map[string]string{
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
+9
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
@@ -57,6 +58,14 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if h := edition.Get(); h != nil && h.IsEmbedded {
name := h.Name
if name == "" {
name = "embedded"
}
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
}
yes, _ := cmd.Flags().GetBool("yes")
format := resolveUpgradeFormat(cmd)
@@ -0,0 +1,69 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
name string
args []string
}{
{"check", []string{"--check"}},
{"list", []string{"--list"}},
{"rollback", []string{"--rollback"}},
{"plain", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
}
msg := err.Error()
if !strings.Contains(msg, "嵌入模式") {
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
}
if !strings.Contains(msg, "embedded") {
t.Errorf("error message should include edition name, got: %q", msg)
}
if !strings.Contains(msg, "dws upgrade") {
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
}
})
}
}
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
t.Cleanup(func() { edition.Override(prev) })
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs([]string{"--check"})
err := cmd.Execute()
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
}
}
+17 -4
View File
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: `查询某个人的考勤统计摘要。
--user、--date、--stats-type 均必填。
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
userID, _ := cmd.Flags().GetString("user")
workDateStr, _ := cmd.Flags().GetString("date")
statsType, _ := cmd.Flags().GetString("stats-type")
if userID == "" {
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
}
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
}
if statsType == "" {
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
}
if statsType != "week" && statsType != "month" {
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
}
// Build nested structure QueryUserAttendVO
vo := map[string]any{
"userId": userID,
"queryDate": workDateStr,
"statsType": statsType,
}
params := map[string]any{
"QueryUserAttendVO": vo,
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
}
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
preferLegacyLeaf(cmd)
return cmd
}
+5
View File
@@ -211,6 +211,11 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
// "accepted but never responded" servers faster, and explicit TLS/dial timeouts.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars; a custom
// Transport without an explicit Proxy field would otherwise bypass
// proxies entirely, which breaks sandboxed/air-gapped deployments
// that rely on an outbound proxy (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+45
View File
@@ -0,0 +1,45 @@
// 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 transport
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv guards the fix for issue #236:
// the MCP HTTP transport must honour HTTP_PROXY / HTTPS_PROXY env vars.
// A custom Transport built without an explicit Proxy field defaults to
// "no proxy" — sandboxed deployments behind an outbound proxy would then
// silently bypass the proxy and fail.
//
// We assert tr.Proxy points at http.ProxyFromEnvironment rather than invoking
// it, because http.ProxyFromEnvironment memoises env on first call (Go's
// envProxyOnce). Test ordering with anything else that reads proxy env early
// would make a value-based assertion flaky. The runtime reads env at process
// boot — pointing at the stdlib resolver is the contract we need.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY/HTTPS_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+10 -1
View File
@@ -19,7 +19,15 @@ const (
)
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
// Kept in sync with build/npm/install.js AGENT_DIRS.
// Kept in sync with:
// - build/npm/install.js AGENT_DIRS
// - scripts/install.sh for-in list
// - scripts/install.ps1 $AgentDirs
// - scripts/install-skills.sh for-in list
// - build/homebrew.rb.tmpl targets
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
var knownSkillDirs = []string{
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
// skillDirBlacklist contains parent directories whose skills are managed by
+2 -1
View File
@@ -116,7 +116,8 @@ install_skills_to_root() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
+2 -1
View File
@@ -44,7 +44,8 @@ $AgentDirs = @(
".amp\skills",
".kiro\skills",
".trae\skills",
".openclaw\skills"
".openclaw\skills",
".hermes\skills"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -201,7 +201,8 @@ install_skills_to_homes() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
.kiro
.trae
.openclaw
.hermes
"
HOME_SKILL_TARGETS="
.agents/skills/dws
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
.kiro/skills/dws
.trae/skills/dws
.openclaw/skills/dws
.hermes/skills/dws
"
cleanup() {
if command -v brew >/dev/null 2>&1; then
+7 -3
View File
@@ -32,12 +32,16 @@ Flags:
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--user string 钉钉用户 ID (必填)
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
--user string 钉钉用户 ID (必填)
```
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
### 查询考勤组与考勤规则
```
Usage:
+112 -6
View File
@@ -1,6 +1,8 @@
package cli_compat_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
}
}
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U002",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
t.Fatal("no call captured")
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
}
}
// execSummaryDryRun runs attendance summary with --dry-run and returns the
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
// necessary because the attendance handler is a custom helper (not a dynamic
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
// call in non-dry-run mode.
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
t.Helper()
root := buildRoot()
_ = setupTestDeps(t, "attendance")
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
for k, v := range flags {
if v != "" {
cliArgs = append(cliArgs, "--"+k, v)
}
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&bytes.Buffer{})
root.SetArgs(cliArgs)
if err := root.Execute(); err != nil {
t.Fatalf("unexpected CLI error: %v", err)
}
var payload struct {
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
}
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
if !ok {
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
}
return vo
}
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if vo["statsType"] != "month" {
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "week",
})
if vo["statsType"] != "week" {
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
})
if err == nil {
t.Fatal("expected error when --stats-type is missing")
}
if !strings.Contains(err.Error(), "stats-type") {
t.Errorf("expected error message to mention stats-type, got: %v", err)
}
}
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "foobar",
})
if err == nil {
t.Fatal("expected error when --stats-type is neither week nor month")
}
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
t.Errorf("expected error message to mention week and month, got: %v", err)
}
}
// ── attendance rules ───────────────────────────────────────
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
+1
View File
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
".kiro/skills/dws",
".trae/skills/dws",
".openclaw/skills/dws",
".hermes/skills/dws",
}
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip