Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de6e46ef4 | ||
|
|
9a8157a4cd | ||
|
|
b8e915edfb | ||
|
|
bd370ed0e9 | ||
|
|
9fa76f8598 | ||
|
|
b175acb48f | ||
|
|
ed1cbd6f06 | ||
|
|
91f44a1efd | ||
|
|
1a7ba01e36 | ||
|
|
6310dcc39e | ||
|
|
f1a68f2424 | ||
|
|
497e4f87d8 | ||
|
|
a8d009aec8 | ||
|
|
1f413fa322 | ||
|
|
ece91bfa3c | ||
|
|
ff33114b2c |
@@ -0,0 +1,44 @@
|
||||
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
|
||||
name: Mirror code to Gitee
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
@@ -58,6 +58,16 @@ jobs:
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -6,6 +6,54 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.41] - 2026-06-24
|
||||
|
||||
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
|
||||
|
||||
### Changed
|
||||
|
||||
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
|
||||
|
||||
## [1.0.40] - 2026-06-24
|
||||
|
||||
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
|
||||
|
||||
### Added
|
||||
|
||||
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
|
||||
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
|
||||
|
||||
## [1.0.39] - 2026-06-18
|
||||
|
||||
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
|
||||
|
||||
## [1.0.38] - 2026-06-16
|
||||
|
||||
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
|
||||
|
||||
### Added
|
||||
|
||||
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
|
||||
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
|
||||
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
|
||||
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
|
||||
|
||||
## [1.0.37] - 2026-06-11
|
||||
|
||||
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
|
||||
|
||||
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## China mirror
|
||||
|
||||
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
|
||||
|
||||
**1. Install script + pre-built binary (Gitee mirror):**
|
||||
|
||||
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
|
||||
|
||||
**2. npm package (npmmirror mirror):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
|
||||
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 国内加速安装
|
||||
|
||||
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
|
||||
|
||||
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
|
||||
|
||||
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
|
||||
|
||||
**2. npm 包(npmmirror 镜像):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Agent identification (agent_code & agentId)
|
||||
|
||||
dws tags every MCP request with **which agent host is driving it** and a
|
||||
**per-instance id**, so usage can be sliced by channel/instance in the data
|
||||
warehouse. This page is the integration contract.
|
||||
|
||||
## What dws sends on the wire
|
||||
|
||||
| Header | Meaning | Granularity |
|
||||
|--------|---------|-------------|
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
|
||||
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
|
||||
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
|
||||
|
||||
`x-dws-agent-id` keeps its original machine-level meaning for backward
|
||||
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
|
||||
clients send no `agent_code` / instance id — treat their absence as
|
||||
"legacy/unknown", not an error.
|
||||
|
||||
## How `agent_code` is resolved (confidence ladder)
|
||||
|
||||
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
|
||||
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
|
||||
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
|
||||
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
|
||||
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
|
||||
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
|
||||
5. **T4 — `custom`:** unknown host. Never guessed.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
Auto-detection cannot cover every agent: most terminal agents (gemini/
|
||||
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
|
||||
continue, …) expose **no reliable self-identifying env var** — only user-set
|
||||
API keys, which must not be used as identity. The robust answer is: **the host
|
||||
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
|
||||
server.** This is accurate for any agent, on any OS, and is future-proof.
|
||||
|
||||
MCP server config example (JSON-style hosts):
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"dingtalk-workspace": {
|
||||
"command": "dws",
|
||||
"args": ["mcp", "..."],
|
||||
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical codes
|
||||
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
|
||||
self-reported, best-effort signals, NOT an authenticated identity.**
|
||||
|
||||
- `agent_code`: every declaration/auto-detect signal is an env var the
|
||||
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
|
||||
`claudecode`).
|
||||
- The ids are **even easier to forge**: they are generated, stored, and sent
|
||||
entirely client-side. `machineId` is a random UUID in the plaintext
|
||||
`~/.dws/identity.json` (which the user owns), and the instance id is just
|
||||
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
|
||||
header — lets anyone mint, split, rotate, or impersonate ids at will. The
|
||||
`dwsa_` prefix does NOT make it a secure identifier.
|
||||
|
||||
- ✅ **Fit for statistics / observability** (the intended use): there is no
|
||||
incentive to misreport one's own agent, and real hosts emit real signals, so
|
||||
aggregate per-channel metrics are reliable in practice.
|
||||
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
|
||||
revocation.** Anything where a party benefits from lying must not trust this
|
||||
field. For control-plane use you need a gateway-issued **authoritative**
|
||||
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
|
||||
heavier mechanism, deliberately out of scope here.
|
||||
|
||||
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
|
||||
|
||||
## Gateway side (required for the data to land)
|
||||
|
||||
dws sending the headers is necessary but not sufficient. The gateway must:
|
||||
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
|
||||
to the upstream-header pass-through allowlist (otherwise they are stripped);
|
||||
2. log them as fields, and deliver them to the warehouse (alongside the
|
||||
existing flow-control / execution logs).
|
||||
@@ -116,6 +116,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
tokenData, err = provider.Login(loginCtx)
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||
@@ -126,6 +127,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
if err != nil {
|
||||
@@ -186,7 +188,6 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
_ = cmd.Flags().MarkHidden("token-url")
|
||||
_ = cmd.Flags().MarkHidden("refresh-url")
|
||||
_ = cmd.Flags().MarkHidden("login-timeout")
|
||||
_ = cmd.Flags().MarkHidden("no-browser")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -687,7 +687,28 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
agentCode, _ := authpkg.AgentCodeFromEnv()
|
||||
// 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": agentCode,
|
||||
|
||||
@@ -328,14 +328,62 @@ 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()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,10 +95,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||
}
|
||||
|
||||
skillSrc, err := resolveSkillSetupSource(source, mode)
|
||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcCleanup()
|
||||
|
||||
dests, err := resolveSkillSetupTargets(target, mode)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
)
|
||||
|
||||
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
|
||||
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
|
||||
// override (validated as an on-disk dir). Otherwise it falls back to the skill
|
||||
// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
|
||||
// the version shipped with the running binary — upgrading the binary therefore
|
||||
// refreshes the installed skill, instead of silently reusing a stale copy from
|
||||
// the current working directory.
|
||||
//
|
||||
// The returned cleanup func removes any temp dir created for the embedded
|
||||
// bundle; it is a no-op when an on-disk source is used. Always call it.
|
||||
func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
|
||||
noop := func() {}
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
|
||||
if explicit != "" || env != "" {
|
||||
dir, err := resolveSkillSetupSource(explicit, mode)
|
||||
return dir, noop, err
|
||||
}
|
||||
return materializeEmbeddedSkillSource(mode)
|
||||
}
|
||||
|
||||
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> subtree
|
||||
// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
|
||||
// real directory lets the existing dir-based install/copy logic stay unchanged.
|
||||
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
|
||||
noop := func() {}
|
||||
sub := "skills/" + mode // embed.FS always uses forward slashes
|
||||
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
|
||||
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
|
||||
if err != nil {
|
||||
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(tmp) }
|
||||
|
||||
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
|
||||
dst := filepath.Join(tmp, filepath.FromSlash(rel))
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
||||
return mkErr
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
})
|
||||
if walkErr != nil {
|
||||
cleanup()
|
||||
return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
|
||||
}
|
||||
return tmp, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
|
||||
// baked into the binary can be extracted to a temp dir and is a valid skill
|
||||
// source root (so `dws skill setup` works with zero local checkout). The
|
||||
// nested-reference and _common checks guard against the embed dropping nested
|
||||
// docs or the `all:` prefix being lost (which would silently skip
|
||||
// dot/underscore dirs).
|
||||
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMono) {
|
||||
t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"SKILL.md",
|
||||
filepath.Join("references", "global-reference.md"),
|
||||
filepath.Join("references", "best_practices", "_common"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup must actually remove the temp dir.
|
||||
cleanup()
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
|
||||
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
|
||||
// rather than probing the current working directory (the stale-skill footgun).
|
||||
func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMono) {
|
||||
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -56,6 +57,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -68,6 +70,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
@@ -88,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -108,6 +112,7 @@ type upgradeOptions struct {
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
@@ -298,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
|
||||
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
|
||||
// If anything fails in Phase 1, no files on disk are modified.
|
||||
|
||||
// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
|
||||
// touching the filesystem. Kept side-effect-free and writer-injectable so the
|
||||
// --dry-run contract can be asserted in tests.
|
||||
func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
|
||||
fmt.Fprintf(w, " 将执行以下操作:\n")
|
||||
fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
|
||||
fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
|
||||
if hasSkills {
|
||||
fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
|
||||
}
|
||||
fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
|
||||
fmt.Fprintf(w, " [4/5] 解压并验证\n")
|
||||
replaceStep := "替换二进制"
|
||||
if hasSkills {
|
||||
replaceStep += " 并安装技能包"
|
||||
}
|
||||
fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
|
||||
}
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
|
||||
@@ -339,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
|
||||
// --dry-run: preview only. Resolve the platform asset so a missing build is
|
||||
// still reported, then describe the steps that *would* run and return before
|
||||
// any side effect (no backup, no download, no replace). Matches the global
|
||||
// flag's contract: "预览操作内容,不实际执行".
|
||||
if opts.dryRun {
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否升级? [y/N] ")
|
||||
|
||||
@@ -430,6 +430,60 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
// Regression for #364: --dry-run must be discoverable from upgrade help so
|
||||
// users know it is supported (and is now actually honored).
|
||||
if !strings.Contains(help, "--dry-run") {
|
||||
t.Error("help should advertise --dry-run for upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeDryRunPlan (#364) ---
|
||||
//
|
||||
// Regression for #364: `dws upgrade --dry-run` previously performed a real
|
||||
// upgrade because the flag was silently ignored. The dry-run path must now be
|
||||
// preview-only — it describes the steps without downloading or replacing
|
||||
// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
|
||||
|
||||
func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dry-run") {
|
||||
t.Errorf("output should be marked as dry-run, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "不会下载或修改任何文件") {
|
||||
t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
|
||||
t.Errorf("output should name the resolved platform asset, got:\n%s", out)
|
||||
}
|
||||
// All five steps should be previewed, including the (skipped) replace step.
|
||||
for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
|
||||
if !strings.Contains(out, step) {
|
||||
t.Errorf("output missing step %s, got:\n%s", step, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRunPlan_WithSkills(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dws-skills.zip") {
|
||||
t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "安装技能包") {
|
||||
t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Without skills, neither should appear.
|
||||
var buf2 bytes.Buffer
|
||||
writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
|
||||
if strings.Contains(buf2.String(), "dws-skills.zip") {
|
||||
t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- isLikelyAMFIKill ---
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// agent_code_detect.go resolves the agent_code — which agent HOST is driving
|
||||
// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
|
||||
// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
|
||||
//
|
||||
// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
|
||||
// the two are never conflated here.
|
||||
//
|
||||
// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
|
||||
// - Prefer generalizable, host-declared signals so one rule covers a whole
|
||||
// family (VSCODE_BRAND covers every VS Code fork, present and future).
|
||||
// - Every per-host signature below is OBSERVED on a real host (live process
|
||||
// env via `ps eww`, or the app bundle Info.plist), not guessed.
|
||||
// - Anything unidentified falls back to AgentCodeCustom — never guess.
|
||||
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
|
||||
// not the agent host) and fuzzy parent-process name matching.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentCodeCustom is the honest fallback for any host we cannot identify.
|
||||
const AgentCodeCustom = "custom"
|
||||
|
||||
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
|
||||
// match when any listed key is present and non-empty.
|
||||
type hostSignature struct {
|
||||
Code string
|
||||
EnvKeys []string
|
||||
}
|
||||
|
||||
// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
|
||||
// the dws subprocess they spawn inherits. All verified on a real machine
|
||||
// (2026-06-16) via live process env / launch env — not guessed.
|
||||
var knownSignatures = []hostSignature{
|
||||
// Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
|
||||
{Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
|
||||
// OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
|
||||
{Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
|
||||
// Hermes — verified on the running gateway: HERMES_HOME.
|
||||
{Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
|
||||
// OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
|
||||
// spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
|
||||
// CODEX_-prefixed name out of user .env to prevent spoofing — so its
|
||||
// presence reliably means "running under Codex".
|
||||
// Source: developers.openai.com/codex/concepts/sandboxing
|
||||
{Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
|
||||
}
|
||||
|
||||
// NOTE on coverage limits (honest, not a TODO to silently ignore):
|
||||
// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
|
||||
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
|
||||
// self-identifying env marker — only user-set API-key/config vars, which we
|
||||
// must not key off (a user setting GEMINI_API_KEY is not "running under
|
||||
// gemini"). They therefore resolve to custom unless they declare themselves.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
|
||||
// That is accurate for ANY agent (present or future) on ANY OS, and is what an
|
||||
// integrating host should wire up. Auto-detection (signatures / VSCODE_BRAND /
|
||||
// bundle id) is a best-effort supplement for hosts that have not declared.
|
||||
|
||||
// bundleIDToCode maps macOS app bundle identifiers to agent codes. The bundle
|
||||
// id is exposed via __CFBundleIdentifier and inherited by child processes the
|
||||
// IDE spawns (including dws), so it identifies the host even from an integrated
|
||||
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
|
||||
// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
|
||||
//
|
||||
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
|
||||
// this map is simply a no-op there (os.Getenv returns "").
|
||||
var bundleIDToCode = map[string]string{
|
||||
"com.qoder.ide": "qoder",
|
||||
"com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
|
||||
"com.microsoft.VSCode": "vscode",
|
||||
"com.workbuddy.workbuddy": "workbuddy",
|
||||
}
|
||||
|
||||
// DetectAgentCode resolves the agent_code via a confidence ladder and returns
|
||||
// the normalized code plus the signal that decided it:
|
||||
//
|
||||
// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
|
||||
// T1 verified per-agent env signature (CLI/daemon agents)
|
||||
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
|
||||
// T3 macOS app bundle id (known agent bundles only)
|
||||
// T4 fallback -> custom (never guess)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return normalizeAgentCode(v), "env:" + name
|
||||
}
|
||||
|
||||
// T1: verified per-agent env signature (most specific — wins over the IDE
|
||||
// it may be running inside).
|
||||
for _, sig := range knownSignatures {
|
||||
for _, k := range sig.EnvKeys {
|
||||
if strings.TrimSpace(os.Getenv(k)) != "" {
|
||||
return sig.Code, "sig:" + k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2: VS Code fork family. The brand value IS the host's self-declaration,
|
||||
// so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
|
||||
// including forks that don't exist yet.
|
||||
if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
|
||||
return normalizeAgentCode(b), "env:VSCODE_BRAND"
|
||||
}
|
||||
|
||||
// T3: macOS app bundle id (known agent bundles only).
|
||||
if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
|
||||
if c, ok := bundleIDToCode[id]; ok {
|
||||
return c, "bundle:" + id
|
||||
}
|
||||
}
|
||||
|
||||
// T4: unknown host — honest fallback, no guessing.
|
||||
return AgentCodeCustom, "fallback"
|
||||
}
|
||||
|
||||
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
|
||||
// values. Unrecognized but non-empty input is lowercased, space-stripped and
|
||||
// kept as-is — still a host declaration, so still accurate (this is what gives
|
||||
// automatic coverage of new VS Code forks via VSCODE_BRAND).
|
||||
func normalizeAgentCode(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
switch s {
|
||||
case "":
|
||||
return AgentCodeCustom
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder", "qoderwork":
|
||||
return "qoder"
|
||||
case "workbuddy", "work-buddy":
|
||||
return "workbuddy"
|
||||
case "visualstudiocode", "code", "code-oss", "vscode":
|
||||
return "vscode"
|
||||
case "cursor":
|
||||
return "cursor"
|
||||
case "windsurf":
|
||||
return "windsurf"
|
||||
case "trae", "traecn":
|
||||
return "trae"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
|
||||
// all so each case starts clean (the suite itself runs under a real host).
|
||||
var agentCodeSignalEnvs = []string{
|
||||
AgentCodeEnv,
|
||||
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
|
||||
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
|
||||
"HERMES_HOME", "CODEX_SANDBOX",
|
||||
"VSCODE_BRAND", "__CFBundleIdentifier",
|
||||
"TERM_PROGRAM", "DWS_CHANNEL",
|
||||
}
|
||||
|
||||
func clearAgentCodeEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range agentCodeSignalEnvs {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(AgentCodeEnv, "Qoder")
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "qoder" {
|
||||
t.Fatalf("want qoder, got %q", code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
|
||||
t.Fatalf("want env signal, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
|
||||
cases := []struct {
|
||||
env, val, want string
|
||||
}{
|
||||
{"CLAUDECODE", "1", "claudecode"},
|
||||
{"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
|
||||
{"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
|
||||
{"HERMES_HOME", "/Users/x/.hermes", "hermes"},
|
||||
{"CODEX_SANDBOX", "seatbelt", "codex"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.env, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(c.env, c.val)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != c.want {
|
||||
t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "sig:") {
|
||||
t.Fatalf("want sig:* signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Qoder": "qoder",
|
||||
"Cursor": "cursor",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Windsurf": "windsurf",
|
||||
"Trae": "trae",
|
||||
"SomeNewFork": "somenewfork", // generic coverage of future forks
|
||||
}
|
||||
for brand, want := range cases {
|
||||
t.Run(brand, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("VSCODE_BRAND", brand)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != want {
|
||||
t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
|
||||
}
|
||||
if sig != "env:VSCODE_BRAND" {
|
||||
t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_BundleID_T3(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"com.qoder.ide": "qoder",
|
||||
"com.todesktop.230313mzl4w4u92": "cursor",
|
||||
"com.microsoft.VSCode": "vscode",
|
||||
"com.workbuddy.workbuddy": "workbuddy",
|
||||
}
|
||||
for id, want := range cases {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", id)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != want {
|
||||
t.Fatalf("bundle %q: want %q, got %q", id, want, code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "bundle:") {
|
||||
t.Fatalf("want bundle:* signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
|
||||
// custom.
|
||||
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("unknown bundle must be custom, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("want custom, got %q", code)
|
||||
}
|
||||
if sig != "fallback" {
|
||||
t.Fatalf("want fallback, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
|
||||
func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("TERM_PROGRAM", "iTerm.app")
|
||||
t.Setenv("DWS_CHANNEL", "Qoderwork")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
|
||||
// (T2). A CLI agent running inside an IDE reports the CLI agent.
|
||||
func TestDetectAgentCode_Precedence(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("CLAUDECODE", "1") // T1
|
||||
t.Setenv("VSCODE_BRAND", "Qoder") // T2
|
||||
if code, _ := DetectAgentCode(); code != "claudecode" {
|
||||
t.Fatalf("T1 must beat T2, got %q", code)
|
||||
}
|
||||
t.Setenv(AgentCodeEnv, "workbuddy") // T0
|
||||
if code, _ := DetectAgentCode(); code != "workbuddy" {
|
||||
t.Fatalf("T0 must beat all, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentCode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"claude": "claudecode",
|
||||
"Claude-Code": "claudecode",
|
||||
"CLAUDECODE": "claudecode",
|
||||
"Qoderwork": "qoder",
|
||||
"WorkBuddy": "workbuddy",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Cursor": "cursor",
|
||||
"": AgentCodeCustom,
|
||||
"some-new-ide": "some-new-ide",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeAgentCode(in); got != want {
|
||||
t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ type DeviceFlowProvider struct {
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -205,7 +206,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
}
|
||||
dfPrintDeviceCodeBox(p.output(), authResp)
|
||||
|
||||
if authResp.VerificationURIComplete != "" {
|
||||
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
|
||||
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
|
||||
p.logger.Debug("could not open browser", "error", bErr)
|
||||
}
|
||||
|
||||
+151
-14
@@ -13,17 +13,27 @@
|
||||
|
||||
// identity.go manages agent instance identification for tracking.
|
||||
//
|
||||
// Each agent installation gets a unique agentId (UUID v4) that persists across
|
||||
// version upgrades but regenerates on reinstall. This identity is transparently
|
||||
// injected into MCP HTTP headers for gateway-side data collection.
|
||||
// Identity has two granularities, both injected into MCP HTTP headers for
|
||||
// gateway-side statistics:
|
||||
//
|
||||
// - machineId: a stable per-install UUID v4 (persists across upgrades,
|
||||
// regenerates on reinstall). Non-PII.
|
||||
// - agentId: a per-(machine × agentCode) id derived deterministically from
|
||||
// machineId + agent_code, so one machine running multiple agent hosts
|
||||
// (e.g. claudecode + cursor) yields a distinct, idempotent agentId per
|
||||
// agent_code. Computed client-side — no gateway round-trip required.
|
||||
//
|
||||
// The agent_code itself is resolved by DetectAgentCode (agent_code_detect.go).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -31,14 +41,34 @@ import (
|
||||
|
||||
const identityFile = "identity.json"
|
||||
|
||||
// identityVersion is the current on-disk schema version. v1 files (no
|
||||
// machineId/agents) are migrated transparently on load.
|
||||
const identityVersion = 2
|
||||
|
||||
// AgentEntry records the derived agentId for a single agent_code on this
|
||||
// machine.
|
||||
type AgentEntry struct {
|
||||
AgentID string `json:"agentId"`
|
||||
FirstSeen string `json:"firstSeen,omitempty"`
|
||||
Detect string `json:"detect,omitempty"` // signal that decided the agent_code
|
||||
}
|
||||
|
||||
// Identity holds the agent instance identification fields.
|
||||
//
|
||||
// AgentID is retained for backward compatibility with v1 readers: on a fresh
|
||||
// install it is written equal to MachineID, and a v1 file's agentId is migrated
|
||||
// into MachineID on load.
|
||||
type Identity struct {
|
||||
AgentID string `json:"agentId"` // UUID v4, generated at install time
|
||||
Source string `json:"source"` // data source, default "dws"
|
||||
Version int `json:"version,omitempty"`
|
||||
AgentID string `json:"agentId"` // v1 install UUID; == MachineID on v2 installs
|
||||
MachineID string `json:"machineId,omitempty"` // stable per-install machine seed
|
||||
Source string `json:"source"` // data source, default "dws"
|
||||
Agents map[string]*AgentEntry `json:"agents,omitempty"` // agent_code -> derived agentId
|
||||
}
|
||||
|
||||
// Load reads the identity from <configDir>/identity.json.
|
||||
// Returns nil if the file does not exist or cannot be parsed.
|
||||
// v1 files are migrated in-memory (machineId backfilled from agentId).
|
||||
func Load(configDir string) *Identity {
|
||||
path := filepath.Join(configDir, identityFile)
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -49,21 +79,43 @@ func Load(configDir string) *Identity {
|
||||
if err := json.Unmarshal(data, &id); err != nil {
|
||||
return nil
|
||||
}
|
||||
if id.AgentID == "" {
|
||||
if id.AgentID == "" && id.MachineID == "" {
|
||||
return nil
|
||||
}
|
||||
id.migrate()
|
||||
return &id
|
||||
}
|
||||
|
||||
// migrate backfills v2 fields from a v1 file in-memory (does not persist).
|
||||
func (id *Identity) migrate() {
|
||||
if id.MachineID == "" {
|
||||
id.MachineID = id.AgentID // v1 install UUID becomes the machine seed
|
||||
}
|
||||
if id.AgentID == "" {
|
||||
id.AgentID = id.MachineID
|
||||
}
|
||||
if id.Source == "" {
|
||||
id.Source = "dws"
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
}
|
||||
id.Version = identityVersion
|
||||
}
|
||||
|
||||
// EnsureExists loads existing identity or creates a new one if not present.
|
||||
func EnsureExists(configDir string) *Identity {
|
||||
if id := Load(configDir); id != nil {
|
||||
return id
|
||||
}
|
||||
|
||||
u := generateUUID()
|
||||
id := &Identity{
|
||||
AgentID: generateUUID(),
|
||||
Source: "dws",
|
||||
Version: identityVersion,
|
||||
AgentID: u, // kept == MachineID for backward-compat
|
||||
MachineID: u,
|
||||
Source: "dws",
|
||||
Agents: make(map[string]*AgentEntry),
|
||||
}
|
||||
|
||||
// Best-effort persist — don't fail the CLI if write fails.
|
||||
@@ -71,14 +123,51 @@ func EnsureExists(configDir string) *Identity {
|
||||
return id
|
||||
}
|
||||
|
||||
// Headers returns the identity as HTTP header key-value pairs.
|
||||
// machineSeed returns the stable seed used to derive per-channel agentIds.
|
||||
func (id *Identity) machineSeed() string {
|
||||
if id.MachineID != "" {
|
||||
return id.MachineID
|
||||
}
|
||||
return id.AgentID
|
||||
}
|
||||
|
||||
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
|
||||
// persisting it on first sight of an agentCode. Idempotent: the same machine
|
||||
// and agentCode always yields the same id, which is what makes cumulative
|
||||
// per-agent_code statistics possible. An empty agentCode is treated as the
|
||||
// custom bucket.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
agentCode = AgentCodeCustom
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
}
|
||||
if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
|
||||
return e.AgentID
|
||||
}
|
||||
aid := deriveAgentID(id.machineSeed(), agentCode)
|
||||
id.Agents[agentCode] = &AgentEntry{
|
||||
AgentID: aid,
|
||||
FirstSeen: time.Now().UTC().Format(time.RFC3339),
|
||||
Detect: signal,
|
||||
}
|
||||
_ = save(configDir, id) // best-effort cache; recomputable if it fails
|
||||
return aid
|
||||
}
|
||||
|
||||
// Headers returns the identity as static HTTP header key-value pairs.
|
||||
// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
|
||||
// continuous across versions. The per-(machine × agent_code) instance id is a
|
||||
// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
|
||||
// ResolveAgentID — it does not override x-dws-agent-id.
|
||||
func (id *Identity) Headers() map[string]string {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
h := make(map[string]string, 5)
|
||||
if id.AgentID != "" {
|
||||
h["x-dws-agent-id"] = id.AgentID
|
||||
if seed := id.machineSeed(); seed != "" {
|
||||
h["x-dws-agent-id"] = seed
|
||||
}
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
|
||||
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
|
||||
}
|
||||
|
||||
// deriveAgentID computes a stable, client-side agentId for a (machine,
|
||||
// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
|
||||
// Deterministic and idempotent; no gateway allocation needed for statistics.
|
||||
func deriveAgentID(seed, agentCode string) string {
|
||||
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
|
||||
enc := base62Encode(sum[:])
|
||||
for len(enc) < 12 {
|
||||
enc = "0" + enc
|
||||
}
|
||||
return "dwsa_" + enc[:12]
|
||||
}
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func base62Encode(b []byte) string {
|
||||
n := new(big.Int).SetBytes(b)
|
||||
if n.Sign() == 0 {
|
||||
return "0"
|
||||
}
|
||||
base := big.NewInt(62)
|
||||
mod := new(big.Int)
|
||||
var out []byte
|
||||
for n.Sign() > 0 {
|
||||
n.DivMod(n, base, mod)
|
||||
out = append(out, base62Alphabet[mod.Int64()])
|
||||
}
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// generateUUID produces a UUID v4 string.
|
||||
func generateUUID() string {
|
||||
var u [16]byte
|
||||
@@ -113,6 +234,22 @@ func generateUUID() string {
|
||||
}
|
||||
u[6] = (u[6] & 0x0f) | 0x40 // version 4
|
||||
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
|
||||
return fmtUUID(u)
|
||||
}
|
||||
|
||||
func fmtUUID(u [16]byte) string {
|
||||
const hexdig = "0123456789abcdef"
|
||||
// 8-4-4-4-12 with dashes => 36 bytes
|
||||
buf := make([]byte, 36)
|
||||
pos := 0
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
buf[pos] = '-'
|
||||
pos++
|
||||
}
|
||||
buf[pos] = hexdig[u[i]>>4]
|
||||
buf[pos+1] = hexdig[u[i]&0x0f]
|
||||
pos += 2
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveAgentID_Format(t *testing.T) {
|
||||
id := deriveAgentID("machine-abc", "claudecode")
|
||||
if !strings.HasPrefix(id, "dwsa_") {
|
||||
t.Fatalf("want dwsa_ prefix, got %q", id)
|
||||
}
|
||||
if len(id) != len("dwsa_")+12 {
|
||||
t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_Deterministic(t *testing.T) {
|
||||
a := deriveAgentID("seed", "claudecode")
|
||||
b := deriveAgentID("seed", "claudecode")
|
||||
if a != b {
|
||||
t.Fatalf("derivation must be deterministic: %q != %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
|
||||
m1c1 := deriveAgentID("machine1", "claudecode")
|
||||
m1c2 := deriveAgentID("machine1", "cursor")
|
||||
m2c1 := deriveAgentID("machine2", "claudecode")
|
||||
if m1c1 == m1c2 {
|
||||
t.Errorf("same machine, different channel must differ: %q", m1c1)
|
||||
}
|
||||
if m1c1 == m2c1 {
|
||||
t.Errorf("different machine, same channel must differ: %q", m1c1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
|
||||
first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
if first != second {
|
||||
t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
|
||||
}
|
||||
|
||||
// Reload from disk — the channel entry must have persisted.
|
||||
reloaded := Load(dir)
|
||||
if reloaded == nil {
|
||||
t.Fatal("expected identity to persist")
|
||||
}
|
||||
e, ok := reloaded.Agents["claudecode"]
|
||||
if !ok || e.AgentID != first {
|
||||
t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
|
||||
}
|
||||
if e.Detect != "sig:CLAUDECODE" {
|
||||
t.Errorf("want detect signal recorded, got %q", e.Detect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
got := id.ResolveAgentID(dir, "", "fallback")
|
||||
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
|
||||
if got != want {
|
||||
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
|
||||
// legacy agentId, and per-channel derivation keyed off that stable seed.
|
||||
func TestLoad_MigratesV1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := Load(dir)
|
||||
if id == nil {
|
||||
t.Fatal("v1 file should load")
|
||||
}
|
||||
if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
|
||||
t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
|
||||
}
|
||||
if id.machineSeed() != id.MachineID {
|
||||
t.Fatalf("seed should be machineId, got %q", id.machineSeed())
|
||||
}
|
||||
// Derivation is stable against the migrated seed.
|
||||
want := deriveAgentID(id.MachineID, "claudecode")
|
||||
if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
|
||||
t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type OAuthProvider struct {
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -393,8 +394,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
if !p.NoBrowser {
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
@@ -185,7 +185,7 @@ func executePipelineCall(
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -333,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
|
||||
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
|
||||
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
|
||||
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
|
||||
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
|
||||
func attachAITag(cmd *cobra.Command, params map[string]any) {
|
||||
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
|
||||
params["clawType"] = edition.ClawType()
|
||||
}
|
||||
}
|
||||
|
||||
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
|
||||
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
|
||||
// content 内携带 title)。
|
||||
@@ -442,6 +453,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
|
||||
}
|
||||
params := map[string]any{"msgType": msgType, "content": contentJSON}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -481,6 +493,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
@@ -493,6 +506,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return params, "send_personal_message", nil
|
||||
case hasUser:
|
||||
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
|
||||
attachAITag(cmd, params)
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
params := map[string]any{
|
||||
@@ -500,6 +514,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -975,8 +990,10 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
|
||||
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
|
||||
attachAITag(cmd, params)
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -1000,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -308,6 +310,104 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
|
||||
// by default NO user-identity send carries the clawType tool argument (so the IM
|
||||
// server renders no AI badge). Only when --ai-tag is passed does each path attach
|
||||
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
|
||||
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
|
||||
// dws does not surprise users by branding every message they send.
|
||||
func TestChatMessageAITagControlsClawType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
make func(runner executor.Runner) *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group-markdown",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "group-rich-media-image",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
make: newChatMessageReplyCommand,
|
||||
args: []string{
|
||||
"--conversation-id", "cid-xyz",
|
||||
"--ref-msg-id", "msg-1",
|
||||
"--ref-sender", "op-1",
|
||||
"--text", "got it",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// Default: no --ai-tag → must omit clawType entirely (no badge).
|
||||
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if v, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("default send must omit clawType, got %#v", v)
|
||||
}
|
||||
})
|
||||
// Opt-in: --ai-tag → attach the edition claw identity.
|
||||
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
got, ok := runner.last.Params["clawType"]
|
||||
if !ok {
|
||||
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
if got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Robot sends are rendered as bot messages already; they must NOT carry the
|
||||
// user-identity clawType argument.
|
||||
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendByBotCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if _, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func equalAny(a, b any) bool {
|
||||
switch av := a.(type) {
|
||||
case []any:
|
||||
|
||||
+83
-2
@@ -335,6 +335,10 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
|
||||
jsonml = stripped
|
||||
}
|
||||
createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -362,7 +366,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 {
|
||||
@@ -425,6 +429,83 @@ func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
|
||||
return strings.TrimLeft(rest, "\r\n"), true
|
||||
}
|
||||
|
||||
// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of
|
||||
// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format
|
||||
// jsonml) writes the body verbatim via update_document, so a leading h1 node
|
||||
// whose text equals the document name renders the title twice — exactly the
|
||||
// problem the markdown path already guards against.
|
||||
//
|
||||
// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON
|
||||
// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The
|
||||
// first content node is removed only when it is an h1 whose concatenated text
|
||||
// (trimmed, case-insensitive) exactly equals name. Any parse failure or
|
||||
// non-match returns the input untouched, so this never blocks a valid write.
|
||||
func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
var body []any
|
||||
if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
// Locate the index of the first content node, skipping a ["root", {}, ...]
|
||||
// wrapper if present.
|
||||
first := 0
|
||||
if len(body) >= 2 {
|
||||
if tag, ok := body[0].(string); ok && tag == "root" {
|
||||
first = 2
|
||||
}
|
||||
}
|
||||
if first >= len(body) {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
node, ok := body[first].([]any)
|
||||
if !ok || len(node) == 0 {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
tag, ok := node[0].(string)
|
||||
if !ok || tag != "h1" {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
body = append(body[:first], body[first+1:]...)
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return jsonmlBody, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the
|
||||
// leading tag name and any attributes object, so ["h1", {..}, ["span", {..},
|
||||
// ["span", {..}, "Title"]]] yields "Title".
|
||||
func jsonmlNodeText(node any) string {
|
||||
switch v := node.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
var b strings.Builder
|
||||
for i, child := range v {
|
||||
// element[0] is the tag name; a map at element[1] is attributes.
|
||||
if i == 0 {
|
||||
if _, isStr := child.(string); isStr {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, isMap := child.(map[string]any); isMap {
|
||||
continue
|
||||
}
|
||||
b.WriteString(jsonmlNodeText(child))
|
||||
}
|
||||
return b.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
@@ -473,7 +554,7 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
if sniffJsonMLLike(content) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
|
||||
}
|
||||
params["markdown"] = content
|
||||
params["markdown"] = stripDocInputUnsafe(content)
|
||||
addDocIntParam(cmd, params, "index", "index")
|
||||
}
|
||||
return runDocTool(cmd, runner, "doc", "update_document", params)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
var docDangerousUnicode = [...]rune{
|
||||
0x200B,
|
||||
0x200C,
|
||||
0x200D,
|
||||
0x200E,
|
||||
0x200F,
|
||||
0x202A,
|
||||
@@ -33,6 +34,8 @@ var docDangerousUnicode = [...]rune{
|
||||
0x202C,
|
||||
0x202D,
|
||||
0x202E,
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x2066,
|
||||
0x2067,
|
||||
0x2068,
|
||||
@@ -49,8 +52,20 @@ var docDangerousSet = func() map[rune]bool {
|
||||
return m
|
||||
}()
|
||||
|
||||
func stripDocDangerousUnicode(s string) string {
|
||||
// stripDocInputUnsafe removes characters that the server-side RejectControlChars
|
||||
// validator (mirrored by apiclient.rejectDangerousChars) would reject:
|
||||
//
|
||||
// 1. C0 control characters (except tab and newline) and DEL (0x7F)
|
||||
// 2. Dangerous Unicode (zero-width, Bidi controls, line/paragraph separators, BOM)
|
||||
//
|
||||
// It is applied at the write boundary so document content passes server
|
||||
// validation instead of being rejected. Tab and newline are preserved because
|
||||
// they are legitimate in document text.
|
||||
func stripDocInputUnsafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7F) {
|
||||
return -1
|
||||
}
|
||||
if docDangerousSet[r] {
|
||||
return -1
|
||||
}
|
||||
@@ -194,7 +209,7 @@ func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
|
||||
}
|
||||
return stripDocDangerousUnicode(string(out)), nil
|
||||
return stripDocInputUnsafe(string(out)), nil
|
||||
}
|
||||
|
||||
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
||||
@@ -247,7 +262,7 @@ func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
return stripDocInputUnsafe(string(out)), nil
|
||||
}
|
||||
|
||||
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestStripDocInputUnsafe verifies that stripDocInputUnsafe removes exactly the
|
||||
// characters the server-side RejectControlChars validator rejects (C0 controls
|
||||
// except tab/newline, DEL, and the dangerous-Unicode set), while leaving all
|
||||
// legitimate text untouched. Offending codepoints use explicit \u / \x escapes
|
||||
// so they are unambiguous in source.
|
||||
func TestStripDocInputUnsafe(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "preserves plain text",
|
||||
in: "正常的文档内容 with ASCII",
|
||||
want: "正常的文档内容 with ASCII",
|
||||
},
|
||||
{
|
||||
name: "keeps tab and newline",
|
||||
in: "标题\n\t正文",
|
||||
want: "标题\n\t正文",
|
||||
},
|
||||
{
|
||||
name: "drops C0 controls (null, SOH, CR) and DEL",
|
||||
in: "正文\x00内容\x01段落\x0d结尾\x7f",
|
||||
want: "正文内容段落结尾",
|
||||
},
|
||||
{
|
||||
name: "drops zero-width space/non-joiner/joiner",
|
||||
in: "正文\u200b内容\u200c段落\u200d结尾",
|
||||
want: "正文内容段落结尾",
|
||||
},
|
||||
{
|
||||
name: "drops bidi overrides and isolates",
|
||||
in: "Bidi\u202a测试\u202e结束\u2066左\u2069右",
|
||||
want: "Bidi测试结束左右",
|
||||
},
|
||||
{
|
||||
name: "drops line and paragraph separators",
|
||||
in: "\u2028\u2029行段",
|
||||
want: "行段",
|
||||
},
|
||||
{
|
||||
name: "drops BOM / ZWNBSP",
|
||||
in: "BOM\ufeff尾",
|
||||
want: "BOM尾",
|
||||
},
|
||||
{
|
||||
name: "drops mixed control and dangerous unicode",
|
||||
in: "混合\x00测试\u200b结尾\x7f",
|
||||
want: "混合测试结尾",
|
||||
},
|
||||
{
|
||||
name: "empty string stays empty",
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := stripDocInputUnsafe(tc.in); got != tc.want {
|
||||
t.Fatalf("stripDocInputUnsafe(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -159,3 +159,110 @@ func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
|
||||
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocCreateStripsDuplicateTitleJSONML verifies the end-to-end JSONML path:
|
||||
// `doc create --content-format jsonml` must drop a leading h1 whose text equals
|
||||
// --name before forwarding the body to update_document, otherwise the rich
|
||||
// document shows the page title twice.
|
||||
func TestDocCreateStripsDuplicateTitleJSONML(t *testing.T) {
|
||||
runner := &docCommandRunner{responses: []map[string]any{{"nodeId": "NODE_X"}}}
|
||||
root := newDocTestRoot(runner)
|
||||
|
||||
body := `{"jsonml":[["h1",{},"命令树参考"],["p",{},"正文"]]}`
|
||||
_, errOut, err := executeDocCommand(t, root,
|
||||
"create", "--name", "命令树参考",
|
||||
"--content-format", "jsonml", "--content", body)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v\nstderr:\n%s", err, errOut)
|
||||
}
|
||||
if len(runner.all) != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (create + update)", len(runner.all))
|
||||
}
|
||||
if runner.all[1].Tool != "update_document" {
|
||||
t.Fatalf("second tool = %q, want update_document", runner.all[1].Tool)
|
||||
}
|
||||
got, _ := runner.all[1].Params["jsonml"].(string)
|
||||
if strings.Contains(got, "命令树参考") {
|
||||
t.Errorf("jsonml = %q, want duplicate h1 stripped", got)
|
||||
}
|
||||
if !strings.Contains(got, "正文") {
|
||||
t.Errorf("jsonml = %q, want body content kept", got)
|
||||
}
|
||||
if !strings.Contains(errOut, "已自动移除") {
|
||||
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripLeadingDuplicateTitleJSONML covers the JSONML-path counterpart: a
|
||||
// leading h1 node whose text equals the document name is removed; everything
|
||||
// else is left untouched.
|
||||
func TestStripLeadingDuplicateTitleJSONML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
docName string
|
||||
want string
|
||||
stripped bool
|
||||
}{
|
||||
{
|
||||
name: "root-wrapped duplicate h1 stripped",
|
||||
body: `["root",{},["h1",{},"晚会简报"],["p",{},"正文"]]`,
|
||||
docName: "晚会简报",
|
||||
want: `["root",{},["p",{},"正文"]]`,
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "nested leaf text matches and stripped",
|
||||
body: `["root",{},["h1",{"uuid":"x"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"晚会简报"]]],["p",{},"正文"]]`,
|
||||
docName: "晚会简报",
|
||||
want: `["root",{},["p",{},"正文"]]`,
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "bare body without root wrapper",
|
||||
body: `[["h1",{},"标题"],["p",{},"正文"]]`,
|
||||
docName: "标题",
|
||||
want: `[["p",{},"正文"]]`,
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
body: `["root",{},["h1",{},"Weekly REPORT"],["p",{},"x"]]`,
|
||||
docName: "weekly report",
|
||||
want: `["root",{},["p",{},"x"]]`,
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "distinct heading kept",
|
||||
body: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
|
||||
docName: "晚会简报",
|
||||
want: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
|
||||
stripped: false,
|
||||
},
|
||||
{
|
||||
name: "non-h1 leading node kept",
|
||||
body: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
|
||||
docName: "晚会简报",
|
||||
want: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
|
||||
stripped: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json untouched",
|
||||
body: `not json`,
|
||||
docName: "x",
|
||||
want: `not json`,
|
||||
stripped: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := stripLeadingDuplicateTitleJSONML(tc.body, tc.docName)
|
||||
if ok != tc.stripped {
|
||||
t.Fatalf("stripped = %v, want %v", ok, tc.stripped)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("body = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ type Hooks struct {
|
||||
Name string // "open" (default) / overlay identifier
|
||||
ScenarioCode string // injected into x-dingtalk-scenario-code header
|
||||
|
||||
// ClawTypeValue is the claw identity carried in message-send tool
|
||||
// arguments (parameter clawType) so the IM server can render the
|
||||
// "Send from AI" indicator on delivered messages. Empty → falls back
|
||||
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
|
||||
ClawTypeValue string
|
||||
|
||||
// --- runtime mode ---
|
||||
IsEmbedded bool // true when running inside a host application
|
||||
HideAuthLogin bool // true suppresses the "dws auth login" command
|
||||
@@ -165,3 +171,14 @@ func Override(h *Hooks) {
|
||||
defer mu.Unlock()
|
||||
current = h
|
||||
}
|
||||
|
||||
// ClawType returns the claw identity for the active edition, falling back
|
||||
// to DefaultOSSClawType when the overlay does not set one. Message-send
|
||||
// helpers attach this value as the clawType tool argument so the IM server
|
||||
// can label delivered messages as sent via AI.
|
||||
func ClawType() string {
|
||||
if v := Get().ClawTypeValue; v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultOSSClawType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package edition
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClawTypeDefaultsToOSSValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(defaultHooks())
|
||||
if got := ClawType(); got != DefaultOSSClawType {
|
||||
t.Fatalf("ClawType() = %q, want %q", got, DefaultOSSClawType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClawTypeUsesOverlayValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
|
||||
if got := ClawType(); got != "wukong" {
|
||||
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,14 @@ set -eu
|
||||
# Environment variables (optional):
|
||||
# DWS_VERSION — release tag (default: latest)
|
||||
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
|
||||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API.
|
||||
GITEE_REPO="${DWS_GITEE_REPO:-}"
|
||||
# Auto-fallback Gitee mirror used when GitHub is unreachable (see pick_source).
|
||||
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
SKILL_NAME="dws"
|
||||
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
|
||||
@@ -29,10 +35,43 @@ need_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
|
||||
gitee_api() {
|
||||
_url="$1"
|
||||
_try=1
|
||||
while [ "$_try" -le 4 ]; do
|
||||
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
|
||||
printf '%s' "$_resp"
|
||||
return 0
|
||||
fi
|
||||
_try=$((_try + 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
pick_source() {
|
||||
[ -n "$GITEE_REPO" ] && return 0
|
||||
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
|
||||
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
|
||||
GITEE_REPO="$GITEE_FALLBACK_REPO"
|
||||
printf ' ⚠ GitHub 不可达,自动切换国内 Gitee 镜像: %s\n' "$GITEE_REPO"
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable, so
|
||||
# resolve the newest vN.N.N tag from the git tags endpoint instead.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
else
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2
|
||||
exit 1
|
||||
@@ -40,6 +79,20 @@ resolve_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve a release asset's download URL by name (GitHub template vs Gitee API).
|
||||
asset_url() {
|
||||
_name="$1"
|
||||
if [ -z "$GITEE_REPO" ]; then
|
||||
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
|
||||
return 0
|
||||
fi
|
||||
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
|
||||
| tr '}' '\n' \
|
||||
| grep "\"name\":[ ]*\"${_name}\"" \
|
||||
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
|
||||
| head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
|
||||
}
|
||||
|
||||
extract_zip() {
|
||||
archive="$1"
|
||||
dest="$2"
|
||||
@@ -154,6 +207,7 @@ install_skills_to_root() {
|
||||
|
||||
main() {
|
||||
need_cmd curl
|
||||
pick_source
|
||||
resolve_version
|
||||
|
||||
printf '\n'
|
||||
@@ -166,7 +220,8 @@ main() {
|
||||
TMPDIR_WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
|
||||
|
||||
ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/dws-skills.zip"
|
||||
ASSET_URL="$(asset_url dws-skills.zip)"
|
||||
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
|
||||
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
|
||||
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
|
||||
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
|
||||
|
||||
+68
-3
@@ -18,6 +18,8 @@
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
@@ -25,6 +27,10 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
$BinName = "dws"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
|
||||
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
|
||||
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
|
||||
$GiteeFallbackRepo = if ($env:DWS_GITEE_FALLBACK_REPO) { $env:DWS_GITEE_FALLBACK_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
|
||||
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
|
||||
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
|
||||
@@ -118,8 +124,61 @@ function Get-Arch {
|
||||
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
|
||||
}
|
||||
|
||||
function Invoke-GiteeApi {
|
||||
param([string]$Uri)
|
||||
# Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
|
||||
for ($i = 1; $i -le 4; $i++) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $Uri -UseBasicParsing
|
||||
} catch {
|
||||
if ($i -eq 4) { throw }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GiteeAssetUrl {
|
||||
param([string]$Name)
|
||||
# Resolve a release asset's download URL by name via the Gitee API
|
||||
# (Gitee attachment URLs carry an unstable numeric id, so never template them).
|
||||
$rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
|
||||
foreach ($a in $rel.assets) {
|
||||
if ($a.name -eq $Name) { return $a.browser_download_url }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function Resolve-Source {
|
||||
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
|
||||
if ($GiteeRepo -ne "") { return }
|
||||
if ($env:DWS_NO_FALLBACK -eq "1") { return }
|
||||
try {
|
||||
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" -Method Head `
|
||||
-TimeoutSec 12 -UseBasicParsing -ErrorAction Stop 2>$null | Out-Null
|
||||
return
|
||||
} catch {
|
||||
$script:GiteeRepo = $GiteeFallbackRepo
|
||||
Write-Say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: $script:GiteeRepo"
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-LatestVersion {
|
||||
if ($Version -eq "latest") {
|
||||
if ($GiteeRepo -ne "") {
|
||||
try {
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable
|
||||
# (404 / empty even when releases exist), so resolve the newest
|
||||
# vN.N.N tag from the git tags endpoint instead.
|
||||
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
|
||||
$latest = $tags.name |
|
||||
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
|
||||
ForEach-Object { [version]($_.TrimStart('v')) } |
|
||||
Sort-Object | Select-Object -Last 1
|
||||
if ($latest) { $script:Version = "v$latest"; return }
|
||||
} catch {}
|
||||
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
|
||||
return
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
|
||||
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
|
||||
@@ -289,7 +348,8 @@ function Install-Binary {
|
||||
Resolve-LatestVersion
|
||||
|
||||
$archiveName = "${BinName}-windows-${arch}.zip"
|
||||
$downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
|
||||
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
|
||||
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
|
||||
|
||||
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
|
||||
|
||||
@@ -301,7 +361,7 @@ function Install-Binary {
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
$checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
|
||||
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
|
||||
try {
|
||||
$checksumPath = Join-Path $tmpDir "checksums.txt"
|
||||
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
|
||||
@@ -483,7 +543,8 @@ function Install-Skills {
|
||||
Write-Say "📦 Installing agent skills from GitHub Releases..."
|
||||
Resolve-LatestVersion
|
||||
|
||||
$zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip"
|
||||
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
|
||||
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
|
||||
|
||||
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
|
||||
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
|
||||
@@ -546,6 +607,10 @@ $SourceRoot = Resolve-SourceRoot
|
||||
|
||||
Write-Banner
|
||||
|
||||
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
|
||||
# Skipped when installing from a local source checkout (no download needed).
|
||||
if (-not $SourceRoot) { Resolve-Source }
|
||||
|
||||
if (!$NoSkills) {
|
||||
Resolve-SkillMode
|
||||
}
|
||||
|
||||
+83
-5
@@ -15,6 +15,8 @@
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
|
||||
# via the Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
@@ -22,6 +24,15 @@ set -eu
|
||||
|
||||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
BIN_NAME="dws"
|
||||
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
|
||||
# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
|
||||
# carry an unstable numeric id, so every asset is resolved by name at install time.
|
||||
GITEE_REPO="${DWS_GITEE_REPO:-}"
|
||||
# Auto-fallback: when DWS_GITEE_REPO is not set, the installer probes GitHub and,
|
||||
# if it is unreachable (typical in mainland China), automatically switches to this
|
||||
# Gitee mirror — so a plain `curl … | sh` works everywhere with no env var.
|
||||
# Set DWS_NO_FALLBACK=1 to disable the probe and force GitHub.
|
||||
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
@@ -77,6 +88,42 @@ download() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
|
||||
# returns sporadic 502/503, so a single curl is unreliable; retry a few times
|
||||
# before giving up. Prints the response body on success.
|
||||
gitee_api() {
|
||||
_url="$1"
|
||||
_try=1
|
||||
while [ "$_try" -le 4 ]; do
|
||||
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
|
||||
printf '%s' "$_resp"
|
||||
return 0
|
||||
fi
|
||||
_try=$((_try + 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the download URL of a release asset by file name.
|
||||
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
|
||||
# Gitee : query the release API and pick the asset whose name matches
|
||||
# (Gitee attachment URLs carry an unstable numeric id, so we never
|
||||
# template them — we read browser_download_url straight from the API).
|
||||
asset_url() {
|
||||
_name="$1"
|
||||
if [ -z "$GITEE_REPO" ]; then
|
||||
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
|
||||
return 0
|
||||
fi
|
||||
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
|
||||
| tr '}' '\n' \
|
||||
| grep "\"name\":[ ]*\"${_name}\"" \
|
||||
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
|
||||
| head -1 \
|
||||
| sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
|
||||
}
|
||||
|
||||
extract_zip() {
|
||||
archive="$1"
|
||||
dest="$2"
|
||||
@@ -111,11 +158,36 @@ detect_arch() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Decide the download source. An explicit DWS_GITEE_REPO always wins. Otherwise
|
||||
# probe GitHub Releases; if it is unreachable (typical in mainland China), switch
|
||||
# GITEE_REPO to the mirror so every subsequent resolve/download uses Gitee.
|
||||
pick_source() {
|
||||
[ -n "$GITEE_REPO" ] && return 0
|
||||
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
|
||||
if need_cmd curl; then
|
||||
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
|
||||
elif need_cmd wget; then
|
||||
wget -q --timeout=12 --tries=1 -O /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
|
||||
fi
|
||||
GITEE_REPO="$GITEE_FALLBACK_REPO"
|
||||
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
|
||||
}
|
||||
|
||||
# Resolve the latest version tag from GitHub
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
# Follow the redirect from /releases/latest to get the tag
|
||||
if need_cmd curl; then
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable — they
|
||||
# return 404 / an empty list even when releases exist — so resolve the
|
||||
# newest version from the git tags endpoint instead: keep only vN.N.N
|
||||
# tags and pick the highest by version order.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
elif need_cmd curl; then
|
||||
# Follow the redirect from /releases/latest to get the tag
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
elif need_cmd wget; then
|
||||
@@ -401,7 +473,8 @@ install_binary() {
|
||||
resolve_version
|
||||
|
||||
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
|
||||
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
|
||||
download_url="$(asset_url "$archive_name")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
|
||||
|
||||
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
|
||||
|
||||
@@ -411,7 +484,7 @@ install_binary() {
|
||||
download "$download_url" "$tmpdir/$archive_name"
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
checksum_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
|
||||
checksum_url="$(asset_url checksums.txt)"
|
||||
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
|
||||
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
|
||||
if [ -n "$expected" ]; then
|
||||
@@ -482,7 +555,8 @@ install_skills() {
|
||||
|
||||
resolve_version
|
||||
skills_archive="dws-skills.zip"
|
||||
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
|
||||
download_url="$(asset_url "$skills_archive")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
|
||||
|
||||
tmpdir_skills="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
|
||||
@@ -559,6 +633,10 @@ main() {
|
||||
|
||||
print_banner
|
||||
|
||||
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
|
||||
# Skipped when installing from a local source checkout (no download needed).
|
||||
[ -z "$source_root" ] && pick_source
|
||||
|
||||
# Resolve skill mode only when we are actually going to touch skills.
|
||||
if [ "$NO_SKILLS" != "1" ]; then
|
||||
resolve_skill_mode
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# Mirror release artifacts to a Gitee release so China users can install without
|
||||
# hitting GitHub. The repo code itself is kept in sync by Gitee's repo-mirror
|
||||
# feature; this script handles what the mirror does NOT carry — the GitHub
|
||||
# Release *attachments* (binaries, checksums, skills zip) — by uploading them to
|
||||
# the matching Gitee release via the Gitee OpenAPI v5.
|
||||
#
|
||||
# Consumed by install.sh when DWS_GITEE_REPO is set (it resolves each asset's
|
||||
# real download_url from the Gitee API, since Gitee attachment URLs carry an
|
||||
# unstable numeric id).
|
||||
#
|
||||
# Required environment (CI secrets):
|
||||
# GITEE_TOKEN Gitee private access token (scopes: projects)
|
||||
# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
# Optional:
|
||||
# VERSION release tag (default: git describe)
|
||||
# DIST_DIR artifacts dir (default: ./dist)
|
||||
# GITEE_API API base (default: https://gitee.com/api/v5)
|
||||
#
|
||||
# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
|
||||
# step can live in release.yml without breaking forks that lack the secret.
|
||||
|
||||
set -eu
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
|
||||
missing=""
|
||||
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
|
||||
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
|
||||
echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
|
||||
|
||||
# ── Resolve or create the Gitee release for this tag ──────────────────────────
|
||||
# Gitee mirror sync brings the git tag over, so the tag should already exist.
|
||||
rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
rel_json="$(curl -fsSL -X POST "${base}/releases" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "tag_name=${VERSION}" \
|
||||
-F "name=${VERSION}" \
|
||||
-F "body=Mirror of GitHub release ${VERSION} for China users." \
|
||||
-F "target_commitish=main" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
fi
|
||||
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
# ── Upload each artifact as a release attachment ──────────────────────────────
|
||||
uploaded=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
echo " ⬆ ${fn}"
|
||||
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "file=@${f}" 2>/dev/null || true)"
|
||||
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
|
||||
uploaded=$((uploaded + 1))
|
||||
else
|
||||
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
|
||||
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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 dws is the module-root package. Its sole purpose is to bake the
|
||||
// skills/ documentation tree into the binary at build time so that
|
||||
// `dws skill setup` installs the skill version shipped with THIS binary,
|
||||
// independent of any local checkout. See internal/app/skill_setup.go.
|
||||
package dws
|
||||
|
||||
import "embed"
|
||||
|
||||
// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into
|
||||
// the binary. The `all:` prefix is required so dot/underscore entries — e.g.
|
||||
// references/best_practices/_common — are included rather than skipped.
|
||||
//
|
||||
//go:embed all:skills
|
||||
var EmbeddedSkills embed.FS
|
||||
Reference in New Issue
Block a user