Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b956a780 | ||
|
|
5e4d974039 | ||
|
|
bb641c2098 | ||
|
|
7e1e93478b | ||
|
|
101e7be98d | ||
|
|
9545c1dde5 | ||
|
|
81367b2861 | ||
|
|
97bab33c71 | ||
|
|
ef6d65087f | ||
|
|
30a024e615 | ||
|
|
00e5ce7367 | ||
|
|
760c40dd71 | ||
|
|
aaa2d7d3be | ||
|
|
fbfcd69c2b | ||
|
|
fab9e5be52 | ||
|
|
ad056a8d83 | ||
|
|
5e254b4745 | ||
|
|
29a8a14760 | ||
|
|
00bef0a809 | ||
|
|
3f5b2fa8d3 | ||
|
|
b0e7b58e95 | ||
|
|
fe46cd4dc2 | ||
|
|
c993086d9d | ||
|
|
f522a9c2c2 | ||
|
|
d89649f9bb | ||
|
|
f218a05ead | ||
|
|
62aef1cc96 | ||
|
|
fc5e4d0d8d | ||
|
|
34248fabf3 | ||
|
|
430d20f2ee | ||
|
|
ada4acc395 | ||
|
|
8bebd77dfa | ||
|
|
b096fa06db | ||
|
|
107f3eaf6e | ||
|
|
df0c0f7545 | ||
|
|
91e67e2e45 | ||
|
|
75468dca1e | ||
|
|
ef5c05a2e6 | ||
|
|
1ac8636418 | ||
|
|
86086437d8 | ||
|
|
d432029d84 | ||
|
|
029bfdd2ed | ||
|
|
8c0551f359 | ||
|
|
b839ee664e | ||
|
|
c16c4aa59a | ||
|
|
aecc0bc588 | ||
|
|
cfad3bbf4a | ||
|
|
40736e2ae1 | ||
|
|
9aeb60da37 | ||
|
|
d3fcd814f3 | ||
|
|
5ed69744cc | ||
|
|
e5c9c91342 | ||
|
|
0e59fedbbb | ||
|
|
c39378864d | ||
|
|
7cb33e970c | ||
|
|
567cf163f1 | ||
|
|
d9ae15f9a6 | ||
|
|
922745f318 | ||
|
|
58dced0c8a | ||
|
|
3427b65da6 | ||
|
|
9d38a3be54 | ||
|
|
1cda263e0e | ||
|
|
5d5884a0d7 | ||
|
|
4a26f1ebd2 | ||
|
|
b65be61599 | ||
|
|
e193ba97f4 | ||
|
|
787a40ffcb | ||
|
|
f69d9ed281 | ||
|
|
f2b2de89f4 | ||
|
|
7915a5a4e1 | ||
|
|
6067906c55 | ||
|
|
493ca36e08 | ||
|
|
518b1cb631 | ||
|
|
f29655e7e4 | ||
|
|
cc8a726b0a | ||
|
|
f0552dd20e | ||
|
|
a040b57be7 | ||
|
|
a2e8700beb | ||
|
|
98804801c9 | ||
|
|
6946dd1e35 | ||
|
|
d09fca4b9b | ||
|
|
70d66daa5f | ||
|
|
1926bd17b9 | ||
|
|
6a392e611c | ||
|
|
710844382e | ||
|
|
b1a88106e5 | ||
|
|
dcf132a7e3 | ||
|
|
d2fd7e00b5 | ||
|
|
7720e3ec9b | ||
|
|
537719c677 | ||
|
|
51db546686 | ||
|
|
e9d50a659f | ||
|
|
2264743e78 | ||
|
|
6244222adf | ||
|
|
a31a29e0ab | ||
|
|
3ec35e30e8 | ||
|
|
da08e84e80 | ||
|
|
5e1983b3df | ||
|
|
8984f7b8be | ||
|
|
dd08db54d8 | ||
|
|
591609fdee | ||
|
|
6447bdd45f | ||
|
|
2610c8ce87 | ||
|
|
0e6cb46f58 | ||
|
|
a613728fbd | ||
|
|
70b9f39715 | ||
|
|
d29aebeecd | ||
|
|
844059a77b | ||
|
|
9ee8ea5524 | ||
|
|
42c71c9c54 | ||
|
|
690d0788e3 | ||
|
|
5a7c0748e3 | ||
|
|
410949cdf3 | ||
|
|
0197dbc81a | ||
|
|
b85353e042 | ||
|
|
f740955423 | ||
|
|
166f665708 | ||
|
|
55f5e0c3d0 | ||
|
|
f58b426381 | ||
|
|
2640338803 | ||
|
|
dbcbb1de85 | ||
|
|
63b6112fa2 | ||
|
|
eaeb9ac05d | ||
|
|
e82ccd3496 | ||
|
|
b4c9db8807 | ||
|
|
aa76f01015 | ||
|
|
931d7e59ef | ||
|
|
db2043c82b | ||
|
|
c2f3653ec4 | ||
|
|
11dfd4ccf2 | ||
|
|
ef60d99b26 | ||
|
|
1b6e197426 | ||
|
|
41b743de77 | ||
|
|
caf672699f | ||
|
|
67ac777657 | ||
|
|
c9ba0373c2 | ||
|
|
4a19530fd0 | ||
|
|
7a28d97739 | ||
|
|
d63f7e5836 | ||
|
|
8aaf0fb6e8 | ||
|
|
0f3c4ccbdd | ||
|
|
1f21fdf7be | ||
|
|
b2fd204e67 | ||
|
|
6f8c9173d3 | ||
|
|
1921e5e37e | ||
|
|
6480c035fa | ||
|
|
1c88dd6a0d | ||
|
|
6ce4a635b2 | ||
|
|
ee2fa735b2 | ||
|
|
a9df88654d | ||
|
|
e58e805b17 | ||
|
|
d1d6d9e74c | ||
|
|
b75f07547f | ||
|
|
ccb4927c48 | ||
|
|
1a2454a5ab | ||
|
|
0a8de62041 | ||
|
|
d6f44143ad | ||
|
|
4d831e5054 | ||
|
|
2c51774150 | ||
|
|
a10169344f | ||
|
|
82d625603f | ||
|
|
11780ae3b0 | ||
|
|
79bab762bf | ||
|
|
e840cac3cc | ||
|
|
45b4f088af | ||
|
|
b28ca6364e | ||
|
|
8b39dac5db | ||
|
|
7ecfe85696 | ||
|
|
9af7f9a034 | ||
|
|
c50494775c | ||
|
|
1fe4842525 | ||
|
|
5708e1c2d2 | ||
|
|
d43ec228aa | ||
|
|
fb335fb403 | ||
|
|
fd61526707 | ||
|
|
c858241b68 | ||
|
|
b826440995 | ||
|
|
25f69b867e | ||
|
|
172060b868 | ||
|
|
dd0990691e | ||
|
|
02bc7ce880 | ||
|
|
4d476ed317 | ||
|
|
ff89ffed69 | ||
|
|
65ab1a3076 | ||
|
|
cf277f3c8f | ||
|
|
59100407dc | ||
|
|
10de987e81 | ||
|
|
c36fcaaf70 | ||
|
|
0cf97cea55 | ||
|
|
47354b918a | ||
|
|
df1e33442b | ||
|
|
a7ca1e1a5b | ||
|
|
211a06c05a | ||
|
|
bb52a505dc | ||
|
|
f899f4f6b6 | ||
|
|
3adeffb09d | ||
|
|
435bf3151c | ||
|
|
1c90edb92a | ||
|
|
36b89a04b1 | ||
|
|
9d3980f90a | ||
|
|
ae5ef70bb8 | ||
|
|
89c3aba2ed | ||
|
|
c151756168 | ||
|
|
473d2f9aaf | ||
|
|
3dcb40c634 | ||
|
|
5e4b5e4eda | ||
|
|
d873b9c017 | ||
|
|
4b8acc8e73 | ||
|
|
0f65cb0064 | ||
|
|
f013536aac | ||
|
|
c56eb7f8c2 | ||
|
|
f9abc79ecc | ||
|
|
99893e473a | ||
|
|
51b3316187 | ||
|
|
9f744caafd | ||
|
|
532fcb4874 | ||
|
|
28d556c5e9 | ||
|
|
c106de8361 | ||
|
|
2e11a23381 | ||
|
|
2c1be7a8fb | ||
|
|
e637d793b0 | ||
|
|
864f94e80e | ||
|
|
7f1d36c9ac | ||
|
|
4eea61897d | ||
|
|
c99ac87839 | ||
|
|
1b3319603d | ||
|
|
995d490a76 | ||
|
|
5a401ddc1b | ||
|
|
f5bdb7118a | ||
|
|
4c86a9f8e1 | ||
|
|
3117ad5d86 | ||
|
|
0e484bb189 | ||
|
|
de4a292174 | ||
|
|
ad43e1ba28 | ||
|
|
ac825ad293 | ||
|
|
8411211a8c | ||
|
|
449d731239 | ||
|
|
13d77ee7b7 | ||
|
|
694288cfbc | ||
|
|
6f5a0afdf7 | ||
|
|
248c6602ac | ||
|
|
9589c23796 | ||
|
|
9460437c9a | ||
|
|
34d691906e | ||
|
|
a78765bb42 | ||
|
|
836f5bdb25 | ||
|
|
3282957958 | ||
|
|
252c61aff6 | ||
|
|
ffefc53861 | ||
|
|
8cbc56edd2 | ||
|
|
fc0873b0c6 | ||
|
|
eea85bd989 | ||
|
|
43560afa78 | ||
|
|
7ef46c1288 | ||
|
|
08413a6903 | ||
|
|
c488421423 | ||
|
|
b3e8783c4f | ||
|
|
1c2c96617a | ||
|
|
5fe366eddd | ||
|
|
a7d109384b | ||
|
|
9e7e7dbc7f | ||
|
|
7652bda320 | ||
|
|
dd1c4e34fd | ||
|
|
e653616aa3 | ||
|
|
3ce64db2fc | ||
|
|
4bbd52fc58 | ||
|
|
9eb3099881 | ||
|
|
73e2de7fe8 | ||
|
|
8b4f05e44e | ||
|
|
a7879edca1 | ||
|
|
3f09eb5c0f | ||
|
|
202cb509a0 | ||
|
|
dd42fd833b | ||
|
|
b5f241c719 | ||
|
|
4cdc3e7320 | ||
|
|
b558bda6df | ||
|
|
1063a6425d | ||
|
|
0a8517432f | ||
|
|
83bfca8d35 | ||
|
|
9e58a062f8 | ||
|
|
aa06d9d5c9 | ||
|
|
bb3d1270b9 | ||
|
|
7d40210a00 | ||
|
|
ab445813b7 | ||
|
|
631a3829e4 | ||
|
|
88b4f4eeaa | ||
|
|
ee8af0c7d0 | ||
|
|
4f3501e904 | ||
|
|
b0108b9c21 | ||
|
|
4dc79fc931 | ||
|
|
130387eb36 | ||
|
|
0c2b2b4703 | ||
|
|
fed83761e5 | ||
|
|
55eb481899 | ||
|
|
eb3eecab1d | ||
|
|
3afbc046d8 | ||
|
|
738b64eaf4 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 57.5%"><title>coverage: 57.5%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">57.5%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">57.5%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">57.5%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,103 @@
|
||||
name: Auto Dev Release
|
||||
|
||||
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
|
||||
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
|
||||
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
|
||||
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
|
||||
# - 不含 npm publish,所以自动发布天然是绿的。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/dws-devapp
|
||||
|
||||
# 串行:两次 push 不会同时算版本号撞车
|
||||
concurrency:
|
||||
group: auto-dev-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
# ---- 质量门:测试不过就不发 ----
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
# ---- 算下一个 dev 版本号并打 tag ----
|
||||
- name: Compute next dev version
|
||||
id: ver
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LATEST" ]; then
|
||||
BASE="v1.0.39"; N=0
|
||||
else
|
||||
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
|
||||
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
|
||||
fi
|
||||
NEW="${BASE}-dev.$((N + 1))"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "next dev release: $NEW (prev: ${LATEST:-none})"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$V"
|
||||
git push origin "$V"
|
||||
|
||||
# ---- 自己发 release(不靠 tag 触发 release.yml)----
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
|
||||
@@ -58,6 +58,18 @@ jobs:
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-oss.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
@@ -75,6 +87,8 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
|
||||
@@ -26,6 +26,7 @@ test/cli_compat/testdata/
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
!skills/**/credentials.md
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
@@ -34,3 +35,10 @@ dws.zip
|
||||
|
||||
# envelope/discovery.pre.json synced via Portal, not git-tracked
|
||||
/envelope/discovery.pre.json
|
||||
|
||||
# local/pre-release MCP service configs may contain personal gateway keys
|
||||
/docs/mcp/serviceconfig-pre*
|
||||
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ changelog:
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: DingTalk-Real-AI
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -6,6 +6,24 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws devapp robot connect` — generic `custom` channel for self-built / unsupported AI tools** (issue #37; `internal/helpers/devapp_connect.go`, `internal/helpers/connect_stream.go`) — a new `--agent-cmd "<command>"` flag (and `custom` channel) lets the bot forward to any headless AI CLI that takes a question as its trailing argument and prints the answer to stdout, so tools that aren't built-in (e.g. 网易有道龙虾 LobsterAI) or self-built agents can be onboarded without code changes. `--agent-cmd` forces the `custom` channel unless `--channel` is set explicitly; detection also falls back to `custom` when `DWS_AGENT_CMD` is present.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`robot connect` now hints how to match terminal answer quality** (issue #39; `internal/helpers/devapp_connect.go`) — when neither a work dir nor a knowledge source is configured, the connector prints a one-time note that the bot runs in a clean temp dir without local project context, pointing at `--agent-workdir` / `--knowledge-dir` / `--knowledge-source` / `--agent-model`. The robot quickstart gains matching FAQ entries, plus a clarification that step 3 (`robot connect`) produces no approval ticket (issue #19).
|
||||
|
||||
- **`robot connect` session commands `/new` vs `/clear` now use each channel's real session op** (PR #20; `internal/helpers/connect_opencode.go`, `internal/helpers/connect_stream.go`) — `/new` (and `/start`, `/reset`) opens a fresh session and leaves the previous one intact (resumable where the agent supports it); `/clear` actively disposes the current session through the agent's real delete primitive — opencode issues `DELETE /session/:id`. Channels whose agent exposes no delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude-family exec) fall back to a reset, so `/clear` behaves like `/new` there. Previously both commands only dropped the local `conversationId → sessionId` mapping, so the two were indistinguishable and opencode sessions were never disposed (they leaked).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`robot connect` no longer aborts long opencode turns at 30 seconds** (PR #19; `internal/helpers/connect_opencode.go`) — the shared opencode HTTP client hard-coded a 30s `Timeout` that covered every request, including `POST /session/{id}/message`, so a long agent turn (e.g. a multi-minute research report) was killed mid-flight with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` even though the per-turn budget (`DWS_AGENT_TIMEOUT_MS`, default 300s) was far larger. The client-level deadline is removed so the per-request ctx governs the round-trip; only the `/global/health` probe keeps a short 10s timeout so startup detection stays snappy.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -299,7 +299,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -495,6 +495,23 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</details>
|
||||
|
||||
## DingTalk bot — connect a robot to your local AI
|
||||
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
|
||||
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
|
||||
|
||||
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
@@ -514,7 +531,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
@@ -579,6 +596,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
## Contributing
|
||||
|
||||
+21
-3
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -491,6 +491,23 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</details>
|
||||
|
||||
## 钉钉机器人 —— 把机器人接到你本地的 AI
|
||||
|
||||
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
|
||||
|
||||
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
@@ -510,7 +527,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
@@ -577,6 +594,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
## 贡献指南
|
||||
|
||||
@@ -43,6 +43,8 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
|
||||
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
|
||||
@@ -187,7 +187,7 @@ _Search the DingTalk Open Platform documentation._
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, traceId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
|
||||
|
||||
## `dws ding` — DING Messages
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
|
||||
Stream long-connection. By default it runs in the foreground and dies when the
|
||||
terminal closes. For an unattended "digital employee" you have two options.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws devapp robot connect --daemon \
|
||||
--channel claudecode \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
|
||||
# Inspect / stop it.
|
||||
dws devapp robot connect status --robot-client-id <clientId>
|
||||
dws devapp robot connect stop --robot-client-id <clientId>
|
||||
```
|
||||
|
||||
- The parent prints the daemon pid and the log path, then exits.
|
||||
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
|
||||
worker connector alive, restarting it with exponential backoff (1s..60s, up to
|
||||
10 consecutive fast failures) when it exits abnormally.
|
||||
- The single-instance lock (one connector per robot per machine) is reused, so a
|
||||
duplicate daemon refuses to start.
|
||||
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<clientId>/daemon.pid`.
|
||||
- The daemon does NOT survive a reboot. For that, use Option B.
|
||||
|
||||
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
|
||||
> Windows service wrapper around the foreground command instead.
|
||||
|
||||
## Option B: OS service manager (survives reboot)
|
||||
|
||||
Use the foreground command (NOT `--daemon`) and let the OS supervise and
|
||||
restart it. This is the most robust way to get boot-time auto-start.
|
||||
|
||||
### macOS — launchd
|
||||
|
||||
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
|
||||
and credentials, then `launchctl load -w <path>`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.dingtalk.dws.connect</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/dws</string>
|
||||
<string>devapp</string>
|
||||
<string>robot</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--robot-client-id</string>
|
||||
<string>REPLACE_CLIENT_ID</string>
|
||||
<string>--robot-client-secret</string>
|
||||
<string>REPLACE_CLIENT_SECRET</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/dws-connect.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/dws-connect.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
|
||||
itself relies on the single-instance lock to avoid duplicates.
|
||||
|
||||
### Linux — systemd (user service)
|
||||
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
|
||||
then:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dws-connect.service
|
||||
# allow it to keep running after logout:
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DWS DingTalk robot connector
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/dws devapp robot connect \
|
||||
--channel claudecode \
|
||||
--robot-client-id REPLACE_CLIENT_ID \
|
||||
--robot-client-secret REPLACE_CLIENT_SECRET
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Optional hardening:
|
||||
# NoNewPrivileges=true
|
||||
# PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
|
||||
stderr into the journal (`journalctl --user -u dws-connect -f`).
|
||||
|
||||
## Which to choose
|
||||
|
||||
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
|
||||
- Need it to come back after a reboot, with the OS owning the lifecycle → use
|
||||
launchd / systemd with the foreground command.
|
||||
@@ -0,0 +1,137 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
## 手工评测流程
|
||||
|
||||
逐条执行,每条三步:
|
||||
|
||||
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
|
||||
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
|
||||
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
|
||||
|
||||
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
|
||||
|
||||
## 覆盖矩阵
|
||||
|
||||
| 用例 | 覆盖的子命令 | 横切行为 |
|
||||
|------|-------------|---------|
|
||||
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
|
||||
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
|
||||
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
|
||||
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
|
||||
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
|
||||
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
|
||||
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
|
||||
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
|
||||
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
|
||||
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
|
||||
|
||||
---
|
||||
|
||||
## 用例
|
||||
|
||||
### C1. 新建应用并配齐基础
|
||||
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
|
||||
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
|
||||
2. `app get --unified-app-id <id> --format json` 看详情。
|
||||
3. `credentials get --unified-app-id <id> --format json` 取凭证。
|
||||
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
|
||||
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
|
||||
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
|
||||
|
||||
### C2. 应用列表与按名定位
|
||||
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
|
||||
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
|
||||
- **期望(分步)**:
|
||||
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
|
||||
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
|
||||
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
|
||||
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
|
||||
|
||||
### C3. 应用生命周期(停用 / 启用 / 删除)
|
||||
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
|
||||
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
|
||||
- **期望(分步)**:
|
||||
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
|
||||
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
|
||||
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
|
||||
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
|
||||
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
|
||||
|
||||
### C4. 网页应用配置到生效
|
||||
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
|
||||
- **覆盖**:`webapp config` / `get`;生效模型。
|
||||
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
|
||||
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
|
||||
- **易错点**:配完直接说已生效,不提版本通道。
|
||||
|
||||
### C5. 版本发布全流程(含选审批人)
|
||||
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
|
||||
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
|
||||
- **期望(分步)**:
|
||||
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
|
||||
2. `version list` 看历史、`version get --version-id <id>` 看详情。
|
||||
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
|
||||
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
|
||||
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
|
||||
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
|
||||
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
|
||||
|
||||
### C6. 权限全流程(查 / 申请 / 批量取消)
|
||||
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
|
||||
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
|
||||
- **期望(分步)**:
|
||||
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
|
||||
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
|
||||
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
|
||||
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
|
||||
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
|
||||
|
||||
### C7. 成员与安全配置
|
||||
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
|
||||
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
|
||||
- **期望(分步)**:
|
||||
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
|
||||
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
|
||||
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
|
||||
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
|
||||
|
||||
### C8. 机器人建号、配置与本地建联
|
||||
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
|
||||
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
|
||||
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
|
||||
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
|
||||
|
||||
### C9. 事件订阅与上游错误排查
|
||||
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
|
||||
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
|
||||
- **期望(分步)**:
|
||||
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
|
||||
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
|
||||
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
|
||||
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
|
||||
|
||||
### C10. 意图消歧(泛词边界)
|
||||
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
|
||||
- **覆盖**:泛词消歧、边界与角色。
|
||||
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
|
||||
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
|
||||
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
|
||||
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
|
||||
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
|
||||
@@ -0,0 +1,305 @@
|
||||
# DevApp 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
|
||||
|
||||
## 一键安装
|
||||
|
||||
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
|
||||
2. 使用本地源码构建 `dws`。
|
||||
3. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
|
||||
|
||||
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
|
||||
|
||||
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell 正式安装命令:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装脚本支持这些环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
|
||||
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
|
||||
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
|
||||
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
|
||||
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
|
||||
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
|
||||
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
|
||||
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
|
||||
|
||||
## 安装后验证
|
||||
|
||||
先确认 `dws` 可执行:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 DevApp 命令存在:
|
||||
|
||||
```bash
|
||||
dws devapp --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
```
|
||||
|
||||
如果尚未登录:
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
```
|
||||
|
||||
## DevApp 是什么
|
||||
|
||||
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
|
||||
|
||||
它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
- 配置网页应用首页和管理后台地址。
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 创建、查询、更新、启用、停用机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 DevApp。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
帮我查一下最近创建的开放平台应用。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先检查发布前置条件。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
|
||||
1. 先查询定位应用。
|
||||
2. 先 dry-run 预览。
|
||||
3. 明确展示将要修改的应用、字段和值。
|
||||
4. 用户确认后加 `--yes` 执行。
|
||||
5. 执行后回读验证。
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。先 dry-run:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后执行:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
回读验证:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws devapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp create --name "考勤应用" --dry-run --format json
|
||||
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws devapp permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人配置
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws devapp member list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
|
||||
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
## 安全边界
|
||||
|
||||
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws devapp` 不存在
|
||||
|
||||
先确认安装的是预览分支源码,而不是正式 Release:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws devapp --help --format json
|
||||
```
|
||||
|
||||
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
|
||||
|
||||
### `dws devapp list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
dws auth login
|
||||
```
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
|
||||
|
||||
先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
- 应用开发者。
|
||||
- 企业管理员或具备开放平台应用管理权限的角色。
|
||||
|
||||
### 机器人配置失败
|
||||
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,使用 `robot config` 创建或配置。
|
||||
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install DevApp in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
and publish versions with dry-run safety built in.
|
||||
```
|
||||
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 DevApp。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
所有写操作先预览,再确认执行。
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- Notion Developer Platform: https://www.notion.com/product/dev
|
||||
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
|
||||
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
# 钉钉 AI 群机器人快速上手
|
||||
|
||||
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
|
||||
|
||||
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
按提示扫码登录即可。
|
||||
|
||||
## 第二步:创建机器人
|
||||
|
||||
建号是异步的,两步(名字、描述可以改成你自己的):
|
||||
|
||||
```bash
|
||||
# 1) 提交建号任务,记下返回的 taskId
|
||||
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
|
||||
|
||||
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
|
||||
dws dev app robot result --task-id 上一步返回的taskId --format json
|
||||
```
|
||||
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
|
||||
```
|
||||
|
||||
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
|
||||
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
|
||||
|
||||
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
|
||||
|
||||
## 进阶配置(可选)
|
||||
|
||||
按需加在第三步的命令后面:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
|
||||
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
|
||||
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
|
||||
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
|
||||
| `--allowed-groups 群ID` | 群白名单 |
|
||||
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
|
||||
|
||||
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
|
||||
|
||||
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
|
||||
|
||||
```bash
|
||||
dws dev connect \
|
||||
--agent-cmd "你的AI命令 一次性问答参数" \
|
||||
--robot-client-id dingxxxx --robot-client-secret yyyy
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**执行命令报 `zsh: parse error near '\n'`?**
|
||||
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
|
||||
|
||||
**群里 @机器人 没反应?**
|
||||
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
|
||||
|
||||
**第二步提示"当前用户没有开发者身份"?**
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
|
||||
|
||||
**提示找不到 dws 命令?**
|
||||
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
|
||||
|
||||
**提示本地没有装 AI 工具?**
|
||||
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
|
||||
|
||||
**机器人回复"调用失败"?**
|
||||
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
|
||||
|
||||
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
|
||||
这通常不是模型问题,而是"机器人看到的上下文比终端少":
|
||||
|
||||
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
|
||||
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
|
||||
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
|
||||
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
|
||||
|
||||
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
|
||||
|
||||
## 会话指令:`/new` 和 `/clear`
|
||||
|
||||
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | **清空当前会话**:彻底从头开始 |
|
||||
|
||||
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
|
||||
|
||||
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
|
||||
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
|
||||
@@ -3,9 +3,12 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -14,14 +17,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
@@ -33,6 +34,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
@@ -20,15 +24,27 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -43,6 +59,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
|
||||
@@ -68,6 +86,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -88,6 +108,8 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
+413
-23
@@ -30,18 +30,39 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
}
|
||||
|
||||
func buildAuthCommand() *cobra.Command {
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
authLoginGuideDirectCLI authLoginGuideAction = "direct_cli"
|
||||
authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app"
|
||||
authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"}
|
||||
authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"}
|
||||
authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"}
|
||||
authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"}
|
||||
authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"}
|
||||
)
|
||||
|
||||
func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "认证管理",
|
||||
@@ -55,7 +76,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
cmd.AddCommand(newAuthLoginCommand(patCaller))
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
@@ -68,7 +89,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthLoginCommand() *cobra.Command {
|
||||
func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||||
@@ -88,7 +109,8 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
@@ -100,6 +122,10 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend)
|
||||
recommendAuthMode := cfg.Recommend || postLoginTUIMode
|
||||
humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode)
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Token) != "":
|
||||
@@ -139,41 +165,93 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
if postLoginTUIMode {
|
||||
var planErr error
|
||||
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
|
||||
if planErr != nil {
|
||||
return planErr
|
||||
}
|
||||
if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项")
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
recommendScopeMode, err = loginRecommendScopeModeSelector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan}
|
||||
if postLoginTUIMode {
|
||||
opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
return loginRecommendProductSelector(products)
|
||||
}
|
||||
}
|
||||
retryFormat := format
|
||||
if humanAuthMode {
|
||||
retryFormat = "table"
|
||||
}
|
||||
run := func(ctx context.Context) error {
|
||||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||
}
|
||||
err := run(cmd.Context())
|
||||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||||
return runDirectPATAuthCheckWaitOnly(
|
||||
cmd.Context(),
|
||||
&GlobalFlags{Format: retryFormat},
|
||||
patErr,
|
||||
cmd.ErrOrStderr(),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode {
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintf(w, "[OK] 登录成功!\n")
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID))
|
||||
}
|
||||
if tokenData.UserName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName))
|
||||
}
|
||||
if expiry := authLoginDisplayExpiry(tokenData); expiry != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry)
|
||||
fmt.Fprintln(w, authLoginInfoLine("有效期", expiry))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n")
|
||||
fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
|
||||
@@ -191,6 +269,109 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
var (
|
||||
authLoginGuideActionSelector = selectAuthLoginGuideAction
|
||||
authLoginGuideActionApplier = applyAuthLoginGuideAction
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
choice := authLoginGuideDirectCLI
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[authLoginGuideAction]().
|
||||
Title("选择操作").
|
||||
Options(
|
||||
huh.NewOption("直接使用CLI", authLoginGuideDirectCLI),
|
||||
huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp),
|
||||
huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error {
|
||||
switch action {
|
||||
case authLoginGuideDirectCLI:
|
||||
return nil
|
||||
case authLoginGuideConfigureAgentApp:
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
|
||||
return nil
|
||||
case authLoginGuideManualCredentials:
|
||||
clientID, clientSecret, err := promptAuthLoginManualCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authpkg.SetClientID(clientID)
|
||||
authpkg.SetClientSecret(clientSecret)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: clientID,
|
||||
ClientSecret: authpkg.PlainSecret(clientSecret),
|
||||
}); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func promptAuthLoginManualCredentials() (string, string, error) {
|
||||
var clientID, clientSecret string
|
||||
nonEmpty := func(label string) func(string) error {
|
||||
return func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s 不能为空", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("输入 AppKey").
|
||||
Value(&clientID).
|
||||
Validate(nonEmpty("AppKey")),
|
||||
huh.NewInput().
|
||||
Title("输入 AppSecret").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&clientSecret).
|
||||
Validate(nonEmpty("AppSecret")),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
||||
}
|
||||
|
||||
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
choice := pat.LoginRecommendScopeRecommended
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[pat.LoginRecommendScopeMode]().
|
||||
Title("选择授权范围").
|
||||
Description("空格选择 回车确认").
|
||||
Options(
|
||||
huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended),
|
||||
huh.NewOption("全部授权", pat.LoginRecommendScopeAll),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
@@ -230,7 +411,7 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -298,7 +479,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -338,7 +519,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
))
|
||||
}
|
||||
if !authpkg.PortableAuthSourceReady() {
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login")
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
@@ -512,7 +693,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -553,6 +734,205 @@ func authLoginDisplayExpiry(data *authpkg.TokenData) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
if len(products) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
selected := make([]string, 0, len(products))
|
||||
options := make([]huh.Option[string], 0, len(products))
|
||||
for _, product := range products {
|
||||
code := strings.TrimSpace(product.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, code)
|
||||
options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true))
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
height := len(options)
|
||||
if height > 15 {
|
||||
height = 15
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("选择要授权的业务域").
|
||||
Description("空格选择 回车确认").
|
||||
Options(options...).
|
||||
Height(height).
|
||||
Value(&selected).
|
||||
Validate(func(values []string) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("至少选择一个授权业务域")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func authLoginHuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.FieldSeparator = lipgloss.NewStyle().SetString("\n")
|
||||
|
||||
t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue)
|
||||
t.Focused.Card = t.Focused.Base
|
||||
t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1)
|
||||
t.Focused.Description = authLoginMutedStyle()
|
||||
t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.MultiSelectSelector = t.Focused.SelectSelector
|
||||
t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue)
|
||||
t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted)
|
||||
t.Focused.FocusedButton = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}).
|
||||
Background(authLoginBrandBlue).
|
||||
Padding(0, 2).
|
||||
Bold(true)
|
||||
t.Focused.BlurredButton = lipgloss.NewStyle().
|
||||
Foreground(authLoginInk).
|
||||
Background(authLoginLine).
|
||||
Padding(0, 2)
|
||||
t.Focused.Next = t.Focused.FocusedButton
|
||||
t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.TextInput.Placeholder = authLoginMutedStyle()
|
||||
t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Blurred = t.Focused
|
||||
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine)
|
||||
t.Blurred.Card = t.Blurred.Base
|
||||
t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1)
|
||||
t.Blurred.Description = authLoginMutedStyle()
|
||||
t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector
|
||||
t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Blurred.NextIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.PrevIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Group.Title = t.Focused.Title
|
||||
t.Group.Description = t.Focused.Description
|
||||
|
||||
t.Help.ShortKey = authLoginMutedStyle()
|
||||
t.Help.ShortDesc = authLoginMutedStyle()
|
||||
t.Help.ShortSeparator = authLoginMutedStyle()
|
||||
t.Help.FullKey = authLoginMutedStyle()
|
||||
t.Help.FullDesc = authLoginMutedStyle()
|
||||
t.Help.FullSeparator = authLoginMutedStyle()
|
||||
t.Help.Ellipsis = authLoginMutedStyle()
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func authLoginStatusLine(message string) string {
|
||||
return fmt.Sprintf("%s %s",
|
||||
lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"),
|
||||
lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message),
|
||||
)
|
||||
}
|
||||
|
||||
func authLoginInfoLine(key, value string) string {
|
||||
label := authLoginMutedStyle().Width(14).Render(key + ":")
|
||||
return fmt.Sprintf("%s %s", label, value)
|
||||
}
|
||||
|
||||
func authLoginMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive)
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
if recommend || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool {
|
||||
return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool {
|
||||
if !hasAuthorizationFlow || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
return plan.AllGranted || len(plan.Scopes) == 0
|
||||
}
|
||||
|
||||
func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool {
|
||||
if cmd == nil || cmd.Root() == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return true
|
||||
}
|
||||
flags := cmd.Root().PersistentFlags()
|
||||
return !flags.Changed("format")
|
||||
}
|
||||
|
||||
func loginRecommendProductLabel(product pat.LoginRecommendProduct) string {
|
||||
name := strings.TrimSpace(product.ProductName)
|
||||
if name == "" || name == product.ProductCode {
|
||||
name = product.ProductCode
|
||||
}
|
||||
summary := strings.TrimSpace(product.Summary)
|
||||
if summary != "" {
|
||||
summary = " - " + clipRunes(summary, 42)
|
||||
}
|
||||
return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary)
|
||||
}
|
||||
|
||||
func clipRunes(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func clearCompatCache() {
|
||||
store := cacheStoreFromEnv()
|
||||
if store != nil {
|
||||
@@ -573,10 +953,20 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
recommend, err := cmd.Flags().GetBool("recommend")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
yes := false
|
||||
if cmd.Root() != nil {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
}
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -26,6 +27,9 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
@@ -180,8 +184,461 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
return root
|
||||
}
|
||||
|
||||
t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) {
|
||||
t.Fatal("--recommend must not show the post-login product TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive --recommend should still use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without recommend shows two-step authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("default interactive login should show post-login authorization TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive post-login authorization should use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit json keeps machine mode", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("explicit --format json must not show post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("explicit --format json must keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("table without recommend shows authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "table"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) {
|
||||
t.Fatal("table format should show post-login TUI without --recommend")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) {
|
||||
t.Fatal("table format should use human authorization flow in an interactive terminal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non interactive skips selector", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) {
|
||||
t.Fatal("non-interactive login should skip post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) {
|
||||
t.Fatal("non-interactive login should keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) {
|
||||
t.Fatal("login without a post-login authorization flow should not switch default json to human mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
|
||||
label := loginRecommendProductLabel(pat.LoginRecommendProduct{
|
||||
ProductCode: "approval",
|
||||
ProductName: "审批",
|
||||
Summary: "审批实例,审批模板,审批任务管理",
|
||||
ScopeCount: 12,
|
||||
})
|
||||
if label != "approval 审批 - 审批实例,审批模板,审批任务管理" {
|
||||
t.Fatalf("label = %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set yes: %v", err)
|
||||
}
|
||||
if err := login.Flags().Set("recommend", "true"); err != nil {
|
||||
t.Fatalf("set recommend: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.Recommend {
|
||||
t.Fatal("Recommend = false, want true")
|
||||
}
|
||||
if !cfg.Yes {
|
||||
t.Fatal("Yes = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("--recommend must not call the post-login guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("--recommend must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("--recommend must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("--recommend must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token", "--recommend"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("--recommend plan recommend = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want all-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("already-granted recommended auth must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("already-granted recommended auth must not call product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want already-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
|
||||
var sawTokenBeforeScopeTUI bool
|
||||
var sawTokenBeforeProductTUI bool
|
||||
var sawTokenBeforePlan bool
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before scope TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeScopeTUI = true
|
||||
return pat.LoginRecommendScopeAll, nil
|
||||
}
|
||||
loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before product TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeProductTUI = true
|
||||
if len(products) != 1 || products[0].ProductCode != "calendar" {
|
||||
t.Fatalf("selector products = %+v, want calendar", products)
|
||||
}
|
||||
return []string{"calendar"}, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}, beforeCall: func(toolName string) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before %s error = %v", toolName, err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken)
|
||||
}
|
||||
sawTokenBeforePlan = true
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !sawTokenBeforeScopeTUI {
|
||||
t.Fatal("scope-mode TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforeProductTUI {
|
||||
t.Fatal("product-domain TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforePlan {
|
||||
t.Fatal("authorization plan was not called after token save")
|
||||
}
|
||||
if len(fake.tools) != 3 {
|
||||
t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("discovery plan recommend = %#v, want true", got)
|
||||
}
|
||||
if got := fake.args[1]["recommend"]; got != false {
|
||||
t.Fatalf("selected all-scope plan recommend = %#v, want false", got)
|
||||
}
|
||||
if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
|
||||
t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type authLoginRecommendSequenceCaller struct {
|
||||
responses []string
|
||||
tools []string
|
||||
args []map[string]any
|
||||
beforeCall func(toolName string)
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if f.beforeCall != nil {
|
||||
f.beforeCall(toolName)
|
||||
}
|
||||
f.tools = append(f.tools, toolName)
|
||||
copiedArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copiedArgs[key] = value
|
||||
}
|
||||
f.args = append(f.args, copiedArgs)
|
||||
response := `{"success":true,"data":{}}`
|
||||
if len(f.responses) > 0 {
|
||||
response = f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
if got == nil {
|
||||
return len(want) == 0
|
||||
}
|
||||
switch values := got.(type) {
|
||||
case []string:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -45,8 +46,17 @@ const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
devappProductID = "devapp"
|
||||
devappServerPath = "/server/op-app"
|
||||
)
|
||||
|
||||
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
|
||||
// from the configured gateway base URL, so it follows the active environment
|
||||
// (production by default, pre when ~/.dws/mcp_url points at the pre gateway).
|
||||
func devappMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + devappServerPath
|
||||
}
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
@@ -220,12 +230,9 @@ func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
@@ -241,24 +248,27 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
dynamicMu.RLock()
|
||||
de := dynamicEndpoints
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
// Hardcoded built-in: devapp is pinned to the open-platform app-management
|
||||
// MCP server in source (NOT service discovery), per product decision.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == devappProductID {
|
||||
return devappMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
dynamicMu.RLock()
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if de != nil {
|
||||
if endpoint, ok := de[candidate]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := dynamicEndpoints[candidate]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +276,13 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
if tool := strings.TrimSpace(toolName); tool != "" {
|
||||
if endpoint, ok := dynamicToolEndpoints[tool]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
@@ -279,18 +291,69 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: edition-owned static/supplement endpoints. Helper-only
|
||||
// products such as devapp intentionally do not depend on Market discovery,
|
||||
// so the internal edition may provide only an endpoint and no tool list.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if endpoint, ok := editionServerEndpoint(candidate); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns the set of product IDs that have direct
|
||||
// runtime endpoints configured, sourced from dynamic server discovery.
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
return "", false
|
||||
}
|
||||
hooks := edition.Get()
|
||||
if hooks == nil {
|
||||
return "", false
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.SupplementServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo) (string, bool) {
|
||||
if fn == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(server.ID) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
for _, prefix := range server.Prefixes {
|
||||
if strings.TrimSpace(prefix) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
// through DINGTALK_<PRODUCT>_MCP_URL instead of requiring discovery.
|
||||
func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
ids := make(map[string]bool, len(dynamicProducts)+2)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[devappProductID] = true
|
||||
for key := range dynamicProducts {
|
||||
ids[key] = true
|
||||
}
|
||||
return ids
|
||||
@@ -365,15 +428,14 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
dynamicMu.RLock()
|
||||
da := dynamicAliases
|
||||
dynamicMu.RUnlock()
|
||||
trimmed := strings.TrimSpace(productID)
|
||||
if da != nil {
|
||||
if normalizedID, ok := da[trimmed]; ok && normalizedID != "" {
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
if normalizedID, ok := dynamicAliases[trimmed]; ok && normalizedID != "" {
|
||||
dynamicMu.RUnlock()
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
if normalizedID, ok := legacyDirectRuntimeAliases[trimmed]; ok {
|
||||
return normalizedID
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
@@ -38,6 +39,107 @@ func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["devapp"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://example.test/server/devapp-supplement",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "devapp",
|
||||
Command: "devapp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-static",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement",
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -112,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
return output.WriteJSON(w, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
|
||||
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
|
||||
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
||||
}
|
||||
@@ -123,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查登录状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查登录状态... "))
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
@@ -186,7 +188,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查网络连通性... ")
|
||||
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
|
||||
}
|
||||
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
@@ -228,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查缓存状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
@@ -300,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查版本更新... ")
|
||||
fmt.Fprint(w, tui.Dim("检查版本更新... "))
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
@@ -348,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
||||
|
||||
func printCheckResult(w io.Writer, r checkResult) {
|
||||
icon := statusIcon(r.Status)
|
||||
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
|
||||
message := r.Message
|
||||
switch r.Status {
|
||||
case statusPass:
|
||||
message = tui.Success(message)
|
||||
case statusWarn:
|
||||
message = tui.Warning(message)
|
||||
case statusFail:
|
||||
message = tui.Danger(message)
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s\n", icon, message)
|
||||
if r.Hint != "" {
|
||||
fmt.Fprintf(w, " %s\n", r.Hint)
|
||||
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) {
|
||||
|
||||
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查性能报告... ")
|
||||
fmt.Fprint(w, tui.Dim("检查性能报告... "))
|
||||
}
|
||||
|
||||
report, err := LoadLatestReport()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
|
||||
// server's tools/list LIVE (by source) and projects each tool into a
|
||||
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
|
||||
// is injected into the schema command so the cli package can render
|
||||
// `dws schema dev.*` from real server schema without importing app/transport.
|
||||
//
|
||||
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
|
||||
// backs `dws dev doc search` (endpoint resolved dynamically, see
|
||||
// helperSourceEndpoint). Results are memoized per source per process so
|
||||
// repeated `dws schema dev.*` hit the network at most once per source. A failed
|
||||
// fetch is not cached, allowing a later retry within the same process.
|
||||
func newHelperToolFetcher() cli.HelperToolFetcher {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cached = map[string]map[string]cli.HelperToolSchema{}
|
||||
)
|
||||
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
|
||||
mu.Lock()
|
||||
if got, ok := cached[source]; ok {
|
||||
mu.Unlock()
|
||||
return got, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
endpoint, err := helperSourceEndpoint(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
cached[source] = schemas
|
||||
mu.Unlock()
|
||||
return schemas, nil
|
||||
}
|
||||
}
|
||||
|
||||
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
|
||||
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
|
||||
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
|
||||
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
|
||||
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
|
||||
func helperSourceEndpoint(source string) (string, error) {
|
||||
switch source {
|
||||
case "", "op-app", "devapp":
|
||||
return devappMCPEndpoint(), nil
|
||||
default:
|
||||
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
|
||||
// converts the descriptors. Auth and identity headers are resolved the same way
|
||||
// the runner does for direct-runtime invocations.
|
||||
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
|
||||
token := resolveRuntimeAuthToken(ctx, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
client := transport.NewClient(nil).WithAuth(token, headers)
|
||||
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
|
||||
for _, td := range result.Tools {
|
||||
out[td.Name] = cli.HelperToolSchema{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Properties: inputSchemaProperties(td.InputSchema),
|
||||
Required: inputSchemaRequired(td.InputSchema),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inputSchemaProperties pulls the "properties" object out of a deserialized
|
||||
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
|
||||
func inputSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// inputSchemaRequired pulls the "required" string list out of a deserialized
|
||||
// MCP inputSchema map.
|
||||
func inputSchemaRequired(schema map[string]any) []string {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
@@ -568,6 +568,13 @@ func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
func registryDiscoveryBaseURL() string {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
return editionURL
|
||||
}
|
||||
return DiscoveryBaseURL()
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
@@ -580,7 +587,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
|
||||
}
|
||||
|
||||
+200
-71
@@ -33,8 +33,9 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,6 +52,10 @@ const (
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
type patSuppressBrowserOpenKeyType struct{}
|
||||
|
||||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
@@ -153,36 +158,31 @@ func extractPatScopeError(err error) *PatScopeError {
|
||||
|
||||
// PrintPatAuthError prints a human-readable PAT authorization error.
|
||||
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "{\n")
|
||||
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
func currentPATOpenBrowser(ctx context.Context, configDir string) bool {
|
||||
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
|
||||
return false
|
||||
}
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
@@ -225,8 +228,11 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
@@ -237,15 +243,19 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func patAuthorizationURIFromData(data map[string]any) string {
|
||||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||||
value, _ := data[key].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
timeout := PatAuthRetryTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
@@ -253,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
start := time.Now()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
@@ -280,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
@@ -288,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
tui.Dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
@@ -340,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patMaxPollInterval caps a server-provided poll interval so a malformed
|
||||
// response cannot make the CLI look permanently stuck.
|
||||
patMaxPollInterval = 30 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
@@ -350,6 +362,12 @@ type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
|
||||
|
||||
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return f(ctx, invocation)
|
||||
}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
@@ -364,8 +382,8 @@ func openPATAuthorizationURI(rawURI string) error {
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper while still
|
||||
// preserving the original data.uri in structured output.
|
||||
// That variant is normalized by the PAT error contract helper before being
|
||||
// printed, opened, or returned in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
@@ -382,6 +400,70 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheck(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
) error {
|
||||
if retry == nil {
|
||||
return patErr
|
||||
}
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWaitOnly(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
output io.Writer,
|
||||
) error {
|
||||
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWithMode(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
retryAfterApproval bool,
|
||||
) error {
|
||||
if retryAfterApproval && retry == nil {
|
||||
return patErr
|
||||
}
|
||||
runner := &runtimeRunner{
|
||||
globalFlags: globalFlags,
|
||||
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if retry != nil {
|
||||
if err := retry(retryCtx); err != nil {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
Kind: "direct_pat_authorization",
|
||||
Stage: "auth_login_recommend",
|
||||
CanonicalProduct: defaultPATProductID,
|
||||
Tool: "pat.batch_grant",
|
||||
CanonicalPath: "pat.batch_grant",
|
||||
Params: map[string]any{
|
||||
"retryAfterApproval": retryAfterApproval,
|
||||
},
|
||||
}, patErr, defaultConfigDir(), output)
|
||||
return err
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
@@ -399,16 +481,25 @@ func handlePatAuthCheck(
|
||||
var patData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
AuthURL string `json:"authUrl"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
PollIntervalSecs int `json:"pollIntervalSeconds"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthURL
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthorizationURL
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
@@ -416,7 +507,7 @@ func handlePatAuthCheck(
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
openBrowser := currentPATOpenBrowser(ctx, configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
@@ -466,44 +557,40 @@ func handlePatAuthCheck(
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
greenFn := color.New(color.FgGreen).SprintFunc()
|
||||
yellowFn := color.New(color.FgYellow).SprintFunc()
|
||||
redFn := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
|
||||
if patData.Data.Desc != "" {
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
|
||||
fmt.Fprintln(output)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
||||
pollCtx, patData.Data.FlowID, configDir, output,
|
||||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
@@ -519,11 +606,11 @@ func handlePatAuthCheck(
|
||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
||||
if exchErr != nil {
|
||||
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
@@ -533,13 +620,23 @@ func handlePatAuthCheck(
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
if shouldSkipPATRetryAfterApproval(invocation) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Workaround: brief delay to let server-side authorization state propagate
|
||||
// before retrying. Without this the retry may use stale credentials.
|
||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
@@ -548,7 +645,7 @@ func handlePatAuthCheck(
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
@@ -556,7 +653,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
@@ -564,7 +661,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
@@ -572,11 +669,23 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
|
||||
if invocation.Params == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := invocation.Params["retryAfterApproval"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
retry, ok := value.(bool)
|
||||
return ok && !retry
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
@@ -630,7 +739,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
b, err := jsonutil.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
@@ -655,6 +764,13 @@ func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
|
||||
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
|
||||
}
|
||||
|
||||
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
|
||||
if interval <= 0 {
|
||||
interval = patPollInterval
|
||||
}
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
@@ -671,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
@@ -686,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
@@ -738,6 +853,20 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePATPollInterval(seconds int) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return patPollInterval
|
||||
}
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if interval > patMaxPollInterval {
|
||||
return patMaxPollInterval
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
|
||||
@@ -608,8 +608,14 @@ func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *
|
||||
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +662,192 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
retried.Store(true)
|
||||
retryHadKey.Store(IsPatRetrying(ctx))
|
||||
return nil
|
||||
}, &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheck error = %v", err)
|
||||
}
|
||||
if !retried.Load() {
|
||||
t.Fatal("expected direct PAT auth retry callback to run")
|
||||
}
|
||||
if !retryHadKey.Load() {
|
||||
t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "授权完成,正在重试") {
|
||||
t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权成功") {
|
||||
t.Fatalf("wait-only auth should still report success, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened bool
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("wait-only auth must not open a second browser tab")
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权链接:") {
|
||||
t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePATPollInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seconds int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "default", seconds: 0, want: patPollInterval},
|
||||
{name: "server value", seconds: 3, want: 3 * time.Second},
|
||||
{name: "cap excessive value", seconds: 90, want: patMaxPollInterval},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolvePATPollInterval(tt.seconds); got != tt.want {
|
||||
t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" {
|
||||
t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != wantURL {
|
||||
t.Fatalf("data.uri = %q, want %q", got, wantURL)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
@@ -951,6 +1143,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") {
|
||||
t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
@@ -959,8 +1157,11 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from json PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from json PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
|
||||
@@ -995,7 +1196,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -1015,6 +1216,12 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
|
||||
@@ -1246,8 +1453,8 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) {
|
||||
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,8 +1499,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test
|
||||
if opened != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
|
||||
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,9 +323,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
genSkillsCmd.Hidden = true
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(),
|
||||
newAuthCommand(patCaller),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
@@ -354,7 +355,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
@@ -372,8 +372,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAuthCommand() *cobra.Command {
|
||||
return buildAuthCommand()
|
||||
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return buildAuthCommand(patCaller)
|
||||
}
|
||||
|
||||
func newSkillCommand() *cobra.Command {
|
||||
@@ -474,7 +474,7 @@ func newCacheCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
|
||||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
selected := selectServersForProduct(servers, product)
|
||||
@@ -596,7 +596,7 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader)
|
||||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||||
}
|
||||
|
||||
func newGenerateSkillsCommand() *cobra.Command {
|
||||
|
||||
+26
-10
@@ -6,6 +6,7 @@ import (
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -50,38 +51,42 @@ func renderRootHelp(root *cobra.Command) {
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
w := root.OutOrStdout()
|
||||
|
||||
_, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console"))
|
||||
_, _ = fmt.Fprintln(w, tui.Rule(76))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
if len(services) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "No MCP services discovered.")
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered."))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(w, "Discovered MCP Services:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, service := range services {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, "Usage:")
|
||||
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Usage:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <service> [command] [flags]"))
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <command> [flags]"))
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "Utility Commands:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Utility Commands:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, utility := range utilities {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
// hint (or any other root-level guidance) after browsing all available
|
||||
@@ -90,10 +95,21 @@ func renderRootHelp(root *cobra.Command) {
|
||||
// it and dropped this, so we restore it explicitly here.
|
||||
if long := strings.TrimSpace(root.Long); long != "" {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, long)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
}
|
||||
|
||||
func commandShort(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
short := strings.TrimSpace(cmd.Short)
|
||||
if cmd.Name() == "help" && short == "Help about any command" {
|
||||
return i18n.T("查看任意命令的帮助信息")
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
|
||||
+45
-5
@@ -259,13 +259,22 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
|
||||
actions := []string{"dws cache refresh"}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -708,7 +717,6 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if version != "" {
|
||||
headers[transport.HeaderVersion] = version
|
||||
}
|
||||
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
@@ -738,13 +746,23 @@ func resolveIdentityHeaders() map[string]string {
|
||||
// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP
|
||||
// protocol level. Returns the error message, or "" if the response is OK.
|
||||
func detectBusinessError(content map[string]any) string {
|
||||
return detectBusinessErrorAtDepth(content, 0)
|
||||
}
|
||||
|
||||
func detectBusinessErrorAtDepth(content map[string]any, depth int) string {
|
||||
if content == nil || depth > 8 {
|
||||
return ""
|
||||
}
|
||||
success, ok := content["success"]
|
||||
if !ok {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
b, ok := success.(bool)
|
||||
if !ok || b {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
if nested := detectNestedBusinessError(content, depth); nested != "" {
|
||||
return nested
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return strings.TrimSpace(msg)
|
||||
@@ -755,6 +773,28 @@ func detectBusinessError(content map[string]any) string {
|
||||
return "business error: success=false"
|
||||
}
|
||||
|
||||
func detectNestedBusinessError(content map[string]any, depth int) string {
|
||||
for _, key := range []string{"content", "result", "data"} {
|
||||
switch child := content[key].(type) {
|
||||
case map[string]any:
|
||||
if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
case []any:
|
||||
for _, item := range child {
|
||||
childMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractMCPErrorMessage builds an error message from a ToolCallResult with
|
||||
// isError=true. It extracts text from content blocks when available.
|
||||
func extractMCPErrorMessage(result transport.ToolCallResult) string {
|
||||
|
||||
@@ -321,6 +321,7 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
t.Setenv(authpkg.AgentCodeEnvCompat, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
@@ -943,6 +944,20 @@ func jsonRPCToolName(req map[string]any) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func TestDetectBusinessErrorNestedServiceResult(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"success": false,
|
||||
"result": map[string]any{
|
||||
"success": false,
|
||||
"errorCode": "ROBOT_NOT_FOUND",
|
||||
"errorMsg": "robot info is not exist",
|
||||
},
|
||||
}
|
||||
if got := detectBusinessError(content); got != "robot info is not exist" {
|
||||
t.Fatalf("detectBusinessError() = %q, want nested errorMsg", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
@@ -95,12 +95,13 @@ var agentSkillPaths = map[string]string{
|
||||
// `agents` is the generic-agent sentinel: install scripts and `setup`
|
||||
// special-case ~/.agents/skills as a no-checks-required fallback so a
|
||||
// fresh machine without any IDE/agent registry still gets skills.
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"qoderwork": ".qoderwork/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".github/skills",
|
||||
|
||||
@@ -21,6 +21,8 @@ var skillSetupAgentHomes = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
@@ -56,7 +58,8 @@ multi 模式支持按产品挑选:
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`,
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup # 交互式
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
@@ -68,7 +71,7 @@ multi 模式支持按产品挑选:
|
||||
}
|
||||
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
|
||||
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)")
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
|
||||
cmd.Flags().Bool("yes", false, "跳过所有确认提示")
|
||||
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
|
||||
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
|
||||
@@ -314,7 +317,31 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
candidates := skillSourceCandidates(explicit, subdir)
|
||||
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
|
||||
// override that does not contain a skill root is an error — never a
|
||||
// silent fallback to another source the user did not ask for.
|
||||
var overrides []string
|
||||
if explicit != "" {
|
||||
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
for _, c := range overrides {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
hint := strings.Join(overrides, "\n - ")
|
||||
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
|
||||
}
|
||||
|
||||
// No explicit override: legacy fallback only — embedded materialization
|
||||
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
|
||||
// the wrapper that callers use. This branch is reachable only when the
|
||||
// wrapper passes through with an empty explicit/env (legacy direct call).
|
||||
candidates := skillSourceCandidates("", subdir)
|
||||
for _, c := range candidates {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
@@ -640,7 +667,14 @@ func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||
}
|
||||
|
||||
func isInteractiveTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
|
||||
}
|
||||
|
||||
func isCharDevice(file *os.File) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
fi, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -47,6 +47,22 @@ func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) {
|
||||
if isCharDevice(nil) {
|
||||
t.Fatal("nil file must not be treated as interactive")
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if isCharDevice(file) {
|
||||
t.Fatal("regular files must not be treated as interactive terminals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
monoDir := filepath.Join(tmp, "skills", "mono")
|
||||
@@ -317,36 +333,6 @@ func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
|
||||
|
||||
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
|
||||
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
|
||||
// successfully discovers ~/.dws/skills/multi/ as the source.
|
||||
func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
|
||||
cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
|
||||
if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run resolver from a tempdir that has no skills/ on disk, simulating a
|
||||
// fresh user machine without a source checkout.
|
||||
scratch := t.TempDir()
|
||||
t.Chdir(scratch)
|
||||
|
||||
got, err := resolveSkillSetupSource("", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
|
||||
}
|
||||
if got != cacheRoot {
|
||||
t.Fatalf("expected %s, got %s", cacheRoot, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMultiSkillName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -15,10 +15,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
data, _ := jsonutil.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
@@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
data, _ := jsonutil.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
data, _ := jsonutil.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
|
||||
@@ -15,20 +15,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ugBold = color.New(color.Bold).SprintFunc()
|
||||
ugGreen = color.New(color.FgGreen).SprintFunc()
|
||||
ugYellow = color.New(color.FgYellow).SprintFunc()
|
||||
ugRed = color.New(color.FgRed).SprintFunc()
|
||||
ugCyan = color.New(color.FgCyan).SprintFunc()
|
||||
ugDim = color.New(color.Faint).SprintFunc()
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
ugBold = tui.Bold
|
||||
ugGreen = tui.Success
|
||||
ugYellow = tui.Warning
|
||||
ugRed = tui.Danger
|
||||
ugCyan = tui.Cyan
|
||||
ugDim = tui.Dim
|
||||
ugBoldGrn = tui.Success
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
// injects to declare "this process is driven by a third-party Agent host,
|
||||
// render authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
|
||||
// AgentCodeEnvCompat is a compatibility alias for hosts that shipped the
|
||||
// reversed prefix before AgentCodeEnv became the public spelling.
|
||||
AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE"
|
||||
)
|
||||
|
||||
// AgentCodeFromEnv returns the effective host agent code and the env name that
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,13 +147,14 @@ type serviceResult struct {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
// previous login methods (OAuth, PAT, etc.) before device flow falls back to
|
||||
// MCP-managed credentials.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, device flow will re-fetch from MCP and
|
||||
// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
|
||||
// the MCP proxy path (which doesn't require clientSecret).
|
||||
// app.json, device flow will re-fetch from MCP and set the correct
|
||||
// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path
|
||||
// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret
|
||||
// overrides intentionally skip this reset.
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -162,22 +163,29 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This
|
||||
// ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -583,20 +591,21 @@ func truncateBody(body []byte, maxLen int) string {
|
||||
}
|
||||
|
||||
var (
|
||||
dfBold = color.New(color.Bold).SprintFunc()
|
||||
dfGreen = color.New(color.FgGreen).SprintFunc()
|
||||
dfYellow = color.New(color.FgYellow).SprintFunc()
|
||||
dfRed = color.New(color.FgRed).SprintFunc()
|
||||
dfCyan = color.New(color.FgCyan).SprintFunc()
|
||||
dfDim = color.New(color.Faint).SprintFunc()
|
||||
dfBold = tui.Bold
|
||||
dfGreen = tui.Success
|
||||
dfYellow = tui.Warning
|
||||
dfRed = tui.Danger
|
||||
dfCyan = tui.Cyan
|
||||
dfDim = tui.Dim
|
||||
)
|
||||
|
||||
func dfPrintStep(w io.Writer, step int, message string, attempt int) {
|
||||
label := fmt.Sprintf("Step %d", step)
|
||||
if attempt > 1 {
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt)
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)))
|
||||
_, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message)
|
||||
}
|
||||
|
||||
func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
@@ -622,7 +631,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
func dfPrintBox(w io.Writer, lines []string) {
|
||||
maxLen := 0
|
||||
for _, line := range lines {
|
||||
if l := dfPlainLength(line); l > maxLen {
|
||||
if l := tui.PlainRuneWidth(line); l > maxLen {
|
||||
maxLen = l
|
||||
}
|
||||
}
|
||||
@@ -631,38 +640,19 @@ func dfPrintBox(w io.Writer, lines []string) {
|
||||
}
|
||||
|
||||
border := strings.Repeat("─", maxLen+4)
|
||||
_, _ = fmt.Fprintf(w, " ┌%s┐\n", border)
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
|
||||
for _, line := range lines {
|
||||
pad := maxLen - dfPlainLength(line)
|
||||
pad := maxLen - tui.PlainRuneWidth(line)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad))
|
||||
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " └%s┘\n", border)
|
||||
}
|
||||
|
||||
func dfPlainLength(s string) int {
|
||||
inEscape := false
|
||||
length := 0
|
||||
for _, r := range s {
|
||||
if r == '\033' {
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
length++
|
||||
}
|
||||
return length
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
|
||||
}
|
||||
|
||||
func dfPrintPollStatus(w io.Writer, count, elapsedSec int) {
|
||||
_, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
_, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
}
|
||||
|
||||
func dfPrintPollResult(w io.Writer, status, message string) {
|
||||
|
||||
@@ -290,6 +290,11 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) {
|
||||
clientID, clientSecret = getRuntimeCredentials()
|
||||
return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
|
||||
@@ -57,8 +57,8 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
// previous login methods before the OAuth flow falls back to MCP-managed
|
||||
// credentials. Complete runtime AppKey/AppSecret overrides skip this reset.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -110,22 +110,29 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we can re-fetch clientID from MCP. This ensures --force
|
||||
// login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
|
||||
@@ -103,7 +103,7 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
|
||||
@@ -125,7 +125,13 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
|
||||
dws schema calendar.create_event --jq '.tool.auth'
|
||||
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
|
||||
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`,
|
||||
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay
|
||||
|
||||
helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app
|
||||
MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required,
|
||||
键为 CLI flag):
|
||||
dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat)
|
||||
dws schema "dev app" # 列出该分组下的子命令`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -137,6 +143,29 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
}
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery
|
||||
// catalog; their schema CONTENT is fetched LIVE from the helper's
|
||||
// pinned MCP server (op-app) and rendered in the gws-flat shape, so
|
||||
// `dws schema "dev app robot config"` answers without touching
|
||||
// discovery. Only the `dev` root claims this path; everything else
|
||||
// falls through to the catalog below.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
payload,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
var degraded *CatalogDegraded
|
||||
@@ -166,6 +195,17 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append helper-only subtrees (e.g. `dev`) to the no-arg product
|
||||
// listing so browsing all products also surfaces helper commands.
|
||||
if len(args) == 0 {
|
||||
if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 {
|
||||
if products, ok := payload["products"].([]map[string]any); ok {
|
||||
payload["products"] = append(products, helpers...)
|
||||
payload["count"] = len(payload["products"].([]map[string]any))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
}}
|
||||
|
||||
t.Run("resolves via --cli-path", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -310,7 +310,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("rejects positional + flag collision", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -1252,7 +1252,7 @@ func TestSchemaCommandOutputsDegradedOnUnauthenticated(t *testing.T) {
|
||||
Reason: DegradedUnauthenticated,
|
||||
Hint: "未登录,无法发现 MCP 服务。请先执行: dws auth login",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1285,9 +1285,9 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) {
|
||||
|
||||
degradedErr := &CatalogDegraded{
|
||||
Reason: DegradedMarketUnreachable,
|
||||
Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络",
|
||||
Hint: "无法连接 MCP 市场,请检查网络",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1310,7 +1310,7 @@ func TestSchemaCommandPropagatesNonDegradedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantErr := errors.New("unexpected failure")
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr}, nil)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// helperSchemaRoots are top-level command names whose subtrees are helper-only
|
||||
// (hard-coded cobra commands, not in the discovery catalog). `dws schema` still
|
||||
// answers for them, but unlike discovery products the schema CONTENT is fetched
|
||||
// LIVE from the helper's pinned MCP server (op-app) and rendered in the flat
|
||||
// gws-aligned shape — never synthesized from local cobra flags, never
|
||||
// hardcoded. The mapping from a leaf command to its MCP tool comes from the
|
||||
// `mcp-tool` cobra annotation set in internal/helpers/devapp.go.
|
||||
var helperSchemaRoots = map[string]bool{"dev": true}
|
||||
|
||||
// HelperToolSchema is the live op-app tool schema the renderer needs: the raw
|
||||
// MCP description plus the inputSchema's properties/required, exactly as the
|
||||
// server returned them (no local transformation of CONTENT).
|
||||
type HelperToolSchema struct {
|
||||
Name string
|
||||
Description string
|
||||
Properties map[string]any // MCP param name → property object {type,description,default?,...}
|
||||
Required []string // MCP param names that are required
|
||||
}
|
||||
|
||||
// HelperToolFetcher loads a helper MCP server's tools/list LIVE and returns
|
||||
// toolName→schema for the given source (e.g. "op-app" for dev app commands,
|
||||
// "devdoc" for dev doc commands). The schema command injects this so
|
||||
// dev_schema.go can resolve a command's MCP tool and render its real schema
|
||||
// without the cli package importing app/transport. Implementations should
|
||||
// cache per-source per-process so repeated `dws schema dev.*` only hit the
|
||||
// network once per source.
|
||||
type HelperToolFetcher func(ctx context.Context, source string) (map[string]HelperToolSchema, error)
|
||||
|
||||
// renderHelperSchema builds the `dws schema` payload for helper-only command
|
||||
// subtrees. Returns (payload, true) when the path targets a helper subtree (so
|
||||
// the caller skips catalog resolution); (nil, false) otherwise so the caller
|
||||
// falls back to the discovery catalog.
|
||||
//
|
||||
// Leaf commands render the gws-flat object {description, path, source,
|
||||
// parameters{<kebab>:{type,description,default?,required}}} with all CONTENT
|
||||
// pulled live from the MCP tool named by the command's `mcp-tool` annotation.
|
||||
// Group/root paths render a browse listing {path, commands:[...]} from the
|
||||
// cobra tree (no MCP needed).
|
||||
func renderHelperSchema(ctx context.Context, root *cobra.Command, rawPath string, fetch HelperToolFetcher) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !helperSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Find resolves to the deepest matching command and returns trailing tokens
|
||||
// it couldn't match as (sub)commands. Any non-flag leftover means a typo'd
|
||||
// or unknown subcommand — surface it with the closest group's children.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// A runnable leaf → emit its live MCP schema in gws-flat shape.
|
||||
// A group → browse its subcommands.
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
payload, err := helperLeafSchema(ctx, target, fetch)
|
||||
return payload, true, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// helperLeafSchema renders a single leaf command as the gws-flat object,
|
||||
// fetching its MCP tool schema live. The command must carry an `mcp-tool`
|
||||
// annotation; commands without one (e.g. `dev connect`, `dev doc search`) are
|
||||
// not devapp tools and get a clear, non-fatal explanation instead.
|
||||
func helperLeafSchema(ctx context.Context, cmd *cobra.Command, fetch HelperToolFetcher) (map[string]any, error) {
|
||||
toolName, source := "", ""
|
||||
if cmd.Annotations != nil {
|
||||
toolName = strings.TrimSpace(cmd.Annotations["mcp-tool"])
|
||||
source = strings.TrimSpace(cmd.Annotations["mcp-source"])
|
||||
}
|
||||
// Default source is op-app (dev app commands); dev doc commands annotate
|
||||
// mcp-source=devdoc to pull from the devdoc MCP server instead.
|
||||
if source == "" {
|
||||
source = "op-app"
|
||||
}
|
||||
path := helperCommandPath(cmd)
|
||||
if toolName == "" {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "no MCP tool bound to this command; schema is unavailable",
|
||||
}, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "live MCP schema fetcher not configured",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tools, err := fetch(ctx, source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s tool schemas: %w", source, err)
|
||||
}
|
||||
tool, ok := tools[toolName]
|
||||
if !ok {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": fmt.Sprintf("MCP tool %q not found in %s tools/list", toolName, source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"description": tool.Description,
|
||||
"path": path,
|
||||
"source": "mcp:" + source,
|
||||
"parameters": helperFlatParameters(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helperFlatParameters projects an MCP tool's inputSchema into the gws-flat
|
||||
// per-parameter object. Keys are kebab-case of the MCP param name (== the CLI
|
||||
// flag); each value is {type, description, default?, required} with type mapped
|
||||
// to a JSON-type string, description verbatim from MCP, default only when MCP
|
||||
// provides one (stringified), and required inline (true iff the param is in the
|
||||
// tool's required[]).
|
||||
func helperFlatParameters(tool HelperToolSchema) map[string]any {
|
||||
required := make(map[string]bool, len(tool.Required))
|
||||
for _, r := range tool.Required {
|
||||
required[r] = true
|
||||
}
|
||||
|
||||
params := make(map[string]any, len(tool.Properties))
|
||||
for name, raw := range tool.Properties {
|
||||
prop, _ := raw.(map[string]any)
|
||||
entry := map[string]any{
|
||||
"type": mcpJSONType(prop),
|
||||
"description": mcpString(prop, "description"),
|
||||
"required": required[name],
|
||||
}
|
||||
if def, ok := mcpDefault(prop); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[kebabCase(name)] = entry
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// mcpJSONType normalizes the MCP property "type" to a JSON-type string. MCP
|
||||
// reports standard JSON Schema types; pass them through, defaulting to "string"
|
||||
// when absent/unknown so the contract is always populated.
|
||||
func mcpJSONType(prop map[string]any) string {
|
||||
t, _ := prop["type"].(string)
|
||||
switch t {
|
||||
case "string", "integer", "number", "boolean", "array", "object":
|
||||
return t
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// mcpString reads a string field from an MCP property object.
|
||||
func mcpString(prop map[string]any, key string) string {
|
||||
if prop == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := prop[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mcpDefault returns the MCP-provided default, stringified, only when present.
|
||||
// gws renders default as a string; mirror that. Non-string JSON defaults
|
||||
// (numbers/bools) are formatted with %v so e.g. 0 → "0", true → "true".
|
||||
func mcpDefault(prop map[string]any) (string, bool) {
|
||||
if prop == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := prop["default"]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
return tv, true
|
||||
case float64:
|
||||
// JSON numbers decode to float64; render integers without a fraction.
|
||||
if tv == float64(int64(tv)) {
|
||||
return fmt.Sprintf("%d", int64(tv)), true
|
||||
}
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
default:
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
}
|
||||
}
|
||||
|
||||
// kebabCase converts an MCP camelCase param name to the CLI flag's kebab form,
|
||||
// matching how flags are registered in internal/helpers/devapp.go:
|
||||
//
|
||||
// eventCallbackUrl → event-callback-url
|
||||
// unifiedAppId → unified-app-id
|
||||
// disableSSLVerify → disable-ssl-verify
|
||||
//
|
||||
// A boundary is inserted before an uppercase letter that follows a lowercase
|
||||
// letter or digit, and before the final uppercase of a run that starts a new
|
||||
// lowercase word (so SSLVerify → ssl-verify, not s-s-l-verify).
|
||||
func kebabCase(name string) string {
|
||||
runes := []rune(name)
|
||||
var b strings.Builder
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) {
|
||||
prevLowerOrDigit := i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1]))
|
||||
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
if i > 0 && (prevLowerOrDigit || nextLower) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
// Collapse any accidental double dashes and trim, just in case the source
|
||||
// already contained separators.
|
||||
out := strings.ReplaceAll(b.String(), "_", "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
return strings.Trim(out, "-")
|
||||
}
|
||||
|
||||
// helperProductSummaries returns light product entries for every helper-only
|
||||
// subtree, appended to the no-arg `dws schema` product listing so agents
|
||||
// browsing all products also see helper commands. Tools are listed by path +
|
||||
// summary only; drill in with `dws schema "<path>"` for full parameter schema.
|
||||
func helperProductSummaries(root *cobra.Command) []map[string]any {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for name := range helperSchemaRoots {
|
||||
top, _, err := root.Find([]string{name})
|
||||
if err != nil || top == nil || !top.HasParent() {
|
||||
continue
|
||||
}
|
||||
leaves := []map[string]any{}
|
||||
walkLeafCommands(top, func(leaf *cobra.Command) {
|
||||
leaves = append(leaves, map[string]any{
|
||||
"cli_name": leaf.Name(),
|
||||
"cli_path": helperCommandPath(leaf),
|
||||
"description": strings.TrimSpace(leaf.Short),
|
||||
})
|
||||
})
|
||||
out = append(out, map[string]any{
|
||||
"id": name,
|
||||
"name": strings.TrimSpace(top.Short),
|
||||
"description": "helper-only 命令组(不走服务发现);schema 从 op-app MCP 实时拉取,用 `dws schema \"" + helperCommandPath(top) + " ...\"` 查具体参数",
|
||||
"helper": true,
|
||||
"tools": leaves,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// walkLeafCommands invokes fn for every runnable leaf under cmd (depth-first).
|
||||
func walkLeafCommands(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd.Runnable() && !cmd.HasAvailableSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkLeafCommands(sub, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// helperSubcommands lists a group's runnable children for browse mode, sorted
|
||||
// by name for deterministic output.
|
||||
func helperSubcommands(cmd *cobra.Command) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"cli_path": helperCommandPath(sub),
|
||||
"description": strings.TrimSpace(sub.Short),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["cli_path"].(string) < out[j]["cli_path"].(string)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// helperCommandPath returns the space-joined path from root to cmd, e.g.
|
||||
// "dev app robot config".
|
||||
func helperCommandPath(cmd *cobra.Command) string {
|
||||
parts := []string{}
|
||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||
parts = append([]string{c.Name()}, parts...)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// firstNonFlag returns the first token that is not a flag (does not start with
|
||||
// "-"), or "" if there is none.
|
||||
func firstNonFlag(tokens []string) string {
|
||||
for _, t := range tokens {
|
||||
if t != "" && !strings.HasPrefix(t, "-") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildHelperTestTree mirrors the shape of the real `dws dev` subtree closely
|
||||
// enough to exercise the live-schema renderer: a group and leaves carrying the
|
||||
// `mcp-tool` annotation that names the op-app tool to fetch.
|
||||
func buildHelperTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Annotations: map[string]string{"mcp-tool": "create_dev_app"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
config := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置机器人",
|
||||
Annotations: map[string]string{"mcp-tool": "set_extension_robot_config"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
// A leaf without an mcp-tool annotation (e.g. dev connect / dev doc search).
|
||||
noTool := &cobra.Command{Use: "connect", Short: "无 MCP 工具", Run: func(*cobra.Command, []string) {}}
|
||||
|
||||
robot := &cobra.Command{Use: "robot", Short: "机器人能力"}
|
||||
robot.AddCommand(config)
|
||||
|
||||
app := &cobra.Command{Use: "app", Short: "应用"}
|
||||
app.AddCommand(create, robot)
|
||||
|
||||
dev := &cobra.Command{Use: "dev", Short: "开放平台开发者命令"}
|
||||
dev.AddCommand(app, noTool)
|
||||
|
||||
root.AddCommand(dev)
|
||||
return root
|
||||
}
|
||||
|
||||
// fakeFetcher returns a canned op-app tools/list so the renderer is exercised
|
||||
// without network. It mirrors the MCP shape: properties keyed by camelCase param
|
||||
// name, required[] listing the camelCase names.
|
||||
func fakeFetcher(tools map[string]HelperToolSchema) HelperToolFetcher {
|
||||
return func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return tools, nil
|
||||
}
|
||||
}
|
||||
|
||||
func robotConfigToolSchema() HelperToolSchema {
|
||||
return HelperToolSchema{
|
||||
Name: "set_extension_robot_config",
|
||||
Description: "创建或更新现有应用的机器人配置",
|
||||
Properties: map[string]any{
|
||||
"unifiedAppId": map[string]any{"type": "string", "description": "统一应用 ID"},
|
||||
"eventCallbackUrl": map[string]any{"type": "string", "description": "事件回调地址"},
|
||||
"skills": map[string]any{"type": "array", "description": "技能列表"},
|
||||
"mode": map[string]any{"type": "string", "description": "机器人模式", "enum": []any{"HTTPS", "STREAM", "AISKILL"}},
|
||||
},
|
||||
Required: []string{"unifiedAppId"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_LeafGwsFlat(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
fetch := fakeFetcher(map[string]HelperToolSchema{
|
||||
"set_extension_robot_config": robotConfigToolSchema(),
|
||||
})
|
||||
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app robot config", fetch)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected helper renderer to claim the path")
|
||||
}
|
||||
|
||||
// Flat top-level: description / path / source / parameters; no wrapper.
|
||||
if payload["description"] != "创建或更新现有应用的机器人配置" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "dev app robot config" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "mcp:op-app" {
|
||||
t.Fatalf("source = %v", payload["source"])
|
||||
}
|
||||
for _, leaked := range []string{"kind", "tool", "product", "helper"} {
|
||||
if _, present := payload[leaked]; present {
|
||||
t.Fatalf("gws-flat output must not carry %q wrapper key", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Keys are kebab-case of the MCP param name == the CLI flag.
|
||||
uid, _ := params["unified-app-id"].(map[string]any)
|
||||
if uid == nil {
|
||||
t.Fatalf("missing unified-app-id param: %#v", params)
|
||||
}
|
||||
if uid["type"] != "string" || uid["required"] != true {
|
||||
t.Fatalf("unified-app-id = %#v, want string+required", uid)
|
||||
}
|
||||
if _, hasDefault := uid["default"]; hasDefault {
|
||||
t.Fatal("unified-app-id must not carry a default (MCP provides none)")
|
||||
}
|
||||
|
||||
cb, _ := params["event-callback-url"].(map[string]any)
|
||||
if cb == nil || cb["required"] != false {
|
||||
t.Fatalf("event-callback-url = %#v, want required=false", cb)
|
||||
}
|
||||
|
||||
skills, _ := params["skills"].(map[string]any)
|
||||
if skills == nil || skills["type"] != "array" {
|
||||
t.Fatalf("skills = %#v, want array", skills)
|
||||
}
|
||||
|
||||
mode, _ := params["mode"].(map[string]any)
|
||||
if mode == nil || mode["type"] != "string" {
|
||||
t.Fatalf("mode = %#v, want string", mode)
|
||||
}
|
||||
if _, hasDefault := mode["default"]; hasDefault {
|
||||
t.Fatalf("mode default = %v, want none", mode["default"])
|
||||
}
|
||||
if mode["required"] != false {
|
||||
t.Fatalf("mode required = %v, want false", mode["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_Group(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["path"] != "dev app" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
if len(cmds) != 2 { // create + robot
|
||||
t.Fatalf("commands count = %d, want 2", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NoAnnotation(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev connect", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected a clear no-MCP-tool error, got %#v", payload)
|
||||
}
|
||||
if _, present := payload["parameters"]; present {
|
||||
t.Fatal("no-tool command must not emit parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app nope", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected error for unknown subcommand, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_ToolMissingInList(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
// Fetcher returns an empty list — the annotated tool isn't present.
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app create", fakeFetcher(map[string]HelperToolSchema{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected not-found error, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_FetchError(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
failing := func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return nil, errors.New("network down")
|
||||
}
|
||||
_, ok, err := renderHelperSchema(context.Background(), root, "dev app create", failing)
|
||||
if !ok {
|
||||
t.Fatal("expected claim even on fetch error")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the fetch error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NonHelperPathDeclined(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
if _, ok, _ := renderHelperSchema(context.Background(), root, "ding.message.send", nil); ok {
|
||||
t.Fatal("non-helper path must not be claimed by the helper renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKebabCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eventCallbackUrl": "event-callback-url",
|
||||
"unifiedAppId": "unified-app-id",
|
||||
"disableSSLVerify": "disable-ssl-verify",
|
||||
"mode": "mode",
|
||||
"skills": "skills",
|
||||
"i18nName": "i18n-name",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := kebabCase(in); got != want {
|
||||
t.Errorf("kebabCase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
if embedded {
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
}
|
||||
return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络"
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
case DegradedRuntimeAllFailed:
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
@@ -279,7 +279,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL)
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers)
|
||||
|
||||
@@ -94,7 +94,7 @@ func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescripto
|
||||
|
||||
response, err := s.MarketClient.FetchServers(ctx, 200)
|
||||
if err == nil {
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL)
|
||||
_ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
+21
-19
@@ -21,6 +21,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -309,7 +311,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
}
|
||||
payload := map[string]any{"error": errorPayload}
|
||||
|
||||
data, marshalErr := json.MarshalIndent(payload, "", " ")
|
||||
data, marshalErr := jsonutil.MarshalIndent(payload, "", " ")
|
||||
if marshalErr != nil {
|
||||
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
||||
return writeErr
|
||||
@@ -344,25 +346,25 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
_, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
_, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error()))
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Line 1: Error summary
|
||||
lines := []string{
|
||||
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
|
||||
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
|
||||
}
|
||||
|
||||
// Always shown: hint, actions, retryable
|
||||
if typed.Hint != "" {
|
||||
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
|
||||
lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint)))
|
||||
}
|
||||
|
||||
// Add user-friendly hint for specific server error codes
|
||||
switch typed.ServerDiag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
|
||||
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
|
||||
lines = append(lines, tui.Cyan("Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"))
|
||||
lines = append(lines, tui.White("Action: 开启地址: "+config.GetDeveloperSettingsURL()))
|
||||
}
|
||||
|
||||
if len(typed.Actions) > 0 {
|
||||
@@ -370,53 +372,53 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if strings.TrimSpace(action) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action)))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
lines = append(lines, tui.Dim(line))
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
lines = append(lines, tui.Warning("Retryable: true"))
|
||||
}
|
||||
|
||||
// Always shown when present: Trace ID, Server Code
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)))
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)))
|
||||
}
|
||||
|
||||
// Verbose+: technical detail, snapshot, reason, server key
|
||||
if v >= VerbosityVerbose {
|
||||
if typed.ServerDiag.TechnicalDetail != "" {
|
||||
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)))
|
||||
}
|
||||
if typed.Reason != "" {
|
||||
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason)))
|
||||
}
|
||||
if typed.ServerKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
|
||||
}
|
||||
if typed.Cause != nil {
|
||||
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error())))
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: all internal diagnostics
|
||||
if v >= VerbosityDebug {
|
||||
if typed.Operation != "" {
|
||||
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation)))
|
||||
}
|
||||
if typed.RPCCode != 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode)))
|
||||
}
|
||||
if len(typed.RPCData) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-12
@@ -21,6 +21,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// hostControlProvider returns the host-owned clawType for the current
|
||||
@@ -105,12 +107,10 @@ const ExitCodePermission = 4
|
||||
// json.Unmarshal-able payload of the form
|
||||
// {"success":false,"code":<frozen enum>,"data":{...}}.
|
||||
//
|
||||
// When the payload includes data.uri, that URL is the authoritative
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
// it verbatim instead of parsing and reconstructing it locally, because
|
||||
// required parameters may live in query, encoded hash, or fragment sections.
|
||||
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
|
||||
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
|
||||
// When the payload includes data.uri/authUrl/authorizationUrl, that value is
|
||||
// the authoritative server-provided authorization link. The CLI accepts all
|
||||
// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a
|
||||
// single data.uri field so terminals and hosts do not need to deduplicate links.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -147,6 +147,7 @@ var patNoPermissionCodes = map[string]bool{
|
||||
// `dws auth login --scope <data.missingScope>`.
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"PAT_BATCH_AUTH_PENDING": true,
|
||||
"PAT_SCOPE_AUTH_REQUIRED": true,
|
||||
}
|
||||
|
||||
@@ -241,7 +242,7 @@ func isBusinessError(body map[string]any) bool {
|
||||
// Check order: DWS gateway auth > PAT permission.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
raw, _ := jsonutil.Marshal(content)
|
||||
return NewAuth(string(raw),
|
||||
WithReason("gateway_auth_expired"),
|
||||
WithHint(authExpiredHint()),
|
||||
@@ -353,8 +354,11 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
@@ -363,6 +367,16 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
func patAuthorizationURIFromData(data map[string]any) string {
|
||||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||||
value, _ := data[key].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
||||
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
||||
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
||||
@@ -443,9 +457,9 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
"code": code,
|
||||
}
|
||||
if data, ok := body["data"]; ok {
|
||||
// Keep data.uri exactly as returned by the service. Host consumers open
|
||||
// that link directly, so local normalization would risk dropping
|
||||
// parameters embedded in query/hash/fragment sections.
|
||||
// ApplyHostMutations canonicalizes PAT URL aliases into one data.uri
|
||||
// before JSON encoding, while stripClassFields keeps the rest of the
|
||||
// service payload intact.
|
||||
out["data"] = stripClassFields(data)
|
||||
} else {
|
||||
fallback := map[string]any{}
|
||||
|
||||
@@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") {
|
||||
t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "flow-1") {
|
||||
t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
@@ -729,6 +748,12 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
|
||||
@@ -745,8 +770,40 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted when data.uri is present")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted when data.uri is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_BATCH_AUTH_PENDING",
|
||||
"data": map[string]any{
|
||||
"flowId": "50dff7654b7444e88ced7489b07cce8d",
|
||||
"authUrl": rawURI,
|
||||
},
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING")
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,7 +859,7 @@ func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
@@ -817,17 +874,26 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=CFTC-27ZN") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
if got, _ := data["uri"].(string); got != want {
|
||||
t.Fatalf("data.uri = %q, want normalized %q", got, want)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != want {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connectGate enforces the connector's access policy: optional user/group
|
||||
// allowlists plus a per-sender rate limit. A long-lived Q&A bot is otherwise
|
||||
// drivable (and billable — every message is an LLM call) by anyone who can
|
||||
// reach it in a group.
|
||||
type connectGate struct {
|
||||
allowedUsers map[string]struct{}
|
||||
allowedGroups map[string]struct{}
|
||||
perMinute int
|
||||
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
now func() time.Time // test hook
|
||||
}
|
||||
|
||||
// splitCommaList splits a comma-separated flag/env value, trimming blanks.
|
||||
func splitCommaList(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newConnectGate builds a gate from staffId / openConversationId allowlists
|
||||
// and a per-sender messages-per-minute cap (0 = unlimited).
|
||||
func newConnectGate(users, groups []string, perMinute int) *connectGate {
|
||||
g := &connectGate{perMinute: perMinute, hits: map[string][]time.Time{}, now: time.Now}
|
||||
for _, u := range users {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
if g.allowedUsers == nil {
|
||||
g.allowedUsers = map[string]struct{}{}
|
||||
}
|
||||
g.allowedUsers[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, grp := range groups {
|
||||
if grp = strings.TrimSpace(grp); grp != "" {
|
||||
if g.allowedGroups == nil {
|
||||
g.allowedGroups = map[string]struct{}{}
|
||||
}
|
||||
g.allowedGroups[grp] = struct{}{}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// enabled reports whether any policy is configured at all.
|
||||
func (g *connectGate) enabled() bool {
|
||||
return g != nil && (len(g.allowedUsers) > 0 || len(g.allowedGroups) > 0 || g.perMinute > 0)
|
||||
}
|
||||
|
||||
// allow reports whether a message may proceed; on denial, reason names the
|
||||
// rule that fired (for the connector log). Group messages must pass the group
|
||||
// allowlist AND, when a user allowlist is set, the sender allowlist too.
|
||||
func (g *connectGate) allow(staffID, convType, convID string) (bool, string) {
|
||||
if g == nil {
|
||||
return true, ""
|
||||
}
|
||||
if convType == "2" && len(g.allowedGroups) > 0 {
|
||||
if _, ok := g.allowedGroups[convID]; !ok {
|
||||
return false, "group-not-allowed"
|
||||
}
|
||||
}
|
||||
if len(g.allowedUsers) > 0 {
|
||||
if _, ok := g.allowedUsers[staffID]; !ok {
|
||||
return false, "user-not-allowed"
|
||||
}
|
||||
}
|
||||
if g.perMinute > 0 && staffID != "" {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
cutoff := now.Add(-time.Minute)
|
||||
kept := g.hits[staffID][:0]
|
||||
for _, t := range g.hits[staffID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= g.perMinute {
|
||||
g.hits[staffID] = kept
|
||||
return false, "rate-limited"
|
||||
}
|
||||
g.hits[staffID] = append(kept, now)
|
||||
// Bound memory against sender-id churn: keep only the current sender
|
||||
// when the map grows absurdly large.
|
||||
if len(g.hits) > 4096 {
|
||||
g.hits = map[string][]time.Time{staffID: g.hits[staffID]}
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectGateAllowlists(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
users []string
|
||||
groups []string
|
||||
staff string
|
||||
convType string
|
||||
convID string
|
||||
wantOK bool
|
||||
wantReason string
|
||||
}{
|
||||
{"no policy allows everyone", nil, nil, "u1", "1", "", true, ""},
|
||||
{"user in list", []string{"u1", "u2"}, nil, "u1", "1", "", true, ""},
|
||||
{"user not in list", []string{"u1"}, nil, "u9", "1", "", false, "user-not-allowed"},
|
||||
{"group in list", nil, []string{"cid-a"}, "u9", "2", "cid-a", true, ""},
|
||||
{"group not in list", nil, []string{"cid-a"}, "u9", "2", "cid-b", false, "group-not-allowed"},
|
||||
{"group list does not gate DMs", nil, []string{"cid-a"}, "u9", "1", "", true, ""},
|
||||
{"both lists: group ok user not", []string{"u1"}, []string{"cid-a"}, "u9", "2", "cid-a", false, "user-not-allowed"},
|
||||
{"both lists: both ok", []string{"u1"}, []string{"cid-a"}, "u1", "2", "cid-a", true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newConnectGate(tc.users, tc.groups, 0)
|
||||
ok, reason := g.allow(tc.staff, tc.convType, tc.convID)
|
||||
if ok != tc.wantOK || reason != tc.wantReason {
|
||||
t.Fatalf("allow = (%v, %q), want (%v, %q)", ok, reason, tc.wantOK, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectGateRateLimit(t *testing.T) {
|
||||
g := newConnectGate(nil, nil, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
g.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if ok, _ := g.allow("u1", "1", ""); !ok {
|
||||
t.Fatalf("message %d should pass", i+1)
|
||||
}
|
||||
}
|
||||
if ok, reason := g.allow("u1", "1", ""); ok || reason != "rate-limited" {
|
||||
t.Fatalf("3rd message = (%v, %q), want rate-limited", ok, reason)
|
||||
}
|
||||
// Another sender is unaffected.
|
||||
if ok, _ := g.allow("u2", "1", ""); !ok {
|
||||
t.Fatal("other sender should pass")
|
||||
}
|
||||
// The window slides: a minute later the sender passes again.
|
||||
now = now.Add(61 * time.Second)
|
||||
if ok, _ := g.allow("u1", "1", ""); !ok {
|
||||
t.Fatal("after window slides the sender should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectGateDisabled(t *testing.T) {
|
||||
var nilGate *connectGate
|
||||
if nilGate.enabled() {
|
||||
t.Fatal("nil gate must report disabled")
|
||||
}
|
||||
if ok, _ := nilGate.allow("u", "1", ""); !ok {
|
||||
t.Fatal("nil gate must allow")
|
||||
}
|
||||
if newConnectGate(nil, nil, 0).enabled() {
|
||||
t.Fatal("empty gate must report disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommaList(t *testing.T) {
|
||||
got := splitCommaList(" a, b ,, c ")
|
||||
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||||
t.Fatalf("splitCommaList = %v", got)
|
||||
}
|
||||
if splitCommaList("") != nil {
|
||||
t.Fatal("empty input should yield nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeExecStub drops an executable shell stub named name into dir so PATH
|
||||
// lookups resolve without the real CLI installed.
|
||||
func writeExecStub(dir, name string) error {
|
||||
return os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
|
||||
}
|
||||
|
||||
// TestConvSessions covers the per-conversation session contract: first message
|
||||
// creates (--session-id), follow-ups resume (--resume), reset re-mints.
|
||||
func TestConvSessions(t *testing.T) {
|
||||
s := newConvSessions("")
|
||||
|
||||
first := s.args("conv-1")
|
||||
if len(first) != 2 || first[0] != "--session-id" || first[1] == "" {
|
||||
t.Fatalf("first args = %v, want [--session-id <uuid>]", first)
|
||||
}
|
||||
second := s.args("conv-1")
|
||||
if len(second) != 2 || second[0] != "--resume" || second[1] != first[1] {
|
||||
t.Fatalf("second args = %v, want [--resume %s]", second, first[1])
|
||||
}
|
||||
|
||||
// A different conversation gets its own session.
|
||||
other := s.args("conv-2")
|
||||
if other[0] != "--session-id" || other[1] == first[1] {
|
||||
t.Fatalf("conv-2 args = %v, want a fresh --session-id distinct from %s", other, first[1])
|
||||
}
|
||||
|
||||
// reset self-heals a broken session: next message starts fresh with a NEW
|
||||
// uuid (the old one may or may not exist agent-side; a new one is safe).
|
||||
s.reset("conv-1")
|
||||
again := s.args("conv-1")
|
||||
if again[0] != "--session-id" || again[1] == first[1] {
|
||||
t.Fatalf("post-reset args = %v, want fresh --session-id != %s", again, first[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyModelArg covers both shapes: replacing an existing model pin
|
||||
// (claudecode's built-in haiku) and inserting before the tail (gemini-style
|
||||
// tails that end with -p and need the prompt to stay trailing).
|
||||
func TestApplyModelArg(t *testing.T) {
|
||||
replaced := applyModelArg(
|
||||
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
|
||||
"--model", "claude-sonnet-4-6")
|
||||
want := []string{"claude", "-p", "--model", "claude-sonnet-4-6", "--strict-mcp-config"}
|
||||
if strings.Join(replaced, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("replace: got %v, want %v", replaced, want)
|
||||
}
|
||||
|
||||
inserted := applyModelArg([]string{"gemini", "-p"}, "-m", "gemini-2.5-pro")
|
||||
wantIns := []string{"gemini", "-m", "gemini-2.5-pro", "-p"}
|
||||
if strings.Join(inserted, " ") != strings.Join(wantIns, " ") {
|
||||
t.Fatalf("insert: got %v, want %v", inserted, wantIns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderSessionAndModelWiring checks forwarderForChannel applies the
|
||||
// options: memory only on ccSessions channels, model only via the spec's flag.
|
||||
func TestForwarderSessionAndModelWiring(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "") // ensure no override
|
||||
// Use DWS_AGENT_CMD-free resolution; claudecode requires the binary on
|
||||
// PATH, which CI may lack — fake it via DWS_AGENT_CMD is wrong (disables
|
||||
// extras by design), so point PATH at a stub.
|
||||
stub := t.TempDir()
|
||||
for _, name := range []string{"claude", "qodercli"} {
|
||||
if err := writeExecStub(stub, name); err != nil {
|
||||
t.Fatalf("stub %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
fwd, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: true, Model: "claude-sonnet-4-6"})
|
||||
if err != nil {
|
||||
t.Fatalf("claudecode forwarder: %v", err)
|
||||
}
|
||||
ef := fwd.(*execForwarder)
|
||||
if ef.sessions == nil {
|
||||
t.Fatal("claudecode with Memory=true should have sessions enabled")
|
||||
}
|
||||
if !strings.Contains(strings.Join(ef.argv, " "), "--model claude-sonnet-4-6") {
|
||||
t.Fatalf("model not applied: %v", ef.argv)
|
||||
}
|
||||
if strings.Contains(strings.Join(ef.argv, " "), "haiku") {
|
||||
t.Fatalf("built-in haiku pin should be replaced: %v", ef.argv)
|
||||
}
|
||||
|
||||
// qoder family runs a persistent stream-json subprocess and carries the
|
||||
// addressable Qoder session id inside each JSON user message. DWS persists
|
||||
// the mapping so a connector restart can resume the same conversation.
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
qf, err := forwarderForChannel("qoder", "qoder-client", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("qoder forwarder: %v", err)
|
||||
}
|
||||
qsf := qf.(*qoderStreamForwarder)
|
||||
if qsf.sessions == nil {
|
||||
t.Fatal("qoder with Memory=true should have sessions")
|
||||
}
|
||||
if qsf.sessions.path == "" {
|
||||
t.Fatal("qoder sessions should persist to disk")
|
||||
}
|
||||
if got := strings.Join(qsf.commandArgs(), " "); !strings.Contains(got, "--input-format stream-json") || !strings.Contains(got, "--output-format stream-json") {
|
||||
t.Fatalf("qoder stream-json args mismatch: argv = %v", qsf.commandArgs())
|
||||
}
|
||||
|
||||
qwf, err := forwarderForChannel("qoderwork", "robot-client", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("qoderwork forwarder: %v", err)
|
||||
}
|
||||
qwsf := qwf.(*qoderStreamForwarder)
|
||||
if qwsf.sessions == nil {
|
||||
t.Fatal("qoderwork with Memory=true should have sessions")
|
||||
}
|
||||
if qwsf.sessions.path == "" {
|
||||
t.Fatal("qoderwork sessions should persist to disk")
|
||||
}
|
||||
|
||||
// Memory off on a supporting channel.
|
||||
off, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: false})
|
||||
if err != nil {
|
||||
t.Fatalf("claudecode memory-off forwarder: %v", err)
|
||||
}
|
||||
if off.(*execForwarder).sessions != nil {
|
||||
t.Fatal("Memory=false must disable sessions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
|
||||
// dry-run preview so callers can see the effective agent tuning.
|
||||
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
|
||||
"--agent-model", "claude-sonnet-4-6", "--agent-workdir", "/tmp/kb",
|
||||
"--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\n%s", err, out.String())
|
||||
}
|
||||
for _, sub := range []string{"claude-sonnet-4-6", "/tmp/kb", "per-conversation"} {
|
||||
if !strings.Contains(out.String(), sub) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", sub, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// memory=false shows as disabled.
|
||||
out.Reset()
|
||||
root = newDevAppTestRoot(&captureRunner{})
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
|
||||
"--agent-memory=false", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"memory": "disabled"`) {
|
||||
t.Fatalf("dry-run output missing disabled memory:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQoderAgentMemoryPayloadIsPerConversation(t *testing.T) {
|
||||
payload := connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
|
||||
t.Fatalf("qoder memory = %v, want per-conversation-qoder-stream", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("qoderwork", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
|
||||
t.Fatalf("qoderwork memory = %v, want per-conversation-qoder-stream", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: false})
|
||||
if got := payload["memory"]; got != "disabled" {
|
||||
t.Fatalf("qoder memory disabled = %v, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectDryRunShowsCliStatus checks the dependency preflight agents
|
||||
// rely on: dry-run reports whether the channel CLI is installed, with the
|
||||
// install hint when missing.
|
||||
func TestRobotConnectDryRunShowsCliStatus(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
stub := t.TempDir()
|
||||
if err := writeExecStub(stub, "claude"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
run := func(channel string) string {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect", "--channel", channel,
|
||||
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%s): %v\n%s", channel, err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// claude on PATH → installed true with path
|
||||
if got := run("claudecode"); !strings.Contains(got, `"installed": true`) {
|
||||
t.Fatalf("claudecode should be installed:\n%s", got)
|
||||
}
|
||||
// codex NOT on PATH → installed false + hint
|
||||
got := run("codex")
|
||||
if !strings.Contains(got, `"installed": false`) || !strings.Contains(got, "@openai/codex") {
|
||||
t.Fatalf("codex should be missing with hint:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "per-conversation-app-server") {
|
||||
t.Fatalf("codex dry-run should advertise app-server memory:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// The "digital twin" confirmation gate. When someone @-mentions the bot with an
|
||||
// *action* request ("create a todo for me"), the bot does NOT execute it: it
|
||||
// records an ApprovalRequest, sends an interactive card with [Approve]/[Reject]
|
||||
// buttons to the owner, and only runs the planned action after the owner taps
|
||||
// Approve. A Reject (or timeout) declines without executing.
|
||||
//
|
||||
// This file owns the gate engine: the request/state model, the thread-safe
|
||||
// store with crash-safe on-disk persistence (so a Pending request survives a
|
||||
// connector restart), and the Submit/Decide/Await/Get API. Card delivery and
|
||||
// the connector wiring live in connect_approval_card.go and connect_stream.go.
|
||||
|
||||
// approvalState is the request lifecycle: Pending → Approved|Rejected →
|
||||
// Executed|Failed. A decision (Approve/Reject) is the owner's call; execution
|
||||
// is what the orchestrator does afterwards on an approved request.
|
||||
type approvalState string
|
||||
|
||||
const (
|
||||
approvalPending approvalState = "pending"
|
||||
approvalApproved approvalState = "approved"
|
||||
approvalRejected approvalState = "rejected"
|
||||
approvalExecuted approvalState = "executed"
|
||||
approvalFailed approvalState = "failed"
|
||||
// approvalDeferred is an approved request whose execution could not complete
|
||||
// right now — typically because the connector's dws login is not (yet) the
|
||||
// bot's owner, so an owner-scoped action can't run. The request is NOT lost:
|
||||
// it stays on disk in this state, the owner is told to recover (log in), and
|
||||
// a later "retry" flushes it. Distinct from Failed, which is terminal.
|
||||
approvalDeferred approvalState = "deferred"
|
||||
)
|
||||
|
||||
// plannedAction is the structured command the gate will run once approved. It
|
||||
// is intentionally generic (product + tool + params) so it maps straight onto
|
||||
// executor.NewHelperInvocation without the gate knowing about any specific
|
||||
// command. For the M2 vertical slice the orchestrator only emits todo.create,
|
||||
// but the shape already supports any helper tool.
|
||||
type plannedAction struct {
|
||||
// Product/Tool/LegacyPath feed executor.NewHelperInvocation. Product is the
|
||||
// canonical product (e.g. "todo"); Tool is the RPC name (e.g.
|
||||
// "create_personal_todo"); LegacyPath is the human command path for logs.
|
||||
Product string `json:"product"`
|
||||
Tool string `json:"tool"`
|
||||
LegacyPath string `json:"legacy_path,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalRequest is one pending/decided confirmation. It is the on-disk record
|
||||
// too (marshalled as-is), so every field is JSON-tagged and self-describing.
|
||||
type ApprovalRequest struct {
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"` // staffId of who asked
|
||||
ConvID string `json:"conv_id"` // conversation to reply into
|
||||
Summary string `json:"summary"` // human-readable "what will happen"
|
||||
Verb string `json:"verb,omitempty"` // action verb, e.g. "todo.create" (remember key)
|
||||
Action plannedAction `json:"action"` // structured command to run on approve
|
||||
State approvalState `json:"state"`
|
||||
// OutTrackID is the delivered card's instance id, recorded so a button
|
||||
// callback that only carries the card id (not the approval id in its action
|
||||
// params) can still be mapped back to this request.
|
||||
OutTrackID string `json:"out_track_id,omitempty"`
|
||||
DecidedBy string `json:"decided_by,omitempty"` // staffId who approved/rejected
|
||||
ExecErr string `json:"exec_err,omitempty"` // failure detail when State=failed
|
||||
// AutoApproved marks a request the owner made of THEMSELVES: no second
|
||||
// confirmation is asked (the owner asking IS the authorization), but the
|
||||
// full record is still persisted for audit, with DecidedBy=owner. Lets the
|
||||
// audit trail distinguish an auto-run from an explicitly-confirmed one.
|
||||
AutoApproved bool `json:"auto_approved,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DecidedAt time.Time `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
// decided reports whether the owner has made a call (approved or rejected),
|
||||
// regardless of any later execution outcome.
|
||||
func (r *ApprovalRequest) decided() bool {
|
||||
switch r.State {
|
||||
case approvalApproved, approvalRejected, approvalExecuted, approvalFailed, approvalDeferred:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approved reports whether the owner approved (in any post-approval state,
|
||||
// including deferred — a deferred request WAS approved, its execution just
|
||||
// hasn't completed yet).
|
||||
func (r *ApprovalRequest) approved() bool {
|
||||
switch r.State {
|
||||
case approvalApproved, approvalExecuted, approvalFailed, approvalDeferred:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approvalGate is the thread-safe confirmation-gate store. It keeps every
|
||||
// request in memory and mirrors each one to <config>/connect/<clientId>/
|
||||
// approvals/<id>.json so a Pending request survives a connector restart.
|
||||
// Persistence is best-effort and never blocks a decision: a write failure only
|
||||
// logs (mirroring connect_sessions_store's contract). An empty clientId means
|
||||
// in-memory only (used by tests).
|
||||
type approvalGate struct {
|
||||
clientID string
|
||||
|
||||
mu sync.Mutex
|
||||
reqs map[string]*ApprovalRequest
|
||||
waiters map[string]chan struct{} // id → closed when the request is decided
|
||||
}
|
||||
|
||||
// newApprovalGate builds the gate and eagerly loads any persisted requests from
|
||||
// disk (so a restart can still resolve a card tapped while the bot was down).
|
||||
func newApprovalGate(clientID string) *approvalGate {
|
||||
g := &approvalGate{
|
||||
clientID: strings.TrimSpace(clientID),
|
||||
reqs: make(map[string]*ApprovalRequest),
|
||||
waiters: make(map[string]chan struct{}),
|
||||
}
|
||||
g.loadAll()
|
||||
return g
|
||||
}
|
||||
|
||||
// approvalDir returns the on-disk directory for this gate's requests, or "" when
|
||||
// persistence is disabled (empty clientId). The clientId is sanitized exactly
|
||||
// like the session store / lock file so it is always filesystem-safe.
|
||||
func (g *approvalGate) approvalDir() string {
|
||||
if g.clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(g.clientID), "approvals")
|
||||
}
|
||||
|
||||
// Submit records a new request in the Pending state, persists it, and returns
|
||||
// the stored request (with a generated ID and CreatedAt). A blank requester or
|
||||
// summary is allowed — the gate does not police content, it only sequences the
|
||||
// approval — but an empty action means "nothing to run on approve", which the
|
||||
// orchestrator must guard before calling Submit.
|
||||
func (g *approvalGate) Submit(req ApprovalRequest) *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(req.ID) == "" {
|
||||
req.ID = uuid.NewString()
|
||||
}
|
||||
req.State = approvalPending
|
||||
if req.CreatedAt.IsZero() {
|
||||
req.CreatedAt = time.Now()
|
||||
}
|
||||
stored := req // copy
|
||||
g.reqs[stored.ID] = &stored
|
||||
g.persist(&stored)
|
||||
return &stored
|
||||
}
|
||||
|
||||
// setOutTrackID records the delivered card instance id for a request so a
|
||||
// callback that carries only the card id can be mapped back. No-op for unknown
|
||||
// ids; persists on success.
|
||||
func (g *approvalGate) setOutTrackID(id, outTrackID string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.OutTrackID = outTrackID
|
||||
g.persist(r)
|
||||
}
|
||||
|
||||
// findByOutTrackID returns the request whose delivered card has the given
|
||||
// instance id, used as the fallback association when a callback's action params
|
||||
// did not carry the approval id. Returns nil when none match.
|
||||
func (g *approvalGate) findByOutTrackID(outTrackID string) *ApprovalRequest {
|
||||
outTrackID = strings.TrimSpace(outTrackID)
|
||||
if outTrackID == "" {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for _, r := range g.reqs {
|
||||
if r.OutTrackID == outTrackID {
|
||||
cp := *r
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingForConv returns a snapshot of the most-recent still-Pending request in
|
||||
// the given conversation, or nil when none is awaiting a decision there. It is
|
||||
// the lookup the text-approval mode needs to map an owner's "同意/拒绝" reply
|
||||
// (which only carries the conversation, not an approval id) back to its request.
|
||||
func (g *approvalGate) pendingForConv(convID string) *ApprovalRequest {
|
||||
convID = strings.TrimSpace(convID)
|
||||
if convID == "" {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var best *ApprovalRequest
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalPending && r.ConvID == convID {
|
||||
if best == nil || r.CreatedAt.After(best.CreatedAt) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *best
|
||||
return &cp
|
||||
}
|
||||
|
||||
// latestPending returns a snapshot of the most-recent still-Pending request
|
||||
// across all conversations, or nil when none awaits a decision. Text approval
|
||||
// uses it to map the owner's "同意/拒绝" — sent from the owner's OWN 1:1 chat
|
||||
// with the bot, a different conversation than where the request originated —
|
||||
// onto the request they are deciding. One bot has one owner, so every pending
|
||||
// request belongs to that owner; newest-first matches "decide what I was just
|
||||
// asked about".
|
||||
func (g *approvalGate) latestPending() *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var best *ApprovalRequest
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalPending {
|
||||
if best == nil || r.CreatedAt.After(best.CreatedAt) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *best
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Decide records the owner's call on a Pending request and wakes any Await.
|
||||
// It is idempotent and race-safe: a second decision (e.g. a double-tap, or a
|
||||
// reject after an approve) is ignored once the request has already been
|
||||
// decided, and the function reports whether THIS call was the deciding one.
|
||||
func (g *approvalGate) Decide(id string, approve bool, by string) (*ApprovalRequest, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if r.decided() {
|
||||
cp := *r
|
||||
return &cp, false // already decided; not the deciding call
|
||||
}
|
||||
if approve {
|
||||
r.State = approvalApproved
|
||||
} else {
|
||||
r.State = approvalRejected
|
||||
}
|
||||
r.DecidedBy = strings.TrimSpace(by)
|
||||
r.DecidedAt = time.Now()
|
||||
g.persist(r)
|
||||
g.wake(id)
|
||||
cp := *r
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
// markExecuted / markFailed record the post-approval execution outcome. They do
|
||||
// not gate or wake anything (Await already returned on the decision); they exist
|
||||
// so the on-disk record reflects what actually happened, for audit and restart.
|
||||
func (g *approvalGate) markExecuted(id string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalExecuted
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *approvalGate) markFailed(id string, cause string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalFailed
|
||||
r.ExecErr = truncateRunes(strings.TrimSpace(cause), 500)
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
// markDeferred records that an approved request could not execute now and is
|
||||
// being held for a later retry (it is NOT lost). The cause is kept so the owner
|
||||
// and the audit trail can see why. Persisted so a connector restart still
|
||||
// remembers the backlog.
|
||||
func (g *approvalGate) markDeferred(id string, cause string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalDeferred
|
||||
r.ExecErr = truncateRunes(strings.TrimSpace(cause), 500)
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
// allDeferred returns snapshots of every request awaiting retry, oldest first
|
||||
// (so a flush replays them in the order they were asked).
|
||||
func (g *approvalGate) allDeferred() []*ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make([]*ApprovalRequest, 0)
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalDeferred {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a snapshot copy of a request, or nil if unknown.
|
||||
func (g *approvalGate) Get(id string) *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok {
|
||||
cp := *r
|
||||
return &cp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Await blocks until the request is decided (approved or rejected), the context
|
||||
// is cancelled, or the timeout elapses, and returns the final snapshot. A
|
||||
// request already decided returns immediately. On timeout the request stays
|
||||
// Pending (a late tap can still resolve it) and Await returns (snapshot, false).
|
||||
// The bool reports whether a decision was observed within the wait.
|
||||
func (g *approvalGate) Await(ctx context.Context, id string, timeout time.Duration) (*ApprovalRequest, bool) {
|
||||
// Fast path + waiter registration under the lock so we never miss a Decide
|
||||
// that lands between the check and the channel subscribe.
|
||||
g.mu.Lock()
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
g.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
if r.decided() {
|
||||
cp := *r
|
||||
g.mu.Unlock()
|
||||
return &cp, true
|
||||
}
|
||||
ch, exists := g.waiters[id]
|
||||
if !exists {
|
||||
ch = make(chan struct{})
|
||||
g.waiters[id] = ch
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
var timer *time.Timer
|
||||
var timeout_c <-chan time.Time
|
||||
if timeout > 0 {
|
||||
timer = time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
timeout_c = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
return g.Get(id), true
|
||||
case <-ctx.Done():
|
||||
return g.Get(id), false
|
||||
case <-timeout_c:
|
||||
return g.Get(id), false
|
||||
}
|
||||
}
|
||||
|
||||
// wake closes and clears the waiter channel for id (must hold g.mu). Safe to
|
||||
// call when there is no waiter.
|
||||
func (g *approvalGate) wake(id string) {
|
||||
if ch, ok := g.waiters[id]; ok {
|
||||
close(ch)
|
||||
delete(g.waiters, id)
|
||||
}
|
||||
}
|
||||
|
||||
// persist atomically writes one request to disk (must hold g.mu). Best-effort:
|
||||
// a failure only logs and never blocks the decision path, exactly like
|
||||
// saveConvSessionMap. No-op when persistence is disabled.
|
||||
func (g *approvalGate) persist(r *ApprovalRequest) {
|
||||
dir := g.approvalDir()
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批目录失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 序列化审批失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, sanitizeLockID(r.ID)+".json")
|
||||
tmp, err := os.CreateTemp(dir, "approval-*.json.tmp")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 设置审批文件权限失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 写入审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 关闭审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 原子替换审批存档失败,跳过落盘:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAll reads every persisted request back into memory at startup. It is
|
||||
// forgiving like loadConvSessionMap: a missing dir (first run) is silent, and a
|
||||
// single corrupt file is skipped with a warning rather than aborting the load.
|
||||
func (g *approvalGate) loadAll() {
|
||||
dir := g.approvalDir()
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批目录失败,按空起:%v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批存档 %s 失败,跳过:%v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
var r ApprovalRequest
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 审批存档 %s 已损坏,跳过:%v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(r.ID) == "" {
|
||||
continue
|
||||
}
|
||||
g.reqs[r.ID] = &r
|
||||
}
|
||||
}
|
||||
|
||||
// ---- "Execution-class" request detection (simplified for the M2 slice) ----
|
||||
//
|
||||
// We do NOT build a general NLP intent classifier here (that is a later task).
|
||||
// Instead the orchestrator instructs the agent to emit a structured marker for
|
||||
// any action it would take, and we parse that marker out of the reply:
|
||||
//
|
||||
// [[ACTION:todo.create title="交方案" due="2026-06-14T18:00:00+08:00"]]
|
||||
//
|
||||
// Matched → route through the gate. No marker → ordinary Q&A, replied directly.
|
||||
// The grammar is deliberately tiny: ACTION:<verb> followed by key="value"
|
||||
// pairs. The only verb wired end-to-end in this slice is todo.create.
|
||||
|
||||
var actionMarkerRe = regexp.MustCompile(`\[\[ACTION:\s*([a-zA-Z0-9_.]+)\s*(.*?)\]\]`)
|
||||
|
||||
// actionKVRe captures key="value" (or key='value') pairs inside a marker.
|
||||
var actionKVRe = regexp.MustCompile(`([a-zA-Z0-9_]+)\s*=\s*"([^"]*)"|([a-zA-Z0-9_]+)\s*=\s*'([^']*)'`)
|
||||
|
||||
// detectedAction is one parsed [[ACTION:...]] marker: its verb (e.g.
|
||||
// "todo.create") and its key/value arguments.
|
||||
type detectedAction struct {
|
||||
Verb string
|
||||
Args map[string]string
|
||||
}
|
||||
|
||||
// parseActionMarker extracts the first [[ACTION:...]] marker from an agent
|
||||
// reply, returning (action, cleaned-reply, found). cleanedReply is the reply
|
||||
// with the marker stripped (and surrounding whitespace tidied) so it can be
|
||||
// shown to a human without the machine syntax leaking through.
|
||||
func parseActionMarker(reply string) (detectedAction, string, bool) {
|
||||
loc := actionMarkerRe.FindStringSubmatchIndex(reply)
|
||||
if loc == nil {
|
||||
return detectedAction{}, reply, false
|
||||
}
|
||||
verb := strings.TrimSpace(reply[loc[2]:loc[3]])
|
||||
argStr := reply[loc[4]:loc[5]]
|
||||
act := detectedAction{Verb: verb, Args: map[string]string{}}
|
||||
for _, m := range actionKVRe.FindAllStringSubmatch(argStr, -1) {
|
||||
if m[1] != "" {
|
||||
act.Args[m[1]] = m[2]
|
||||
} else if m[3] != "" {
|
||||
act.Args[m[3]] = m[4]
|
||||
}
|
||||
}
|
||||
cleaned := strings.TrimSpace(reply[:loc[0]] + reply[loc[1]:])
|
||||
return act, cleaned, true
|
||||
}
|
||||
|
||||
// toPlannedAction maps a detected marker onto the structured command the gate
|
||||
// will execute. It returns (action, summary, ok): ok is false for an unknown or
|
||||
// malformed verb (e.g. todo.create without a title), so the orchestrator can
|
||||
// fall back to a plain reply instead of submitting a no-op approval.
|
||||
//
|
||||
// ownerStaffID is the todo executor (the owner is who the reminder is for); the
|
||||
// slice intentionally creates the todo for the owner, matching "create a todo
|
||||
// for me" where "me" is the digital-twin owner.
|
||||
func toPlannedAction(act detectedAction, ownerStaffID string) (plannedAction, string, bool) {
|
||||
switch act.Verb {
|
||||
case "todo.create":
|
||||
title := strings.TrimSpace(act.Args["title"])
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(act.Args["subject"])
|
||||
}
|
||||
if title == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
vo := map[string]any{
|
||||
"subject": title,
|
||||
"executorIds": []string{ownerStaffID},
|
||||
}
|
||||
summary := fmt.Sprintf("创建待办:%s", title)
|
||||
if due := strings.TrimSpace(act.Args["due"]); due != "" {
|
||||
if ms, err := parseDueToMillis(due); err == nil {
|
||||
vo["dueTime"] = ms
|
||||
summary += fmt.Sprintf("(截止 %s)", due)
|
||||
}
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
LegacyPath: "todo task create",
|
||||
Params: map[string]any{"PersonalTodoCreateVO": vo},
|
||||
}
|
||||
return pa, summary, true
|
||||
|
||||
case "calendar.create":
|
||||
title := firstNonEmpty(act.Args["title"], act.Args["summary"])
|
||||
start := strings.TrimSpace(act.Args["start"])
|
||||
end := strings.TrimSpace(act.Args["end"])
|
||||
if title == "" || start == "" || end == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "calendar",
|
||||
Tool: "create_calendar_event",
|
||||
LegacyPath: "calendar event create",
|
||||
Params: map[string]any{
|
||||
"summary": title,
|
||||
"startDateTime": start,
|
||||
"endDateTime": end,
|
||||
},
|
||||
}
|
||||
return pa, fmt.Sprintf("创建日程:%s(%s ~ %s)", title, start, end), true
|
||||
|
||||
case "doc.create":
|
||||
name := firstNonEmpty(act.Args["name"], act.Args["title"])
|
||||
if name == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "doc",
|
||||
Tool: "create_document",
|
||||
LegacyPath: "doc create",
|
||||
Params: map[string]any{"name": name},
|
||||
}
|
||||
return pa, fmt.Sprintf("创建文档:%s", name), true
|
||||
|
||||
default:
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first trimmed non-empty string among the args.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// approveWords / rejectWords are the whole-message replies the text-approval
|
||||
// mode treats as the owner's decision. The match is on the ENTIRE trimmed
|
||||
// message (case-insensitive) so a casual mention ("同意他的看法") never
|
||||
// accidentally approves an action — only a bare "同意" / "拒绝" decides.
|
||||
var (
|
||||
approveWords = map[string]struct{}{
|
||||
"同意": {}, "通过": {}, "批准": {}, "确认": {}, "同意执行": {},
|
||||
"approve": {}, "ok": {}, "yes": {}, "y": {},
|
||||
}
|
||||
rejectWords = map[string]struct{}{
|
||||
"拒绝": {}, "驳回": {}, "不行": {}, "不同意": {}, "取消": {}, "否决": {},
|
||||
"reject": {}, "no": {}, "n": {},
|
||||
}
|
||||
)
|
||||
|
||||
// retryWords are the whole-message replies that flush the deferred backlog
|
||||
// (the owner has recovered — e.g. logged dws in as themselves — and wants the
|
||||
// held requests replayed).
|
||||
var retryWords = map[string]struct{}{
|
||||
"重试": {}, "恢复": {}, "补做": {}, "重新执行": {}, "继续": {},
|
||||
"retry": {}, "resume": {},
|
||||
}
|
||||
|
||||
// isRetryWord reports whether a whole message is a bare "flush the backlog"
|
||||
// command.
|
||||
func isRetryWord(msg string) bool {
|
||||
_, ok := retryWords[strings.ToLower(strings.TrimSpace(msg))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// parseDecisionWord classifies a whole message as an approve/reject decision.
|
||||
// It returns (approve, ok): ok is false when the message is not a bare decision
|
||||
// keyword, so the caller forwards it to the agent as an ordinary message.
|
||||
func parseDecisionWord(msg string) (approve bool, ok bool) {
|
||||
m := strings.ToLower(strings.TrimSpace(msg))
|
||||
if _, yes := approveWords[m]; yes {
|
||||
return true, true
|
||||
}
|
||||
if _, no := rejectWords[m]; no {
|
||||
return false, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// classifyPlannedAction maps a planned command onto its read/write class so the
|
||||
// gate can honor the "写类才拦" design: only a write (or Unknown — per the
|
||||
// CmdClass safety contract) action needs the owner's sign-off; a read-class
|
||||
// action is safe to let through without gating.
|
||||
//
|
||||
// It classifies on the human command path (LegacyPath, e.g. "todo task create")
|
||||
// first, since that is exactly the space-joined segment shape ClassifyDwsCommand
|
||||
// expects. When LegacyPath is absent or yields Unknown, it falls back to the
|
||||
// product + RPC tool name (e.g. "todo" + "create_personal_todo"), whose leading
|
||||
// verb token ("create") the classifier can still recognise.
|
||||
func classifyPlannedAction(pa plannedAction) CmdClass {
|
||||
if path := strings.TrimSpace(pa.LegacyPath); path != "" {
|
||||
if c := ClassifyDwsCommand(strings.Fields(path)...); c != CmdClassUnknown {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ClassifyDwsCommand(pa.Product, pa.Tool)
|
||||
}
|
||||
|
||||
// parseDueToMillis converts an ISO-8601 due string to epoch millis, accepting
|
||||
// the common RFC3339 shapes the agent is asked to emit. Kept local to the gate
|
||||
// so detection has no dependency on the cobra todo command's flag plumbing.
|
||||
func parseDueToMillis(due string) (int64, error) {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, due); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unrecognized due time %q", due)
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// This file wires the confirmation gate to DingTalk:
|
||||
// - approvalCardSender: the dependency-injected boundary that delivers the
|
||||
// [Approve]/[Reject] card to the owner and updates it with the outcome.
|
||||
// Tests inject a fake; production uses dingtalkApprovalCardSender (HTTP).
|
||||
// - approvalOrchestrator: the connector-side glue. It owns the gate + sender +
|
||||
// runner, decides whether an agent reply is an action request, drives the
|
||||
// Submit → card → Await → execute → reply loop, and routes button callbacks
|
||||
// into gate.Decide. connect_stream.go calls into it; it never imports stream
|
||||
// internals back, so it stays unit-testable without a live connection.
|
||||
|
||||
// Button action constants. The card's two buttons each carry a fixed action
|
||||
// id; the approval id and the decision travel as private action params so a
|
||||
// callback can be associated and resolved without any server-side lookup table.
|
||||
const (
|
||||
approvalActionApprove = "dws_approval_approve"
|
||||
approvalActionReject = "dws_approval_reject"
|
||||
approvalParamID = "dwsApprovalId" // approval request id
|
||||
approvalParamDecision = "dwsDecision" // "approve" | "reject"
|
||||
approvalDecisionApprove = "approve"
|
||||
approvalDecisionReject = "reject"
|
||||
)
|
||||
|
||||
// approvalCardSender delivers and updates the owner-facing confirmation card.
|
||||
// It is an interface so the orchestrator and its tests never touch the network:
|
||||
// the real sender talks to the DingTalk card API, the fake records calls.
|
||||
type approvalCardSender interface {
|
||||
// SendApprovalCard delivers an interactive card with [Approve]/[Reject]
|
||||
// buttons (each carrying req.ID + its decision in the action params) to the
|
||||
// owner's 1:1 chat with the bot, returning the delivered card instance id
|
||||
// (outTrackId) for later result updates and callback association.
|
||||
SendApprovalCard(ctx context.Context, ownerUserID string, req *ApprovalRequest) (string, error)
|
||||
// UpdateApprovalCard replaces the card body with the final outcome text
|
||||
// (best-effort; an error must not fail the surrounding flow).
|
||||
UpdateApprovalCard(ctx context.Context, outTrackID, text string) error
|
||||
}
|
||||
|
||||
// approvalRunner is the subset of executor.Runner the orchestrator needs. Kept
|
||||
// as a named alias so the dependency is explicit and easy to fake.
|
||||
type approvalRunner interface {
|
||||
Run(context.Context, executor.Invocation) (executor.Result, error)
|
||||
}
|
||||
|
||||
// approvalReplier abstracts "send a plain text line back into the conversation"
|
||||
// (the group reply after approve/reject). connect_stream.go supplies a closure
|
||||
// over the chatbot replier + sessionWebhook; tests supply a recorder.
|
||||
type approvalReplier func(ctx context.Context, convID, text string) error
|
||||
|
||||
// ownerNotifier sends a proactive 1:1 message to specific users (the owner, the
|
||||
// requester), independent of any inbound sessionWebhook. Text approval needs it
|
||||
// because the approval conversation (owner's 1:1 with the bot) is not the
|
||||
// conversation the request arrived on, and because the decision can land minutes
|
||||
// later when the original webhook is stale. *aiCardClient implements it.
|
||||
type ownerNotifier interface {
|
||||
sendOTOText(ctx context.Context, userIDs []string, text string) error
|
||||
}
|
||||
|
||||
// auditSink records a terminal-state request to a durable, reviewable place
|
||||
// (e.g. a DingTalk online sheet) so every action the twin takes is auditable
|
||||
// beyond the local approvals JSON. Best-effort: a sink failure must never block
|
||||
// or fail the action.
|
||||
type auditSink interface {
|
||||
record(ctx context.Context, req *ApprovalRequest)
|
||||
}
|
||||
|
||||
// sheetAuditSink appends one row per action to a DingTalk online sheet (axls)
|
||||
// via the sheet `append_rows` tool, run under the connector's (bot) identity.
|
||||
// Columns: 时间 | 摘要 | 请求人 | 状态 | 批准人 | 是否自动 | 失败原因 | 单据ID.
|
||||
type sheetAuditSink struct {
|
||||
runner approvalRunner
|
||||
nodeID string // axls doc id / URL
|
||||
sheetID string // worksheet id or name (e.g. "Sheet1")
|
||||
}
|
||||
|
||||
func (s *sheetAuditSink) record(ctx context.Context, req *ApprovalRequest) {
|
||||
if s == nil || s.runner == nil || req == nil {
|
||||
return
|
||||
}
|
||||
auto := ""
|
||||
if req.AutoApproved {
|
||||
auto = "自动"
|
||||
}
|
||||
decidedAt := ""
|
||||
if !req.DecidedAt.IsZero() {
|
||||
decidedAt = req.DecidedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
row := []any{
|
||||
req.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
req.Summary,
|
||||
req.Requester,
|
||||
string(req.State),
|
||||
req.DecidedBy,
|
||||
auto,
|
||||
req.ExecErr,
|
||||
req.ID,
|
||||
decidedAt,
|
||||
}
|
||||
inv := executor.NewHelperInvocation("sheet append", "sheet", "append_rows", map[string]any{
|
||||
"nodeId": s.nodeID,
|
||||
"sheetId": s.sheetID,
|
||||
"values": [][]any{row},
|
||||
})
|
||||
// The online-sheet API throttles (e.g. THREADPOOL_BUSY) under bursts, dropping
|
||||
// an audit row on a transient error. Retry a few times with backoff so the
|
||||
// trail stays complete; still best-effort — give up (log) after the last try.
|
||||
var err error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if _, err = s.runner.Run(ctx, inv); err == nil {
|
||||
return
|
||||
}
|
||||
if attempt < 3 && isTransientSheetErr(err) {
|
||||
time.Sleep(time.Duration(attempt) * 600 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][audit] 写审计表格失败(已重试)approvalId=%s: %v\n", req.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// isTransientSheetErr reports whether a sheet-append error is a transient
|
||||
// throttle/timeout worth retrying (vs. a permanent error like a bad node id).
|
||||
func isTransientSheetErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToUpper(err.Error())
|
||||
for _, sig := range []string{"BUSY", "THREADPOOL", "TIMEOUT", "RATE", "LIMIT", "TOO MANY", "503", "429"} {
|
||||
if strings.Contains(s, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approvalOrchestrator is the connector-side controller for the gate. It is
|
||||
// constructed per connector with the owner's userId, the gate, the card sender
|
||||
// and the runner. Zero owner / nil sender disables the gate (the connector then
|
||||
// behaves exactly as before — plain Q&A).
|
||||
type approvalOrchestrator struct {
|
||||
gate *approvalGate
|
||||
sender approvalCardSender
|
||||
runner approvalRunner
|
||||
ownerUserID string
|
||||
awaitWindow time.Duration
|
||||
// textMode swaps the interactive [Approve]/[Reject] card for a private
|
||||
// text confirmation: the bot DMs the OWNER "X 请求执行……" and the owner
|
||||
// replies "同意"/"拒绝" in their own 1:1 chat with the bot. The requester
|
||||
// never sees the approval — they only get the final result. Used when no
|
||||
// approval-card template is configured, so the gate works with zero
|
||||
// card-platform setup. The decision is captured asynchronously (see
|
||||
// handleOwnerDecision), not by blocking on Await.
|
||||
textMode bool
|
||||
// notifier delivers the proactive owner/requester 1:1 messages text mode
|
||||
// needs (nil in card mode).
|
||||
notifier ownerNotifier
|
||||
// audit, when set, records every terminal-state request to a durable sink
|
||||
// (e.g. a DingTalk online sheet) on top of the local approvals JSON. nil =
|
||||
// local-file audit only.
|
||||
audit auditSink
|
||||
// allowedScopes is the role's capability allowlist (product names). When
|
||||
// non-empty, an action whose product is not listed is refused before it ever
|
||||
// reaches the gate, keeping the role in its lane. Empty = no restriction.
|
||||
allowedScopes []string
|
||||
// confirmPolicy governs how OTHERS' requests are confirmed: "manual" (ask
|
||||
// every time, the default), "auto" (run without asking, still audited),
|
||||
// "remember" (ask once per action verb then reuse). The owner's own requests
|
||||
// always auto-run regardless.
|
||||
confirmPolicy string
|
||||
// remembered caches the owner's decision per action verb for the "remember"
|
||||
// policy (verb → approved). Guarded by rememberMu.
|
||||
rememberMu sync.Mutex
|
||||
remembered map[string]bool
|
||||
}
|
||||
|
||||
// gateDecision decides how to handle a write action: "auto" (run now), "ask"
|
||||
// (route to the owner), or "reject" (a remembered rejection). The owner's own
|
||||
// requests always auto-run. For others it follows confirmPolicy.
|
||||
func (o *approvalOrchestrator) gateDecision(requester, verb string) string {
|
||||
if strings.TrimSpace(requester) != "" && strings.TrimSpace(requester) == o.ownerUserID {
|
||||
return "auto"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(o.confirmPolicy)) {
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "remember":
|
||||
o.rememberMu.Lock()
|
||||
decided, ok := o.remembered[verb]
|
||||
o.rememberMu.Unlock()
|
||||
if !ok {
|
||||
return "ask"
|
||||
}
|
||||
if decided {
|
||||
return "auto"
|
||||
}
|
||||
return "reject"
|
||||
default: // manual / empty
|
||||
return "ask"
|
||||
}
|
||||
}
|
||||
|
||||
// rememberDecision records the owner's decision for an action verb so the
|
||||
// "remember" policy can reuse it. No-op unless the policy is "remember".
|
||||
func (o *approvalOrchestrator) rememberDecision(verb string, approved bool) {
|
||||
if strings.ToLower(strings.TrimSpace(o.confirmPolicy)) != "remember" || strings.TrimSpace(verb) == "" {
|
||||
return
|
||||
}
|
||||
o.rememberMu.Lock()
|
||||
if o.remembered == nil {
|
||||
o.remembered = make(map[string]bool)
|
||||
}
|
||||
o.remembered[verb] = approved
|
||||
o.rememberMu.Unlock()
|
||||
}
|
||||
|
||||
// scopeAllows reports whether the role may use the given product. An empty
|
||||
// allowlist means no restriction (allow all).
|
||||
func (o *approvalOrchestrator) scopeAllows(product string) bool {
|
||||
if len(o.allowedScopes) == 0 {
|
||||
return true
|
||||
}
|
||||
product = strings.ToLower(strings.TrimSpace(product))
|
||||
for _, s := range o.allowedScopes {
|
||||
if strings.ToLower(strings.TrimSpace(s)) == product {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recordAudit writes the request's current (terminal) state to the audit sink,
|
||||
// fetching the fresh snapshot so state/exec_err reflect the final outcome.
|
||||
// No-op when no sink is configured; best-effort.
|
||||
func (o *approvalOrchestrator) recordAudit(ctx context.Context, id string) {
|
||||
if o == nil || o.audit == nil {
|
||||
return
|
||||
}
|
||||
if r := o.gate.Get(id); r != nil {
|
||||
o.audit.record(ctx, r)
|
||||
}
|
||||
}
|
||||
|
||||
// newApprovalOrchestrator builds the card-mode controller. awaitWindow caps how
|
||||
// long the connector blocks for the owner's decision before giving up (the
|
||||
// request stays Pending on disk, so a late tap still records and executes
|
||||
// nothing twice).
|
||||
func newApprovalOrchestrator(gate *approvalGate, sender approvalCardSender, runner approvalRunner, ownerUserID string) *approvalOrchestrator {
|
||||
return &approvalOrchestrator{
|
||||
gate: gate,
|
||||
sender: sender,
|
||||
runner: runner,
|
||||
ownerUserID: strings.TrimSpace(ownerUserID),
|
||||
awaitWindow: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// newTextApprovalOrchestrator builds the text-mode controller (no card sender).
|
||||
// notifier delivers the private owner/requester messages. The owner confirms by
|
||||
// replying "同意"/"拒绝" in their 1:1 chat with the bot; the decision is captured
|
||||
// by handleOwnerDecision on the next inbound message, so handleReply must NOT
|
||||
// block waiting for it.
|
||||
func newTextApprovalOrchestrator(gate *approvalGate, runner approvalRunner, ownerUserID string, notifier ownerNotifier) *approvalOrchestrator {
|
||||
o := newApprovalOrchestrator(gate, nil, runner, ownerUserID)
|
||||
o.textMode = true
|
||||
o.notifier = notifier
|
||||
return o
|
||||
}
|
||||
|
||||
// enabled reports whether the gate is active. Card mode needs an owner + a card
|
||||
// sender; text mode needs an owner + a notifier to reach them privately.
|
||||
func (o *approvalOrchestrator) enabled() bool {
|
||||
if o == nil || o.ownerUserID == "" || o.gate == nil {
|
||||
return false
|
||||
}
|
||||
if o.textMode {
|
||||
return o.notifier != nil
|
||||
}
|
||||
return o.sender != nil
|
||||
}
|
||||
|
||||
// agentSystemHint is appended to the forwarded prompt so the agent emits the
|
||||
// structured action marker (see parseActionMarker) instead of executing
|
||||
// anything itself. It is the simplified "intent detection" for the slice.
|
||||
const agentSystemHint = "\n\n[系统提示] 如果用户的请求是要执行一个动作(而不是单纯提问)," +
|
||||
"不要假装已经完成,而是在回复末尾用下面其中一个标记声明这个动作,由主人确认后再执行:\n" +
|
||||
`· 建待办:[[ACTION:todo.create title="待办标题" due="2026-06-14T18:00:00+08:00"]](due 可省略)` + "\n" +
|
||||
`· 建日程:[[ACTION:calendar.create title="日程标题" start="2026-06-20T10:00:00+08:00" end="2026-06-20T11:00:00+08:00"]]` + "\n" +
|
||||
`· 建文档:[[ACTION:doc.create name="文档名"]]` + "\n" +
|
||||
"时间一律用 ISO-8601(带时区)。如果只是普通提问,正常回答即可,不要输出任何标记。"
|
||||
|
||||
// decorateForActionDetection appends the action-marker instruction to a prompt
|
||||
// when the gate is enabled. A no-op when disabled, so plain Q&A is untouched.
|
||||
func (o *approvalOrchestrator) decorateForActionDetection(prompt string) string {
|
||||
if !o.enabled() {
|
||||
return prompt
|
||||
}
|
||||
return prompt + agentSystemHint
|
||||
}
|
||||
|
||||
// handleReply is the orchestration hook the connector calls with the agent's
|
||||
// raw reply. It returns (finalReply, handled): when handled is true the gate
|
||||
// took over (it has already sent the card / will reply asynchronously via
|
||||
// reply) and the connector must NOT send finalReply itself. When false the
|
||||
// connector replies normally with finalReply (the marker, if any, is stripped).
|
||||
//
|
||||
// requester is the staffId who asked; convID is where the group answer goes;
|
||||
// reply is the function used to post the approve/reject outcome back.
|
||||
func (o *approvalOrchestrator) handleReply(ctx context.Context, requester, convID, agentReply string, reply approvalReplier) (string, bool) {
|
||||
if !o.enabled() {
|
||||
return agentReply, false
|
||||
}
|
||||
act, cleaned, found := parseActionMarker(agentReply)
|
||||
if !found {
|
||||
return agentReply, false // plain Q&A
|
||||
}
|
||||
pa, summary, ok := toPlannedAction(act, o.ownerUserID)
|
||||
if !ok {
|
||||
// Unknown/blank action: degrade to a plain reply (marker stripped) so a
|
||||
// malformed marker never silently swallows the answer.
|
||||
return cleaned, false
|
||||
}
|
||||
|
||||
// Role scope: an action outside the role's capability allowlist is refused
|
||||
// up front (never reaches the gate or the owner). The requester is told it is
|
||||
// out of lane — this is a capability boundary, not an approval, so it is fine
|
||||
// to surface to them.
|
||||
if !o.scopeAllows(pa.Product) {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 越权拦截:角色无 %q 能力,拒绝动作 %s\n", pa.Product, summary)
|
||||
return fmt.Sprintf("(该数字员工没有「%s」能力,无法执行此操作:%s)", pa.Product, summary), false
|
||||
}
|
||||
|
||||
// 写类才拦:route the gate-or-not decision through the read/write classifier
|
||||
// instead of gating every detected marker. A definitively read-class action
|
||||
// is safe and bypasses the gate (the marker is stripped and the reply goes
|
||||
// out normally). Write — and Unknown, per the CmdClass safety contract — keep
|
||||
// the owner's sign-off requirement.
|
||||
if classifyPlannedAction(pa) == CmdClassRead {
|
||||
return cleaned, false
|
||||
}
|
||||
|
||||
// Confirmation strategy: owner-self always auto-runs; others follow
|
||||
// confirmPolicy (manual=ask / auto=run / remember=reuse last decision).
|
||||
decision := o.gateDecision(requester, act.Verb)
|
||||
req := o.gate.Submit(ApprovalRequest{
|
||||
Requester: requester,
|
||||
ConvID: convID,
|
||||
Summary: summary,
|
||||
Verb: act.Verb,
|
||||
Action: pa,
|
||||
AutoApproved: decision == "auto",
|
||||
})
|
||||
|
||||
switch decision {
|
||||
case "auto":
|
||||
// Asking IS the authorization (owner-self), or the policy pre-authorized
|
||||
// it (auto / remembered-approve). Execute directly; still fully audited.
|
||||
return o.autoApproveAndExecute(ctx, req, convID, reply)
|
||||
case "reject":
|
||||
// Remembered rejection for this action kind — decline without bothering
|
||||
// the owner, and tell the requester.
|
||||
o.gate.Decide(req.ID, false, o.ownerUserID)
|
||||
o.recordAudit(ctx, req.ID)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 记忆策略:%q 此前被拒,自动拒绝 approvalId=%s\n", act.Verb, req.ID)
|
||||
return fmt.Sprintf("(主人此前已拒绝这类操作,本次未执行:%s)", summary), false
|
||||
default: // "ask"
|
||||
// Text mode: privately DM the owner and return immediately — the owner's
|
||||
// "同意/拒绝" reply is captured asynchronously by handleOwnerDecision
|
||||
// (blocking here would deadlock the per-conversation worker that must also
|
||||
// process that reply). The requester (this conversation) sees nothing.
|
||||
if o.textMode {
|
||||
return o.handleReplyText(ctx, req)
|
||||
}
|
||||
return o.handleReplyCard(ctx, req, convID, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// autoApproveAndExecute runs an owner's own request without a second
|
||||
// confirmation, while still recording the full lifecycle (Decide by the owner →
|
||||
// execute) so the on-disk approvals log audits it like any other action. It
|
||||
// replies into the conversation the owner asked in (the inbound webhook is still
|
||||
// fresh — the owner just messaged). Used for both card and text mode.
|
||||
func (o *approvalOrchestrator) autoApproveAndExecute(ctx context.Context, req *ApprovalRequest, convID string, reply approvalReplier) (string, bool) {
|
||||
decided, _ := o.gate.Decide(req.ID, true, o.ownerUserID)
|
||||
if decided == nil {
|
||||
decided = req
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][audit] 主人自助操作,自动执行 approvalId=%s owner=%s: %s\n",
|
||||
decided.ID, o.ownerUserID, decided.Summary)
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
// Do not lose it: hold for retry and tell the owner to recover. Most
|
||||
// likely the connector's dws login is not the bot owner, so an
|
||||
// owner-scoped action can't run yet.
|
||||
o.gate.markDeferred(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
o.notifyOwnerDeferred(ctx, decided, execErr)
|
||||
if reply != nil {
|
||||
_ = reply(ctx, convID, "收到,但现在没能直接完成(已记下,稍后会补做)。")
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
if reply != nil {
|
||||
_ = reply(ctx, convID, "已为你完成:"+decided.Summary+"\n"+out)
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// notifyOwnerDeferred privately tells the owner that an action could not run now
|
||||
// and is being held, with the likely cause and how to recover (log dws in as the
|
||||
// bot owner, then reply "重试"). Best-effort.
|
||||
func (o *approvalOrchestrator) notifyOwnerDeferred(ctx context.Context, req *ApprovalRequest, execErr error) {
|
||||
if o.notifier == nil {
|
||||
return
|
||||
}
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" || who == o.ownerUserID {
|
||||
who = "你"
|
||||
}
|
||||
msg := fmt.Sprintf("⚠️ %s 请求执行:%s\n但我现在没能以你的身份完成(很可能这台连接器的 dws 没有登录成你本人的账号)。\n"+
|
||||
"已先记下,不会丢。把 dws 登录成机器人主人账号后会自动补做;想立即补做可回复「重试」。\n(错误:%s)",
|
||||
who, req.Summary, truncateRunes(execErr.Error(), 120))
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, msg)
|
||||
}
|
||||
|
||||
// handleReplyText privately DMs the OWNER the approval request and hands control
|
||||
// back to the connector (handled=true). The requester sees nothing — the
|
||||
// approval happens only between the owner and the bot. It never blocks: the
|
||||
// owner's decision arrives later as an inbound message routed through
|
||||
// handleOwnerDecision.
|
||||
func (o *approvalOrchestrator) handleReplyText(ctx context.Context, req *ApprovalRequest) (string, bool) {
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" {
|
||||
who = "有人"
|
||||
}
|
||||
prompt := fmt.Sprintf("🔔 %s 请求执行一个操作,需要你确认:\n%s\n回复「同意」执行,或「拒绝」取消。", who, req.Summary)
|
||||
if err := o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, prompt); err != nil {
|
||||
// Cannot reach the owner: the action does not run. Do NOT leak the
|
||||
// approval to the requester — text mode's whole point is privacy — so
|
||||
// just log and swallow (handled), leaving the request Pending on disk.
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 私聊主人 %s 失败,本次未执行: %v\n", o.ownerUserID, err)
|
||||
return "", true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 已私聊主人 %s 待确认 approvalId=%s requester=%s: %s\n",
|
||||
o.ownerUserID, req.ID, req.Requester, req.Summary)
|
||||
return "", true
|
||||
}
|
||||
|
||||
// handleOwnerDecision intercepts an inbound message that is the OWNER replying
|
||||
// "同意/拒绝" (in their own 1:1 chat with the bot) to the pending request. It
|
||||
// decides, executes-or-declines, privately acks the owner, and sends the
|
||||
// outcome to the original requester. Returns true when it consumed the message
|
||||
// (so the connector skips forwarding it to the agent). A non-owner sender, a
|
||||
// non-decision message, or no pending request all return false (ordinary
|
||||
// message). Only active in text mode.
|
||||
func (o *approvalOrchestrator) handleOwnerDecision(ctx context.Context, senderStaffID, text string) bool {
|
||||
if o == nil || !o.textMode || !o.enabled() {
|
||||
return false
|
||||
}
|
||||
if senderStaffID == "" || senderStaffID != o.ownerUserID {
|
||||
return false
|
||||
}
|
||||
// "重试/恢复": the owner has recovered — flush the deferred backlog.
|
||||
if isRetryWord(text) {
|
||||
return o.flushDeferred(ctx)
|
||||
}
|
||||
approve, ok := parseDecisionWord(text)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
req := o.gate.latestPending()
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
decided, deciding := o.gate.Decide(req.ID, approve, senderStaffID)
|
||||
if !deciding {
|
||||
// Already decided (e.g. a double reply): consume the keyword, do not
|
||||
// re-execute.
|
||||
return decided != nil
|
||||
}
|
||||
// "remember" policy: cache this decision for the action kind so the next
|
||||
// same-verb request reuses it without asking again.
|
||||
o.rememberDecision(decided.Verb, approve)
|
||||
if !decided.approved() {
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "好的,已拒绝,未执行。")
|
||||
o.notifyRequester(ctx, decided, "你的请求未获主人批准,本次未执行。")
|
||||
return true
|
||||
}
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
// Approved but could not run now → hold for retry, tell the owner how to
|
||||
// recover. The requester is NOT told it failed (their task is not lost).
|
||||
o.gate.markDeferred(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
o.notifyOwnerDeferred(ctx, decided, execErr)
|
||||
return true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "已同意并执行完成:"+decided.Summary)
|
||||
o.notifyRequester(ctx, decided, "已为你完成:"+decided.Summary+"\n"+out)
|
||||
return true
|
||||
}
|
||||
|
||||
// flushDeferred replays the deferred backlog after the owner recovers. Each
|
||||
// request is re-executed; on success it is marked executed and the original
|
||||
// requester gets the outcome, on failure it stays deferred (still not lost).
|
||||
// The owner gets a summary. Returns true (the "重试" message is always consumed).
|
||||
func (o *approvalOrchestrator) flushDeferred(ctx context.Context) bool {
|
||||
pending := o.gate.allDeferred()
|
||||
if len(pending) == 0 {
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "当前没有积压的请求。")
|
||||
return true
|
||||
}
|
||||
done, stuck := o.flushDeferredOnce(ctx, pending)
|
||||
// Manual "重试" always reports back (the owner asked), naming what was done.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "已补做 %d 个积压请求", len(done))
|
||||
if len(done) > 0 {
|
||||
b.WriteString(":\n")
|
||||
for i, s := range done {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, s)
|
||||
}
|
||||
} else {
|
||||
b.WriteString("。")
|
||||
}
|
||||
if stuck > 0 {
|
||||
fmt.Fprintf(&b, "仍有 %d 个未成功(身份可能还没对上,确认 dws 登录后再回复「重试」)。", stuck)
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, strings.TrimRight(b.String(), "\n"))
|
||||
return true
|
||||
}
|
||||
|
||||
// flushDeferredOnce re-executes each given deferred request once. Successful
|
||||
// ones are marked executed and their requester is notified; failed ones stay
|
||||
// deferred. Returns the completed summaries and the still-stuck count. Shared by
|
||||
// the manual "重试" path and the background auto-retry.
|
||||
func (o *approvalOrchestrator) flushDeferredOnce(ctx context.Context, pending []*ApprovalRequest) (done []string, stuck int) {
|
||||
for _, req := range pending {
|
||||
out, execErr := o.execute(ctx, req)
|
||||
if execErr != nil {
|
||||
o.gate.markDeferred(req.ID, execErr.Error())
|
||||
o.recordAudit(ctx, req.ID)
|
||||
stuck++
|
||||
continue
|
||||
}
|
||||
o.gate.markExecuted(req.ID)
|
||||
o.recordAudit(ctx, req.ID)
|
||||
o.notifyRequester(ctx, req, "(已恢复)已为你完成:"+req.Summary+"\n"+out)
|
||||
done = append(done, req.Summary)
|
||||
}
|
||||
return done, stuck
|
||||
}
|
||||
|
||||
// autoFlushDeferred is the background-retry pass: it replays the backlog and
|
||||
// messages the owner ONLY when something actually completed (so a periodic tick
|
||||
// while the identity is still wrong stays silent — no spam). The requester is
|
||||
// still notified per completed item inside flushDeferredOnce.
|
||||
func (o *approvalOrchestrator) autoFlushDeferred(ctx context.Context) {
|
||||
pending := o.gate.allDeferred()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
done, _ := o.flushDeferredOnce(ctx, pending)
|
||||
if len(done) == 0 {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "(已自动恢复)补做了 %d 个积压请求:\n", len(done))
|
||||
for i, s := range done {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, s)
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, strings.TrimRight(b.String(), "\n"))
|
||||
}
|
||||
|
||||
// startAutoRetry runs autoFlushDeferred on an interval until ctx is cancelled,
|
||||
// so a deferred backlog drains by itself once the owner's identity comes back —
|
||||
// no manual "重试" needed. A no-op when there is no notifier (card mode) or a
|
||||
// non-positive interval.
|
||||
func (o *approvalOrchestrator) startAutoRetry(ctx context.Context, interval time.Duration) {
|
||||
if o == nil || o.notifier == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
o.autoFlushDeferred(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// notifyRequester sends the outcome to the original requester, unless they are
|
||||
// the owner themselves (who already got the owner-side ack — no need to double
|
||||
// message). Best-effort.
|
||||
func (o *approvalOrchestrator) notifyRequester(ctx context.Context, req *ApprovalRequest, text string) {
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" || who == o.ownerUserID {
|
||||
return
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{who}, text)
|
||||
}
|
||||
|
||||
// handleReplyCard runs the interactive-card flow: deliver the [Approve]/[Reject]
|
||||
// card to the owner, block for the decision, then execute-or-decline and report
|
||||
// the outcome into the conversation.
|
||||
func (o *approvalOrchestrator) handleReplyCard(ctx context.Context, req *ApprovalRequest, convID string, reply approvalReplier) (string, bool) {
|
||||
outTrackID, err := o.sender.SendApprovalCard(ctx, o.ownerUserID, req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 确认卡片投放失败: %v\n", err)
|
||||
// Cannot ask the owner → tell the requester the gate could not engage,
|
||||
// rather than silently executing or silently dropping.
|
||||
return "(需要主人确认,但确认卡片发送失败,本次未执行)", false
|
||||
}
|
||||
o.gate.setOutTrackID(req.ID, outTrackID)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 已向主人 %s 发送确认卡片 approvalId=%s outTrackId=%s: %s\n",
|
||||
o.ownerUserID, req.ID, outTrackID, req.Summary)
|
||||
|
||||
// Block for the owner's decision off the connector's per-conversation
|
||||
// worker is fine: the worker already serializes one conversation, and the
|
||||
// gate persists so a restart mid-wait loses only the in-memory block.
|
||||
decided, gotDecision := o.gate.Await(ctx, req.ID, o.awaitWindow)
|
||||
if !gotDecision || decided == nil {
|
||||
_ = reply(ctx, convID, "(等待主人确认超时,本次未执行)")
|
||||
return "", true
|
||||
}
|
||||
if !decided.approved() {
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已拒绝:未执行。")
|
||||
_ = reply(ctx, convID, "主人暂时没批准。")
|
||||
return "", true
|
||||
}
|
||||
|
||||
// Approved → the orchestrator runs the planned command directly (no agent
|
||||
// round-trip), then reports the outcome to the group and the card.
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
o.gate.markFailed(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已同意,但执行失败:"+execErr.Error())
|
||||
_ = reply(ctx, convID, "主人已同意,但执行失败:"+execErr.Error())
|
||||
return "", true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已同意并执行完成。")
|
||||
_ = reply(ctx, convID, "主人已同意,已执行:"+decided.Summary+"\n"+out)
|
||||
return "", true
|
||||
}
|
||||
|
||||
// execute runs the request's planned action via the runner and returns a short
|
||||
// human-readable result line. It is the only place the gate touches the
|
||||
// executor, so adding a new approvable verb is a matter of toPlannedAction +
|
||||
// the runner already supporting that tool.
|
||||
func (o *approvalOrchestrator) execute(ctx context.Context, req *ApprovalRequest) (string, error) {
|
||||
if o.runner == nil {
|
||||
return "", fmt.Errorf("no runner configured")
|
||||
}
|
||||
inv := executor.NewHelperInvocation(req.Action.LegacyPath, req.Action.Product, req.Action.Tool, req.Action.Params)
|
||||
res, err := o.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !res.Invocation.Implemented {
|
||||
// The helper override was not wired at runtime (e.g. dry-run / discovery
|
||||
// gap): surface it rather than claim success.
|
||||
return "(动作已受理,但运行时未实际执行——请检查命令是否在当前环境可用)", nil
|
||||
}
|
||||
return "(待办已创建)", nil
|
||||
}
|
||||
|
||||
// handleCardCallback maps a button tap to gate.Decide. It pulls the approval id
|
||||
// and decision from the action params (primary association), falling back to the
|
||||
// card instance id (OutTrackId) when the params are absent. Returns a card
|
||||
// response that flips the buttons to a decided state. Safe to call repeatedly:
|
||||
// a second tap after a decision is a no-op (Decide is idempotent).
|
||||
func (o *approvalOrchestrator) handleCardCallback(ctx context.Context, req *card.CardRequest) (*card.CardResponse, error) {
|
||||
if o == nil || o.gate == nil || req == nil {
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
id, approve, ok := decodeCardAction(req)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 卡片回调无法解析动作 outTrackId=%s\n", req.OutTrackId)
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
if id == "" {
|
||||
// No approval id in the params: associate by the card instance id.
|
||||
if found := o.gate.findByOutTrackID(req.OutTrackId); found != nil {
|
||||
id = found.ID
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 卡片回调无法关联审批 outTrackId=%s\n", req.OutTrackId)
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
decided, deciding := o.gate.Decide(id, approve, strings.TrimSpace(req.UserId))
|
||||
verb := "同意"
|
||||
if !approve {
|
||||
verb = "拒绝"
|
||||
}
|
||||
if deciding {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 主人 %s 点了[%s] approvalId=%s\n", req.UserId, verb, id)
|
||||
} else if decided != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 审批 %s 已是终态(%s),忽略重复点击\n", id, decided.State)
|
||||
}
|
||||
// Echo the decision back into the card's private data so the renderer can
|
||||
// show a decided state. Best-effort; the gate state is the source of truth.
|
||||
return &card.CardResponse{
|
||||
UserPrivateData: &card.CardDataDto{
|
||||
CardParamMap: map[string]string{"dwsDecision": verb},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeCardAction extracts (approvalId, approve, ok) from a card callback. It
|
||||
// prefers the explicit decision param, then the tapped action id, so the card
|
||||
// can carry the decision either way. ok is false when neither identifies a
|
||||
// decision (a non-approval card, or a malformed payload).
|
||||
func decodeCardAction(req *card.CardRequest) (id string, approve bool, ok bool) {
|
||||
id = strings.TrimSpace(req.GetActionString(approvalParamID))
|
||||
decision := strings.TrimSpace(req.GetActionString(approvalParamDecision))
|
||||
switch decision {
|
||||
case approvalDecisionApprove:
|
||||
return id, true, true
|
||||
case approvalDecisionReject:
|
||||
return id, false, true
|
||||
}
|
||||
// Fall back to which button was pressed (its action id).
|
||||
for _, a := range req.CardActionData.CardPrivateData.ActionIdList {
|
||||
switch strings.TrimSpace(a) {
|
||||
case approvalActionApprove:
|
||||
return id, true, true
|
||||
case approvalActionReject:
|
||||
return id, false, true
|
||||
}
|
||||
}
|
||||
return id, false, false
|
||||
}
|
||||
|
||||
// ---- Real (HTTP) card sender ----
|
||||
|
||||
// dingtalkApprovalCardSender delivers the interactive approval card via the
|
||||
// DingTalk card API, reusing aiCardClient for auth + the create/deliver/update
|
||||
// HTTP plumbing. The card template must define two buttons whose action params
|
||||
// the connector fills in (approval id + decision); see the docstring on
|
||||
// SendApprovalCard for the contract.
|
||||
type dingtalkApprovalCardSender struct {
|
||||
cli *aiCardClient
|
||||
templateID string
|
||||
}
|
||||
|
||||
// newDingtalkApprovalCardSender builds the real sender. An empty templateID
|
||||
// returns nil: without an interactive-card template the connector cannot render
|
||||
// buttons, so the caller must keep the gate disabled rather than deliver a card
|
||||
// the owner cannot act on.
|
||||
func newDingtalkApprovalCardSender(clientID, clientSecret, templateID string) approvalCardSender {
|
||||
templateID = strings.TrimSpace(templateID)
|
||||
if templateID == "" {
|
||||
return nil
|
||||
}
|
||||
return &dingtalkApprovalCardSender{
|
||||
cli: newAICardClient(clientID, clientSecret, templateID),
|
||||
templateID: templateID,
|
||||
}
|
||||
}
|
||||
|
||||
// SendApprovalCard creates an interactive card instance and delivers it to the
|
||||
// owner's 1:1 chat with the bot. The approval id and per-button decision are
|
||||
// passed in cardData.cardParamMap so the template's [Approve]/[Reject] buttons
|
||||
// echo them back as action params on tap (see decodeCardAction). Best-effort
|
||||
// result-check via callChecked surfaces a per-target deliver failure that the
|
||||
// card API hides inside an HTTP 200.
|
||||
func (s *dingtalkApprovalCardSender) SendApprovalCard(ctx context.Context, ownerUserID string, req *ApprovalRequest) (string, error) {
|
||||
outTrackID := "dws_approval_" + req.ID
|
||||
create := map[string]any{
|
||||
"cardTemplateId": s.templateID,
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
"title": "需要你确认",
|
||||
"summary": req.Summary,
|
||||
"requester": req.Requester,
|
||||
approvalParamID: req.ID,
|
||||
"approveDecision": approvalDecisionApprove,
|
||||
"rejectDecision": approvalDecisionReject,
|
||||
"approveActionId": approvalActionApprove,
|
||||
"rejectActionId": approvalActionReject,
|
||||
"config": `{"autoLayout":true}`,
|
||||
},
|
||||
},
|
||||
"callbackType": "STREAM",
|
||||
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
}
|
||||
if err := s.cli.call(ctx, http.MethodPost, "/v1.0/card/instances", create); err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliver := map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"userIdType": 1,
|
||||
"openSpaceId": "dtv1.card//IM_ROBOT." + ownerUserID,
|
||||
"imRobotOpenDeliverModel": map[string]any{
|
||||
"spaceType": "IM_ROBOT",
|
||||
"robotCode": s.cli.clientID,
|
||||
},
|
||||
}
|
||||
if err := s.cli.callChecked(ctx, http.MethodPost, "/v1.0/card/instances/deliver", deliver); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outTrackID, nil
|
||||
}
|
||||
|
||||
// UpdateApprovalCard rewrites the card's result line after a decision. It is
|
||||
// best-effort: a stuck card is bad UX but must never fail the gate flow, so
|
||||
// callers ignore the error.
|
||||
func (s *dingtalkApprovalCardSender) UpdateApprovalCard(ctx context.Context, outTrackID, text string) error {
|
||||
return s.cli.call(ctx, http.MethodPut, "/v1.0/card/instances", map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{"cardParamMap": map[string]any{"result": text, "summary": text}},
|
||||
"cardUpdateOptions": map[string]any{"updateCardDataByKey": true},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// ---- fakes (no network, no real runner) ----
|
||||
|
||||
type fakeCardSender struct {
|
||||
mu sync.Mutex
|
||||
sent []*ApprovalRequest
|
||||
updates []string
|
||||
failSend bool
|
||||
lastTrack string
|
||||
}
|
||||
|
||||
func (f *fakeCardSender) SendApprovalCard(_ context.Context, _ string, req *ApprovalRequest) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failSend {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
f.sent = append(f.sent, req)
|
||||
f.lastTrack = "track-" + req.ID
|
||||
return f.lastTrack, nil
|
||||
}
|
||||
|
||||
func (f *fakeCardSender) UpdateApprovalCard(_ context.Context, _, text string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.updates = append(f.updates, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeRunner records the invocations it is asked to run and reports them as
|
||||
// implemented (so the orchestrator treats them as executed).
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
runs []executor.Invocation
|
||||
fail bool // when true, Run returns an error (simulates identity not ready)
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runs = append(r.runs, inv)
|
||||
if r.fail {
|
||||
return executor.Result{}, context.DeadlineExceeded
|
||||
}
|
||||
inv.Implemented = true
|
||||
return executor.Result{Invocation: inv}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRunner) setFail(v bool) { r.mu.Lock(); defer r.mu.Unlock(); r.fail = v }
|
||||
|
||||
func (r *fakeRunner) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.runs) }
|
||||
|
||||
// fakeNotifier records the proactive 1:1 messages text-mode approval sends to
|
||||
// the owner and the requester.
|
||||
type fakeNotifier struct {
|
||||
mu sync.Mutex
|
||||
sent []sentMsg
|
||||
}
|
||||
|
||||
type sentMsg struct {
|
||||
to []string
|
||||
text string
|
||||
}
|
||||
|
||||
func (n *fakeNotifier) sendOTOText(_ context.Context, userIDs []string, text string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.sent = append(n.sent, sentMsg{to: append([]string(nil), userIDs...), text: text})
|
||||
return nil
|
||||
}
|
||||
|
||||
// toUser reports whether any recorded message was sent to userID.
|
||||
func (n *fakeNotifier) toUser(userID string) bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for _, m := range n.sent {
|
||||
for _, u := range m.to {
|
||||
if u == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n *fakeNotifier) count() int { n.mu.Lock(); defer n.mu.Unlock(); return len(n.sent) }
|
||||
|
||||
// cardClickReq builds a card callback as DingTalk delivers it: the tapped
|
||||
// button's action id plus its private params (approval id + decision).
|
||||
func cardClickReq(outTrackID, approvalID, decision, actionID, userID string) *card.CardRequest {
|
||||
return &card.CardRequest{
|
||||
OutTrackId: outTrackID,
|
||||
UserId: userID,
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{actionID},
|
||||
Params: map[string]any{
|
||||
approvalParamID: approvalID,
|
||||
approvalParamDecision: decision,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeCardAction verifies the action parser pulls the approval id and the
|
||||
// approve/reject decision from a button tap, via params and via the action id
|
||||
// fallback.
|
||||
func TestDecodeCardAction(t *testing.T) {
|
||||
approve := cardClickReq("t1", "appr-1", approvalDecisionApprove, approvalActionApprove, "owner")
|
||||
id, ok2, ok := decodeCardAction(approve)
|
||||
if !ok || !ok2 || id != "appr-1" {
|
||||
t.Fatalf("approve decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
reject := cardClickReq("t2", "appr-2", approvalDecisionReject, approvalActionReject, "owner")
|
||||
id, ok2, ok = decodeCardAction(reject)
|
||||
if !ok || ok2 || id != "appr-2" {
|
||||
t.Fatalf("reject decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
// Decision param missing → fall back to the tapped action id.
|
||||
fallback := &card.CardRequest{
|
||||
OutTrackId: "t3",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{approvalActionApprove},
|
||||
Params: map[string]any{approvalParamID: "appr-3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
id, ok2, ok = decodeCardAction(fallback)
|
||||
if !ok || !ok2 || id != "appr-3" {
|
||||
t.Fatalf("fallback decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
// A non-approval card → not ok.
|
||||
if _, _, ok := decodeCardAction(&card.CardRequest{OutTrackId: "x"}); ok {
|
||||
t.Fatal("non-approval card should not decode as a decision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCardCallback_DrivesDecide verifies a button callback flips the gate
|
||||
// state and that an approval id missing from params is recovered by outTrackId.
|
||||
func TestHandleCardCallback_DrivesDecide(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("c1")
|
||||
o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
|
||||
|
||||
app := gate.Submit(ApprovalRequest{Summary: "x"})
|
||||
gate.setOutTrackID(app.ID, "track-x")
|
||||
|
||||
// Callback carries no approval id in params → associate by outTrackId.
|
||||
req := &card.CardRequest{
|
||||
OutTrackId: "track-x",
|
||||
UserId: "owner",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionApprove}},
|
||||
},
|
||||
}
|
||||
if _, err := o.handleCardCallback(context.Background(), req); err != nil {
|
||||
t.Fatalf("callback err: %v", err)
|
||||
}
|
||||
if got := gate.Get(app.ID); !got.approved() {
|
||||
t.Fatalf("state = %s, want approved after approve tap", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_RejectDoesNotExecute is the reject-path guarantee: a rejected
|
||||
// approval must NOT run the planned command.
|
||||
func TestHandleReply_RejectDoesNotExecute(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("c2")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
||||
o.awaitWindow = time.Second
|
||||
|
||||
var replies []string
|
||||
var rmu sync.Mutex
|
||||
reply := func(_ context.Context, _, text string) error {
|
||||
rmu.Lock()
|
||||
replies = append(replies, text)
|
||||
rmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// The owner rejects shortly after the card is sent.
|
||||
go func() {
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
sender.mu.Lock()
|
||||
n := len(sender.sent)
|
||||
var id string
|
||||
if n > 0 {
|
||||
id = sender.sent[0].ID
|
||||
}
|
||||
sender.mu.Unlock()
|
||||
if id != "" {
|
||||
gate.Decide(id, false, "owner")
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
agentReply := `好的。[[ACTION:todo.create title="交方案"]]`
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
||||
if !handled {
|
||||
t.Fatal("an action reply must be handled by the gate")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("handled reply should return empty connector reply, got %q", out)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("reject path must NOT execute, runner ran %d times", runner.count())
|
||||
}
|
||||
if gate.Get(gate.list()[0].ID).State != approvalRejected {
|
||||
t.Fatal("request should be rejected")
|
||||
}
|
||||
rmu.Lock()
|
||||
joined := strings.Join(replies, " | ")
|
||||
rmu.Unlock()
|
||||
if !strings.Contains(joined, "没批准") {
|
||||
t.Fatalf("reject reply not posted: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToPlannedAction_Actions covers the action verbs the twin can execute:
|
||||
// each maps to the right product/tool, and missing required args degrade to
|
||||
// "not an action" (ok=false) so a malformed marker never submits a no-op.
|
||||
func TestToPlannedAction_Actions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
act detectedAction
|
||||
wantOK bool
|
||||
wantProduct string
|
||||
wantTool string
|
||||
}{
|
||||
{"todo", detectedAction{Verb: "todo.create", Args: map[string]string{"title": "交方案"}}, true, "todo", "create_personal_todo"},
|
||||
{"calendar", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会", "start": "2026-06-20T10:00:00+08:00", "end": "2026-06-20T11:00:00+08:00"}}, true, "calendar", "create_calendar_event"},
|
||||
{"calendar missing time", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会"}}, false, "", ""},
|
||||
{"doc", detectedAction{Verb: "doc.create", Args: map[string]string{"name": "周报"}}, true, "doc", "create_document"},
|
||||
{"doc missing name", detectedAction{Verb: "doc.create", Args: map[string]string{}}, false, "", ""},
|
||||
{"unknown verb", detectedAction{Verb: "drive.delete", Args: map[string]string{}}, false, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pa, summary, ok := toPlannedAction(tc.act, "owner")
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok=%v want %v", ok, tc.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pa.Product != tc.wantProduct || pa.Tool != tc.wantTool {
|
||||
t.Fatalf("got %s/%s want %s/%s", pa.Product, pa.Tool, tc.wantProduct, tc.wantTool)
|
||||
}
|
||||
if summary == "" {
|
||||
t.Fatal("summary should not be empty for a valid action")
|
||||
}
|
||||
// Every twin action is write-class → must be gated.
|
||||
if classifyPlannedAction(pa) != CmdClassWrite {
|
||||
t.Fatalf("%s should classify as write (gated), got %s", tc.name, classifyPlannedAction(pa))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPlannedAction verifies the gate's read/write bridge: it classifies
|
||||
// on the human command path first and falls back to product + RPC tool name.
|
||||
func TestClassifyPlannedAction(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pa plannedAction
|
||||
want CmdClass
|
||||
}{
|
||||
{
|
||||
name: "write via legacy path",
|
||||
pa: plannedAction{Product: "todo", Tool: "create_personal_todo", LegacyPath: "todo task create"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
{
|
||||
name: "read via legacy path",
|
||||
pa: plannedAction{Product: "todo", Tool: "list_personal_todo", LegacyPath: "todo task list"},
|
||||
want: CmdClassRead,
|
||||
},
|
||||
{
|
||||
name: "empty legacy path falls back to product+tool",
|
||||
pa: plannedAction{Product: "todo", Tool: "create_personal_todo"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
{
|
||||
name: "unrecognised path falls back to tool verb",
|
||||
pa: plannedAction{Product: "todo", Tool: "delete_personal_todo", LegacyPath: "todo xyz123"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := classifyPlannedAction(tc.pa); got != tc.want {
|
||||
t.Fatalf("classifyPlannedAction(%+v) = %s, want %s", tc.pa, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_ReadClassBypassesGate proves the "写类才拦" wiring: when the
|
||||
// planned command classifies as read-only, the gate does not engage — no card is
|
||||
// sent, nothing runs, and the cleaned reply goes out normally (handled=false).
|
||||
// We force the todo.create path to read-class via an override so the test drives
|
||||
// the real handleReply → classifyPlannedAction path end to end.
|
||||
func TestHandleReply_ReadClassBypassesGate(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
SetCmdClassOverride("todo task create", CmdClassRead)
|
||||
defer SetCmdClassOverride("todo task create", CmdClassUnknown)
|
||||
|
||||
gate := newApprovalGate("c3")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
||||
|
||||
reply := func(_ context.Context, _, _ string) error { return nil }
|
||||
agentReply := `好的,已为你查到。[[ACTION:todo.create title="交方案"]]`
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
||||
|
||||
if handled {
|
||||
t.Fatal("a read-class action must NOT be handled by the gate")
|
||||
}
|
||||
if !strings.Contains(out, "已为你查到") || strings.Contains(out, "[[ACTION") {
|
||||
t.Fatalf("read-class reply should be the cleaned text without the marker, got %q", out)
|
||||
}
|
||||
sender.mu.Lock()
|
||||
nSent := len(sender.sent)
|
||||
sender.mu.Unlock()
|
||||
if nSent != 0 {
|
||||
t.Fatalf("read-class action must not send a confirmation card, sent %d", nSent)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("read-class action must not execute, runner ran %d times", runner.count())
|
||||
}
|
||||
if len(gate.list()) != 0 {
|
||||
t.Fatalf("read-class action must not create an approval request, got %d", len(gate.list()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDecisionWord checks whole-message decision parsing: a bare keyword
|
||||
// decides, anything else (including a keyword embedded in a sentence) does not.
|
||||
func TestParseDecisionWord(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantApprove bool
|
||||
wantOk bool
|
||||
}{
|
||||
{"同意", true, true},
|
||||
{" 同意 ", true, true},
|
||||
{"通过", true, true},
|
||||
{"Yes", true, true},
|
||||
{"OK", true, true},
|
||||
{"拒绝", false, true},
|
||||
{"no", false, true},
|
||||
{"不同意", false, true},
|
||||
{"同意他的看法", false, false}, // embedded, not a bare decision
|
||||
{"帮我创建个待办", false, false},
|
||||
{"", false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotApprove, gotOk := parseDecisionWord(tc.in)
|
||||
if gotOk != tc.wantOk || (tc.wantOk && gotApprove != tc.wantApprove) {
|
||||
t.Fatalf("parseDecisionWord(%q) = (%v,%v), want (%v,%v)", tc.in, gotApprove, gotOk, tc.wantApprove, tc.wantOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingForConv finds the pending request for a conversation and stops
|
||||
// returning it once decided.
|
||||
func TestPendingForConv(t *testing.T) {
|
||||
gate := newApprovalGate("") // in-memory
|
||||
a := gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "a"})
|
||||
gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "b"})
|
||||
|
||||
if got := gate.pendingForConv("c1"); got == nil || got.ID != a.ID {
|
||||
t.Fatalf("pendingForConv(c1) = %v, want request %s", got, a.ID)
|
||||
}
|
||||
if got := gate.pendingForConv("none"); got != nil {
|
||||
t.Fatalf("pendingForConv(none) = %v, want nil", got)
|
||||
}
|
||||
gate.Decide(a.ID, true, "owner")
|
||||
if got := gate.pendingForConv("c1"); got != nil {
|
||||
t.Fatalf("decided request must not be pending, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerApproves is the private text-approval happy
|
||||
// path: an action privately DMs the OWNER (the requester sees nothing, no
|
||||
// execution yet); a non-owner "同意" is ignored; the owner's "同意" — sent from
|
||||
// their own chat, NOT the request conversation — decides and executes.
|
||||
func TestHandleReply_TextMode_OwnerApproves(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct1")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// A group requester (not the owner) asks for an action.
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-group", `好的。[[ACTION:todo.create title="交方案"]]`, nil)
|
||||
if !handled || out != "" {
|
||||
t.Fatalf("text-mode action: handled=%v out=%q, want handled with empty out (requester sees nothing)", handled, out)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("must not execute before the owner decides, ran %d", runner.count())
|
||||
}
|
||||
if len(gate.list()) != 1 {
|
||||
t.Fatalf("expected one pending request, got %d", len(gate.list()))
|
||||
}
|
||||
// The approval prompt went privately to the OWNER, never to the requester.
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatalf("approval prompt was not DMed to the owner: %+v", notifier.sent)
|
||||
}
|
||||
if notifier.toUser("requester") {
|
||||
t.Fatal("the requester must NOT be notified of the approval")
|
||||
}
|
||||
|
||||
// A non-owner saying 同意 must not decide or execute.
|
||||
if o.handleOwnerDecision(context.Background(), "intruder", "同意") {
|
||||
t.Fatal("non-owner must not be able to approve")
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("non-owner approval must not execute, ran %d", runner.count())
|
||||
}
|
||||
|
||||
// The owner saying 同意 decides and executes; the requester then gets the
|
||||
// outcome.
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||
t.Fatal("owner approval must be consumed")
|
||||
}
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("owner approval must execute exactly once, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("request state = %s, want executed", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("requester") {
|
||||
t.Fatal("the requester should get the final outcome after execution")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerSelfAutoApproves: when the requester IS the
|
||||
// owner, the action runs immediately with NO confirmation round-trip, but is
|
||||
// still recorded (auto_approved=true, decided-by owner, executed) for audit.
|
||||
func TestHandleReply_TextMode_OwnerSelfAutoApproves(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctself")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
var replies []string
|
||||
reply := func(_ context.Context, _, text string) error { replies = append(replies, text); return nil }
|
||||
|
||||
// The OWNER asks for the action themselves.
|
||||
out, handled := o.handleReply(context.Background(), "owner", "conv-owner", `好。[[ACTION:todo.create title="自己的待办"]]`, reply)
|
||||
if !handled || out != "" {
|
||||
t.Fatalf("owner self-request: handled=%v out=%q", handled, out)
|
||||
}
|
||||
// Executed immediately — no second confirmation asked.
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("owner self-request must execute immediately, ran %d", runner.count())
|
||||
}
|
||||
if notifier.count() != 0 {
|
||||
t.Fatalf("owner self-request must NOT send an approval DM, sent %d", notifier.count())
|
||||
}
|
||||
// Still recorded for audit: auto_approved + executed + decided by owner.
|
||||
rec := gate.list()[0]
|
||||
if !rec.AutoApproved || rec.State != approvalExecuted || rec.DecidedBy != "owner" {
|
||||
t.Fatalf("audit record wrong: autoApproved=%v state=%s decidedBy=%s", rec.AutoApproved, rec.State, rec.DecidedBy)
|
||||
}
|
||||
if !strings.Contains(strings.Join(replies, "|"), "已为你完成") {
|
||||
t.Fatalf("owner should get the result, replies=%v", replies)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAudit records the requests handed to the audit sink.
|
||||
type fakeAudit struct {
|
||||
mu sync.Mutex
|
||||
recs []*ApprovalRequest
|
||||
}
|
||||
|
||||
func (a *fakeAudit) record(_ context.Context, req *ApprovalRequest) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
cp := *req
|
||||
a.recs = append(a.recs, &cp)
|
||||
}
|
||||
|
||||
func (a *fakeAudit) count() int { a.mu.Lock(); defer a.mu.Unlock(); return len(a.recs) }
|
||||
|
||||
// TestAudit_RecordsTerminalOutcomes verifies the audit sink gets one terminal
|
||||
// record per action: an auto-executed owner request, and an approved request.
|
||||
func TestAudit_RecordsTerminalOutcomes(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("cta")
|
||||
audit := &fakeAudit{}
|
||||
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
||||
o.audit = audit
|
||||
reply := func(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// Owner self-request → auto-executed → audited once, as executed.
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自己"]]`, reply)
|
||||
if audit.count() != 1 {
|
||||
t.Fatalf("auto-exec must produce one audit record, got %d", audit.count())
|
||||
}
|
||||
if audit.recs[0].State != approvalExecuted || !audit.recs[0].AutoApproved {
|
||||
t.Fatalf("auto audit record: state=%s auto=%v", audit.recs[0].State, audit.recs[0].AutoApproved)
|
||||
}
|
||||
|
||||
// Someone else's request, approved by the owner → audited as executed.
|
||||
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="别人"]]`, reply)
|
||||
if audit.count() != 1 {
|
||||
t.Fatal("a pending (not yet decided) request must not be audited yet")
|
||||
}
|
||||
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
||||
if audit.count() != 2 {
|
||||
t.Fatalf("owner approval must produce a second audit record, got %d", audit.count())
|
||||
}
|
||||
if audit.recs[1].State != approvalExecuted || audit.recs[1].DecidedBy != "owner" {
|
||||
t.Fatalf("approved audit record: state=%s decidedBy=%s", audit.recs[1].State, audit.recs[1].DecidedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeferredExecution_HoldsAndRetries is the graceful-degradation path: when
|
||||
// execution fails (identity not ready), the request is DEFERRED (not lost, not
|
||||
// failed), the owner is privately notified; after the owner recovers and replies
|
||||
// "重试", the backlog is replayed, executes, and the requester gets the result.
|
||||
func TestDeferredExecution_HoldsAndRetries(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctdef")
|
||||
runner := &fakeRunner{fail: true} // identity not ready → execution fails
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// A non-owner request, approved by the owner, but execution fails → deferred.
|
||||
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="待恢复"]]`, nil)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||
t.Fatal("owner approval must be consumed")
|
||||
}
|
||||
rec := gate.list()[0]
|
||||
if rec.State != approvalDeferred {
|
||||
t.Fatalf("failed execution must DEFER (not fail), got state=%s", rec.State)
|
||||
}
|
||||
// Owner was told to recover; requester was NOT told it failed (task not lost).
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatal("owner should be notified of the deferred action")
|
||||
}
|
||||
if notifier.toUser("requester") {
|
||||
t.Fatal("requester must NOT be told it failed — their task is held, not lost")
|
||||
}
|
||||
|
||||
// A bare non-retry word from the owner should pass through (not consumed).
|
||||
if o.handleOwnerDecision(context.Background(), "owner", "在吗") {
|
||||
t.Fatal("ordinary owner message must not be consumed")
|
||||
}
|
||||
|
||||
// Owner recovers (identity now works) and replies "重试" → backlog flushes.
|
||||
runner.setFail(false)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "重试") {
|
||||
t.Fatal("retry word must be consumed")
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("after retry the deferred request must execute, got %s", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("requester") {
|
||||
t.Fatal("requester should get the outcome after the backlog is flushed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Auto: a non-owner request runs without asking under "auto".
|
||||
func TestConfirmPolicy_Auto(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctauto2")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
o.confirmPolicy = "auto"
|
||||
|
||||
_, handled := o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if !handled {
|
||||
t.Fatal("auto policy should handle (run) the action")
|
||||
}
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("auto policy must execute without asking, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("state=%s want executed", gate.list()[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Manual: a non-owner request asks the owner (default).
|
||||
func TestConfirmPolicy_Manual(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctman")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier) // no policy = manual
|
||||
|
||||
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, nil)
|
||||
if runner.count() != 0 {
|
||||
t.Fatal("manual policy must NOT execute before the owner decides")
|
||||
}
|
||||
if gate.list()[0].State != approvalPending {
|
||||
t.Fatalf("state=%s want pending (awaiting owner)", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatal("manual policy should DM the owner for approval")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Remember: ask once per verb, then reuse the decision.
|
||||
func TestConfirmPolicy_Remember(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctrem")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
o.confirmPolicy = "remember"
|
||||
|
||||
// First todo.create from a non-owner → asks (pending).
|
||||
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="第一次"]]`, nil)
|
||||
if gate.latestPending() == nil {
|
||||
t.Fatal("first request should ask the owner")
|
||||
}
|
||||
// Owner approves → remembered, executes.
|
||||
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("approved request should execute, ran %d", runner.count())
|
||||
}
|
||||
|
||||
// Second todo.create → reused decision, auto-runs (no new pending).
|
||||
_, handled := o.handleReply(context.Background(), "someone", "c2", `[[ACTION:todo.create title="第二次"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if !handled || runner.count() != 2 {
|
||||
t.Fatalf("remembered approve should auto-run the 2nd request, handled=%v ran=%d", handled, runner.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeEnforcement: an action outside the role's allowlist is refused up
|
||||
// front (the requester is told, nothing reaches the gate); an in-scope action
|
||||
// proceeds; an empty allowlist allows everything.
|
||||
func TestScopeEnforcement(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctscope")
|
||||
runner := &fakeRunner{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", &fakeNotifier{})
|
||||
o.allowedScopes = []string{"todo"} // HR-style: only todo
|
||||
|
||||
// doc.create is out of scope → refused, NOT gated, requester told.
|
||||
out, handled := o.handleReply(context.Background(), "requester", "c1", `[[ACTION:doc.create name="周报"]]`, nil)
|
||||
if handled {
|
||||
t.Fatal("out-of-scope action must not be handled by the gate")
|
||||
}
|
||||
if !strings.Contains(out, "没有") || !strings.Contains(out, "doc") {
|
||||
t.Fatalf("requester should be told it's out of scope, got %q", out)
|
||||
}
|
||||
if len(gate.list()) != 0 {
|
||||
t.Fatal("out-of-scope action must not create an approval request")
|
||||
}
|
||||
|
||||
// todo.create is in scope → proceeds (owner self-request auto-executes).
|
||||
o.handleReply(context.Background(), "owner", "c2", `[[ACTION:todo.create title="排期"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if len(gate.list()) != 1 {
|
||||
t.Fatalf("in-scope action should be processed, got %d requests", len(gate.list()))
|
||||
}
|
||||
|
||||
// scopeAllows: empty allowlist = allow all.
|
||||
o.allowedScopes = nil
|
||||
if !o.scopeAllows("anything") {
|
||||
t.Fatal("empty allowlist must allow all products")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoFlushDeferred verifies the background retry: silent while the identity
|
||||
// is still wrong (no spam), and it drains + notifies once execution works.
|
||||
func TestAutoFlushDeferred(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctauto")
|
||||
runner := &fakeRunner{fail: true}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// Defer one (identity not ready).
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自动恢复"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
deferNotices := notifier.count() // the deferred DM
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
||||
}
|
||||
|
||||
// Auto-retry while still failing → stays deferred, NO new owner message.
|
||||
o.autoFlushDeferred(context.Background())
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatal("should still be deferred while identity is wrong")
|
||||
}
|
||||
if notifier.count() != deferNotices {
|
||||
t.Fatalf("auto-retry must stay silent when nothing completes (sent %d, was %d)", notifier.count(), deferNotices)
|
||||
}
|
||||
|
||||
// Identity recovers → auto-retry drains it and notifies once.
|
||||
runner.setFail(false)
|
||||
o.autoFlushDeferred(context.Background())
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("auto-retry should execute once identity is back, got %s", gate.list()[0].State)
|
||||
}
|
||||
notifier.mu.Lock()
|
||||
last := notifier.sent[len(notifier.sent)-1].text
|
||||
notifier.mu.Unlock()
|
||||
if !strings.Contains(last, "已自动恢复") || !strings.Contains(last, "自动恢复") {
|
||||
t.Fatalf("owner should get an auto-recovery notice, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTransientSheetErr distinguishes retryable throttles from permanent errors.
|
||||
func TestIsTransientSheetErr(t *testing.T) {
|
||||
for _, e := range []string{"system error: THREADPOOL_BUSY", "server busy", "request timeout", "HTTP 429 too many requests"} {
|
||||
if !isTransientSheetErr(fmt.Errorf("%s", e)) {
|
||||
t.Fatalf("%q should be transient", e)
|
||||
}
|
||||
}
|
||||
for _, e := range []string{"invalid nodeId", "permission denied", "not found"} {
|
||||
if isTransientSheetErr(fmt.Errorf("%s", e)) {
|
||||
t.Fatalf("%q should NOT be transient", e)
|
||||
}
|
||||
}
|
||||
if isTransientSheetErr(nil) {
|
||||
t.Fatal("nil is not transient")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushDeferred_ReportsCompletedItems verifies the owner gets a per-item
|
||||
// completion list (not just a count) when the backlog is flushed.
|
||||
func TestFlushDeferred_ReportsCompletedItems(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctflush")
|
||||
runner := &fakeRunner{fail: true}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="我的待办"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
||||
}
|
||||
|
||||
runner.setFail(false)
|
||||
o.handleOwnerDecision(context.Background(), "owner", "重试")
|
||||
|
||||
notifier.mu.Lock()
|
||||
var last string
|
||||
if len(notifier.sent) > 0 {
|
||||
last = notifier.sent[len(notifier.sent)-1].text
|
||||
}
|
||||
notifier.mu.Unlock()
|
||||
if !strings.Contains(last, "已补做 1 个") || !strings.Contains(last, "创建待办:我的待办") {
|
||||
t.Fatalf("flush summary should list the completed item, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsRetryWord checks whole-message retry-keyword matching.
|
||||
func TestIsRetryWord(t *testing.T) {
|
||||
for _, w := range []string{"重试", " 恢复 ", "retry", "继续"} {
|
||||
if !isRetryWord(w) {
|
||||
t.Fatalf("%q should be a retry word", w)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"重试一下他的方案", "", "你好"} {
|
||||
if isRetryWord(w) {
|
||||
t.Fatalf("%q should NOT be a retry word", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerRejects: the owner's "拒绝" declines without
|
||||
// executing.
|
||||
func TestHandleReply_TextMode_OwnerRejects(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct2")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
o.handleReply(context.Background(), "requester", "conv-group", `[[ACTION:todo.create title="x"]]`, nil)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "拒绝") {
|
||||
t.Fatal("owner rejection must be consumed")
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("rejection must NOT execute, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalRejected {
|
||||
t.Fatalf("state = %s, want rejected", gate.list()[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleOwnerDecision_NotADecision: an ordinary owner message (not a bare
|
||||
// keyword) is not consumed, so it still reaches the agent.
|
||||
func TestHandleOwnerDecision_NotADecision(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct3")
|
||||
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
||||
gate.Submit(ApprovalRequest{ConvID: "conv-1", Summary: "x", State: approvalPending})
|
||||
|
||||
if o.handleOwnerDecision(context.Background(), "owner", "顺便帮我查下天气") {
|
||||
t.Fatal("a non-decision owner message must pass through to the agent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLatestPending returns the newest pending request across conversations.
|
||||
func TestLatestPending(t *testing.T) {
|
||||
gate := newApprovalGate("")
|
||||
gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "old", CreatedAt: time.Unix(100, 0)})
|
||||
newer := gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "new", CreatedAt: time.Unix(200, 0)})
|
||||
if got := gate.latestPending(); got == nil || got.ID != newer.ID {
|
||||
t.Fatalf("latestPending = %v, want newest %s", got, newer.ID)
|
||||
}
|
||||
gate.Decide(newer.ID, true, "owner")
|
||||
if got := gate.latestPending(); got == nil || got.Summary != "old" {
|
||||
t.Fatalf("after deciding newest, latestPending should fall back to the older pending, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// list is a tiny test helper to enumerate the gate's requests.
|
||||
func (g *approvalGate) list() []*ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make([]*ApprovalRequest, 0, len(g.reqs))
|
||||
for _, r := range g.reqs {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
)
|
||||
|
||||
// TestApprovalGate_EndToEndApprove exercises the whole vertical slice with no
|
||||
// network: an agent reply carrying a todo.create marker → Submit → approval card
|
||||
// → owner taps [Approve] via a card callback → the planned todo.create runs on
|
||||
// the (fake) runner → the group is told it was executed.
|
||||
//
|
||||
// It mirrors exactly what runStreamConnector wires together, but with the card
|
||||
// sender and runner faked, so the orchestration logic is verified end to end.
|
||||
func TestApprovalGate_EndToEndApprove(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
gate := newApprovalGate("e2e-client")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner-007")
|
||||
o.awaitWindow = 2 * time.Second
|
||||
|
||||
var replies []string
|
||||
var rmu sync.Mutex
|
||||
groupReply := func(_ context.Context, _, text string) error {
|
||||
rmu.Lock()
|
||||
replies = append(replies, text)
|
||||
rmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Simulate the owner tapping [Approve] once the card has been delivered, via
|
||||
// the exact card-callback path the Stream router would invoke.
|
||||
go func() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
sender.mu.Lock()
|
||||
track := sender.lastTrack
|
||||
sender.mu.Unlock()
|
||||
if track != "" {
|
||||
_, _ = o.handleCardCallback(context.Background(), &card.CardRequest{
|
||||
OutTrackId: track,
|
||||
UserId: "owner-007",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{approvalActionApprove},
|
||||
Params: map[string]any{
|
||||
approvalParamDecision: approvalDecisionApprove,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// This is the agent reply for: 群里有人 @机器人 "帮我建个待办:明天下班前交方案".
|
||||
agentReply := `收到,我来帮你建这个待办。[[ACTION:todo.create title="明天下班前交方案" due="2026-06-14T18:00:00+08:00"]]`
|
||||
|
||||
out, handled := o.handleReply(context.Background(), "requester-zhang", "group-conv-9", agentReply, groupReply)
|
||||
|
||||
if !handled {
|
||||
t.Fatal("action reply must be handled by the gate")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("handled reply returns empty connector reply, got %q", out)
|
||||
}
|
||||
|
||||
// The planned command ran exactly once, and it was todo.create.
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("expected exactly 1 execution, got %d", runner.count())
|
||||
}
|
||||
runner.mu.Lock()
|
||||
inv := runner.runs[0]
|
||||
runner.mu.Unlock()
|
||||
if inv.CanonicalProduct != "todo" || inv.Tool != "create_personal_todo" {
|
||||
t.Fatalf("executed wrong command: %s.%s", inv.CanonicalProduct, inv.Tool)
|
||||
}
|
||||
vo, _ := inv.Params["PersonalTodoCreateVO"].(map[string]any)
|
||||
if vo == nil || vo["subject"] != "明天下班前交方案" {
|
||||
t.Fatalf("todo subject wrong: %+v", vo)
|
||||
}
|
||||
|
||||
// State machine reached Executed.
|
||||
final := gate.list()[0]
|
||||
if final.State != approvalExecuted {
|
||||
t.Fatalf("final state = %s, want executed", final.State)
|
||||
}
|
||||
|
||||
// The owner saw [Approve]/[Reject] (the card was delivered), and the group
|
||||
// got the executed-confirmation reply.
|
||||
if len(sender.sent) != 1 {
|
||||
t.Fatalf("expected 1 card delivered, got %d", len(sender.sent))
|
||||
}
|
||||
rmu.Lock()
|
||||
joined := strings.Join(replies, " | ")
|
||||
rmu.Unlock()
|
||||
if !strings.Contains(joined, "已同意") || !strings.Contains(joined, "已执行") {
|
||||
t.Fatalf("group not told of execution: %q", joined)
|
||||
}
|
||||
|
||||
t.Logf("E2E approve chain OK:\n"+
|
||||
" card delivered to owner: %v\n"+
|
||||
" approval state: %s\n"+
|
||||
" command executed: %s.%s (subject=%q)\n"+
|
||||
" group replies: %s",
|
||||
sender.sent[0].Summary, final.State, inv.CanonicalProduct, inv.Tool, vo["subject"], joined)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestApprovalGate_SubmitPending verifies Submit lands a request in Pending with
|
||||
// a generated id and timestamp.
|
||||
func TestApprovalGate_SubmitPending(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-A")
|
||||
req := g.Submit(ApprovalRequest{Requester: "u1", ConvID: "cid", Summary: "do thing"})
|
||||
if req.ID == "" {
|
||||
t.Fatal("Submit should assign an ID")
|
||||
}
|
||||
if req.State != approvalPending {
|
||||
t.Fatalf("state = %s, want pending", req.State)
|
||||
}
|
||||
if req.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt should be set")
|
||||
}
|
||||
if got := g.Get(req.ID); got == nil || got.State != approvalPending {
|
||||
t.Fatalf("Get returned %+v, want pending", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_DecideApproveReject covers both decision branches and the
|
||||
// idempotency of a second decision.
|
||||
func TestApprovalGate_DecideApproveReject(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-B")
|
||||
|
||||
app := g.Submit(ApprovalRequest{Summary: "approve me"})
|
||||
decided, deciding := g.Decide(app.ID, true, "owner")
|
||||
if !deciding || decided.State != approvalApproved || decided.DecidedBy != "owner" {
|
||||
t.Fatalf("approve decision = %+v deciding=%v", decided, deciding)
|
||||
}
|
||||
// Second decision is a no-op.
|
||||
if _, again := g.Decide(app.ID, false, "owner"); again {
|
||||
t.Fatal("second Decide must not be the deciding call")
|
||||
}
|
||||
if g.Get(app.ID).State != approvalApproved {
|
||||
t.Fatal("state must stay approved after a redundant reject")
|
||||
}
|
||||
|
||||
rej := g.Submit(ApprovalRequest{Summary: "reject me"})
|
||||
d2, _ := g.Decide(rej.ID, false, "owner")
|
||||
if d2.State != approvalRejected {
|
||||
t.Fatalf("reject decision = %s, want rejected", d2.State)
|
||||
}
|
||||
|
||||
// Unknown id.
|
||||
if _, ok := g.Decide("nope", true, "owner"); ok {
|
||||
t.Fatal("deciding unknown id should report false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_AwaitWakesOnDecide verifies Await blocks until a decision and
|
||||
// returns the decided snapshot.
|
||||
func TestApprovalGate_AwaitWakesOnDecide(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-C")
|
||||
app := g.Submit(ApprovalRequest{Summary: "wait for me"})
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
g.Decide(app.ID, true, "owner")
|
||||
}()
|
||||
|
||||
decided, got := g.Await(context.Background(), app.ID, time.Second)
|
||||
if !got || decided == nil || !decided.approved() {
|
||||
t.Fatalf("Await = %+v got=%v, want approved", decided, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_AwaitTimeout verifies a timeout leaves the request Pending and
|
||||
// reports no decision.
|
||||
func TestApprovalGate_AwaitTimeout(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-D")
|
||||
app := g.Submit(ApprovalRequest{Summary: "nobody decides"})
|
||||
decided, got := g.Await(context.Background(), app.ID, 10*time.Millisecond)
|
||||
if got {
|
||||
t.Fatal("Await should report no decision on timeout")
|
||||
}
|
||||
if decided.State != approvalPending {
|
||||
t.Fatalf("state = %s, want pending after timeout", decided.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_PersistAndRecover verifies a Pending request written by one
|
||||
// gate is recovered by a fresh gate over the same config dir (restart survival).
|
||||
func TestApprovalGate_PersistAndRecover(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
|
||||
g1 := newApprovalGate("client-E")
|
||||
app := g1.Submit(ApprovalRequest{Requester: "u9", Summary: "survive restart",
|
||||
Action: plannedAction{Product: "todo", Tool: "create_personal_todo"}})
|
||||
g1.setOutTrackID(app.ID, "card-123")
|
||||
|
||||
// Fresh gate, same dir → must recover the request and its outTrackId.
|
||||
g2 := newApprovalGate("client-E")
|
||||
got := g2.Get(app.ID)
|
||||
if got == nil {
|
||||
t.Fatal("request not recovered from disk")
|
||||
}
|
||||
if got.State != approvalPending || got.Summary != "survive restart" {
|
||||
t.Fatalf("recovered = %+v", got)
|
||||
}
|
||||
if got.OutTrackID != "card-123" {
|
||||
t.Fatalf("recovered outTrackId = %q, want card-123", got.OutTrackID)
|
||||
}
|
||||
if byTrack := g2.findByOutTrackID("card-123"); byTrack == nil || byTrack.ID != app.ID {
|
||||
t.Fatal("findByOutTrackID failed after recovery")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_NoPersistWhenEmptyClient verifies an empty clientId keeps the
|
||||
// gate in memory only (no disk writes, mirroring the session store).
|
||||
func TestApprovalGate_NoPersistWhenEmptyClient(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("")
|
||||
if g.approvalDir() != "" {
|
||||
t.Fatal("empty clientId must disable persistence")
|
||||
}
|
||||
app := g.Submit(ApprovalRequest{Summary: "memory only"})
|
||||
if g.Get(app.ID) == nil {
|
||||
t.Fatal("in-memory request should still be retrievable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionMarker covers the simplified execution-class detection: a
|
||||
// marker is extracted with its args and stripped from the human-facing reply,
|
||||
// while plain Q&A is left untouched.
|
||||
func TestParseActionMarker(t *testing.T) {
|
||||
reply := `好的,我来帮你建。[[ACTION:todo.create title="交方案" due="2026-06-14T18:00:00+08:00"]]`
|
||||
act, cleaned, found := parseActionMarker(reply)
|
||||
if !found {
|
||||
t.Fatal("marker should be detected")
|
||||
}
|
||||
if act.Verb != "todo.create" {
|
||||
t.Fatalf("verb = %s", act.Verb)
|
||||
}
|
||||
if act.Args["title"] != "交方案" || act.Args["due"] != "2026-06-14T18:00:00+08:00" {
|
||||
t.Fatalf("args = %+v", act.Args)
|
||||
}
|
||||
if cleaned != "好的,我来帮你建。" {
|
||||
t.Fatalf("cleaned = %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, f := parseActionMarker("这是一个普通问题的回答,没有动作。"); f {
|
||||
t.Fatal("plain reply must not match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToPlannedAction verifies the marker → command mapping, including the
|
||||
// reject of a malformed (titleless) action.
|
||||
func TestToPlannedAction(t *testing.T) {
|
||||
pa, summary, ok := toPlannedAction(detectedAction{
|
||||
Verb: "todo.create",
|
||||
Args: map[string]string{"title": "交方案", "due": "2026-06-14T18:00:00+08:00"},
|
||||
}, "owner-1")
|
||||
if !ok {
|
||||
t.Fatal("valid todo.create should map")
|
||||
}
|
||||
if pa.Product != "todo" || pa.Tool != "create_personal_todo" {
|
||||
t.Fatalf("planned = %+v", pa)
|
||||
}
|
||||
vo, _ := pa.Params["PersonalTodoCreateVO"].(map[string]any)
|
||||
if vo == nil || vo["subject"] != "交方案" {
|
||||
t.Fatalf("vo = %+v", vo)
|
||||
}
|
||||
if _, hasDue := vo["dueTime"]; !hasDue {
|
||||
t.Fatal("due should be parsed into dueTime")
|
||||
}
|
||||
execs, _ := vo["executorIds"].([]string)
|
||||
if len(execs) != 1 || execs[0] != "owner-1" {
|
||||
t.Fatalf("executors = %+v, want [owner-1]", execs)
|
||||
}
|
||||
if summary == "" {
|
||||
t.Fatal("summary should be non-empty")
|
||||
}
|
||||
|
||||
if _, _, ok := toPlannedAction(detectedAction{Verb: "todo.create", Args: map[string]string{}}, "o"); ok {
|
||||
t.Fatal("titleless action must be rejected")
|
||||
}
|
||||
if _, _, ok := toPlannedAction(detectedAction{Verb: "unknown.verb"}, "o"); ok {
|
||||
t.Fatal("unknown verb must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
)
|
||||
|
||||
// AI-card reply support for the stream-bridge channels: instead of a plain
|
||||
// one-shot text message, the bot answers with a DingTalk AI card that shows a
|
||||
// "Thinking" state while the local agent runs and flips to done with the
|
||||
// final content — the same UX the hermes official pipeline provides.
|
||||
//
|
||||
// The experience combines the two verified official implementations:
|
||||
// - the "🤔Thinking/🥳Done" chip on the user's message is a text emotion
|
||||
// (message reaction), ported from the hermes gateway adapter;
|
||||
// - the card itself uses the openclaw connector's public template and
|
||||
// payload contract (msgContent + flowStatus state machine), because card
|
||||
// templates are app-scoped: hermes' own template (c629162a-...) renders
|
||||
// "内容加载失败" for any other app — confirmed by A/B on the same robot.
|
||||
//
|
||||
// Call order: create instance → deliver → flowStatus INPUTING → streaming
|
||||
// frames (throttled, isFull) → finalize frame → flowStatus FINISHED.
|
||||
const (
|
||||
// defaultAICardTemplateID is the public AI-card streaming template the
|
||||
// openclaw connector ships with (messaging/card.ts) — a best-effort
|
||||
// default. Card templates are APP-SCOPED: the reliable production setup is
|
||||
// registering an AI-card template under YOUR app in the DingTalk developer
|
||||
// console (the hermes docs prescribe exactly this) and passing its ID via
|
||||
// --card-template / DWS_CARD_TEMPLATE. Using another app's template (e.g.
|
||||
// hermes' c629162a-...) renders "内容加载失败" — confirmed by live A/B on
|
||||
// the same robot.
|
||||
defaultAICardTemplateID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema"
|
||||
|
||||
// aiCardMaxContent mirrors hermes' MAX_MESSAGE_LENGTH truncation.
|
||||
aiCardMaxContent = 20000
|
||||
|
||||
// flowStatus states of the AI-card template (openclaw AICardStatus).
|
||||
aiCardFlowInputing = "2"
|
||||
aiCardFlowFinished = "3"
|
||||
aiCardFlowFailed = "5"
|
||||
)
|
||||
|
||||
// dingtalkCardAPIBase is a var so tests can point it at a httptest server.
|
||||
var dingtalkCardAPIBase = "https://api.dingtalk.com"
|
||||
|
||||
// aiCardClient creates and finalizes AI cards using the robot's own
|
||||
// credentials (clientId/clientSecret → app access token, cached ~2h).
|
||||
type aiCardClient struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
templateID string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// newAICardClient builds the reply-UX client. With an empty templateID the
|
||||
// client does emotions only (Thinking/Done chips) and no cards — mirroring
|
||||
// hermes, where cards are an opt-in enabled by configuring a template ID and
|
||||
// replies stay plain text otherwise. This avoids the silent-failure trap:
|
||||
// card APIs all succeed even when the client cannot render the template.
|
||||
func newAICardClient(clientID, clientSecret, templateID string) *aiCardClient {
|
||||
templateID = strings.TrimSpace(templateID)
|
||||
return &aiCardClient{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
templateID: templateID,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// hasTemplate reports whether card replies are enabled (a template is
|
||||
// configured); without one the client is used for emotions only.
|
||||
func (c *aiCardClient) hasTemplate() bool { return c.templateID != "" }
|
||||
|
||||
// aiCardInstance is one delivered card, identified by its outTrackId.
|
||||
type aiCardInstance struct {
|
||||
outTrackID string
|
||||
// inputing marks that the INPUTING flow state was already set (once per
|
||||
// card, before the first streaming frame).
|
||||
inputing bool
|
||||
// lastFrame timestamps the last non-final streaming frame for the
|
||||
// per-card throttle (the streaming endpoint 403s on <~500ms updates;
|
||||
// hermes uses 800ms).
|
||||
lastFrame time.Time
|
||||
}
|
||||
|
||||
// accessToken returns a cached app access token, refreshing 5 minutes early.
|
||||
func (c *aiCardClient) accessToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) {
|
||||
return c.token, nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"appKey": c.clientID,
|
||||
"appSecret": c.clientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("accessToken HTTP %d: %s", resp.StatusCode, truncateRunes(string(raw), 200))
|
||||
}
|
||||
var parsed struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("accessToken parse failed: %s", truncateRunes(string(raw), 200))
|
||||
}
|
||||
c.token = parsed.AccessToken
|
||||
expire := parsed.ExpireIn
|
||||
if expire <= 0 {
|
||||
expire = 7200
|
||||
}
|
||||
c.tokenExp = time.Now().Add(time.Duration(expire) * time.Second)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// call performs one authenticated card-API request and fails on non-2xx.
|
||||
func (c *aiCardClient) call(ctx context.Context, method, path string, payload map[string]any) error {
|
||||
_, err := c.callRaw(ctx, method, path, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload map[string]any) (string, error) {
|
||||
token, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-acs-dingtalk-access-token", token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("%s %s HTTP %d: %s", method, path, resp.StatusCode, truncateRunes(string(raw), 300))
|
||||
}
|
||||
// Surface every response body in the connector log: the card APIs report
|
||||
// business-level failures inside a 200, and a silently dropped frame is
|
||||
// otherwise indistinguishable from the client-side "内容加载失败".
|
||||
fmt.Fprintf(os.Stderr, "[connect][card][api] %s %s -> %d %s\n", method, path, resp.StatusCode, truncateRunes(string(raw), 200))
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// sendOTOText sends a plain-text 1:1 (robot → person) message to each userId,
|
||||
// using the robot batch one-to-one send API. It is how the connector reaches a
|
||||
// specific person (e.g. the digital-twin owner) proactively, outside the
|
||||
// ephemeral inbound sessionWebhook — the webhook can only reply into the
|
||||
// conversation a message arrived on, and may be stale by the time an owner
|
||||
// decides. Auth reuses the app access token (the bot's own clientId/secret), so
|
||||
// the message is sent as the bot in its own org.
|
||||
func (c *aiCardClient) sendOTOText(ctx context.Context, userIDs []string, text string) error {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
param, err := json.Marshal(map[string]string{"content": text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{
|
||||
"robotCode": c.clientID,
|
||||
"userIds": userIDs,
|
||||
"msgKey": "sampleText",
|
||||
"msgParam": string(param),
|
||||
})
|
||||
}
|
||||
|
||||
// createAndDeliver creates an AI-card instance and delivers it into the
|
||||
// conversation the message came from. With empty content the template
|
||||
// renders the "Thinking" state while the agent runs. Group messages deliver
|
||||
// to IM_GROUP (with robotCode), 1:1 to IM_ROBOT (spaceType only) — exactly
|
||||
// the hermes deliver shapes.
|
||||
func (c *aiCardClient) createAndDeliver(ctx context.Context, data *chatbot.BotCallbackDataModel) (*aiCardInstance, error) {
|
||||
outTrackID := "dws_" + uuid.NewString()
|
||||
create := map[string]any{
|
||||
"cardTemplateId": c.templateID,
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]any{"config": `{"autoLayout":true}`},
|
||||
},
|
||||
"callbackType": "STREAM",
|
||||
"imGroupOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
}
|
||||
if err := c.call(ctx, http.MethodPost, "/v1.0/card/instances", create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deliver := map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"userIdType": 1,
|
||||
}
|
||||
if data.ConversationType == "2" { // group chat
|
||||
deliver["openSpaceId"] = "dtv1.card//IM_GROUP." + data.ConversationId
|
||||
deliver["imGroupOpenDeliverModel"] = map[string]any{"robotCode": c.clientID}
|
||||
} else { // 1:1 chat with the robot
|
||||
if data.SenderStaffId == "" {
|
||||
return nil, fmt.Errorf("missing senderStaffId for 1:1 card delivery")
|
||||
}
|
||||
deliver["openSpaceId"] = "dtv1.card//IM_ROBOT." + data.SenderStaffId
|
||||
deliver["imRobotOpenDeliverModel"] = map[string]any{
|
||||
"spaceType": "IM_ROBOT",
|
||||
"robotCode": c.clientID,
|
||||
"extension": map[string]any{"dynamicSummary": "true"},
|
||||
}
|
||||
}
|
||||
if err := c.callChecked(ctx, http.MethodPost, "/v1.0/card/instances/deliver", deliver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aiCardInstance{outTrackID: outTrackID}, nil
|
||||
}
|
||||
|
||||
// callChecked is call() plus a business-level result check: the deliver API
|
||||
// reports per-target failures INSIDE a HTTP 200 (e.g. {"result":[{"success":
|
||||
// false,"errorMsg":"spaceId is illegal"}],"success":true}) — observed live.
|
||||
func (c *aiCardClient) callChecked(ctx context.Context, method, path string, payload map[string]any) error {
|
||||
raw, err := c.callRaw(ctx, method, path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(raw, `"success":false`) {
|
||||
return fmt.Errorf("%s %s business failure: %s", method, path, truncateRunes(raw, 300))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamingUpdate sends one full content frame to the card.
|
||||
func (c *aiCardClient) streamingUpdate(ctx context.Context, card *aiCardInstance, content string, finalize, isError bool) error {
|
||||
if r := []rune(content); len(r) > aiCardMaxContent {
|
||||
content = string(r[:aiCardMaxContent])
|
||||
}
|
||||
return c.call(ctx, http.MethodPut, "/v1.0/card/streaming", map[string]any{
|
||||
"outTrackId": card.outTrackID,
|
||||
"guid": uuid.NewString(),
|
||||
"key": "msgContent",
|
||||
"content": content,
|
||||
"isFull": true,
|
||||
"isFinalize": finalize,
|
||||
"isError": isError,
|
||||
})
|
||||
}
|
||||
|
||||
// aiCardFrameGap spaces the deliver → content → finalize frames. Delivering
|
||||
// and finalizing back-to-back races the client's card fetch and intermittently
|
||||
// renders "内容加载失败" (the very failure that killed the #407 card attempt);
|
||||
// a short gap lets the client subscribe before the closing frame lands.
|
||||
var aiCardFrameGap = 500 * time.Millisecond
|
||||
|
||||
// cardContentParams is the cardParamMap contract of the openclaw template.
|
||||
func cardContentParams(flowStatus, content string) map[string]any {
|
||||
return map[string]any{
|
||||
"flowStatus": flowStatus,
|
||||
"msgContent": content,
|
||||
"staticMsgContent": "",
|
||||
"sys_full_json_obj": `{"order":["msgContent"]}`,
|
||||
"config": `{"autoLayout":true}`,
|
||||
}
|
||||
}
|
||||
|
||||
// setFlowStatus updates the card instance's flow state (openclaw contract:
|
||||
// INPUTING before streaming, FINISHED after the finalize frame).
|
||||
func (c *aiCardClient) setFlowStatus(ctx context.Context, card *aiCardInstance, flowStatus, content string, byKey bool) error {
|
||||
payload := map[string]any{
|
||||
"outTrackId": card.outTrackID,
|
||||
"cardData": map[string]any{"cardParamMap": cardContentParams(flowStatus, content)},
|
||||
}
|
||||
if byKey {
|
||||
payload["cardUpdateOptions"] = map[string]any{"updateCardDataByKey": true}
|
||||
}
|
||||
return c.call(ctx, http.MethodPut, "/v1.0/card/instances", payload)
|
||||
}
|
||||
|
||||
// aiCardFrameThrottle is the minimum spacing between non-final streaming
|
||||
// frames per card (hermes uses 800ms; the endpoint 403s on rapid updates).
|
||||
var aiCardFrameThrottle = 800 * time.Millisecond
|
||||
|
||||
// streamFrame pushes one full-content frame into the card, switching the card
|
||||
// to INPUTING before the first frame. Non-final frames are throttled per
|
||||
// card — a skipped frame is fine because every frame carries the full text so
|
||||
// far (isFull). The final frame always goes out.
|
||||
func (c *aiCardClient) streamFrame(ctx context.Context, card *aiCardInstance, content string, final bool) error {
|
||||
normalized := normalizeForCard(content)
|
||||
if !card.inputing {
|
||||
if err := c.setFlowStatus(ctx, card, aiCardFlowInputing, normalized, false); err != nil {
|
||||
return err
|
||||
}
|
||||
card.inputing = true
|
||||
card.lastFrame = time.Now()
|
||||
}
|
||||
if !final {
|
||||
if time.Since(card.lastFrame) < aiCardFrameThrottle {
|
||||
return nil // skip; a later frame re-sends the full text
|
||||
}
|
||||
}
|
||||
if err := c.streamingUpdate(ctx, card, normalized, final, false); err != nil {
|
||||
return err
|
||||
}
|
||||
card.lastFrame = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish closes the card: the finalized streaming frame plus the FINISHED
|
||||
// flow state. A failure leaves the card spinning, so callers must treat an
|
||||
// error as "fall back to a plain webhook reply" (after markFailed).
|
||||
func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content string) error {
|
||||
normalized := normalizeForCard(content)
|
||||
if err := c.streamFrame(ctx, card, content, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true)
|
||||
}
|
||||
|
||||
// finalize is the one-shot path for channels without incremental output:
|
||||
// INPUTING → final frame → FINISHED in one call.
|
||||
func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error {
|
||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.finish(ctx, card, content)
|
||||
}
|
||||
|
||||
// repair re-pushes the finalize frame once, a few seconds after the reply.
|
||||
// hermes streams many frames so a client that misses one recovers on the
|
||||
// next; our burst of two frames has no such retry, and a missed fetch shows
|
||||
// "内容加载失败" until a new frame arrives. Best-effort by design.
|
||||
func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) {
|
||||
if err := sleepCtx(ctx, aiCardRepairDelay); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true)
|
||||
}
|
||||
|
||||
// aiCardRepairDelay is how long after finalize the repair frame goes out.
|
||||
var aiCardRepairDelay = 3 * time.Second
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// markFailed best-effort closes a stuck card in the error state so the
|
||||
// Thinking chip does not spin forever when finalize failed and the reply
|
||||
// went out as a plain message instead. Errors are ignored by design.
|
||||
func (c *aiCardClient) markFailed(ctx context.Context, card *aiCardInstance) {
|
||||
_ = c.setFlowStatus(ctx, card, aiCardFlowFailed, "", true)
|
||||
}
|
||||
|
||||
// ---- Message reactions (the "🤔Thinking" chip) ----
|
||||
//
|
||||
// The status chip attached to the user's message is NOT a card: it is a text
|
||||
// emotion (message reaction) — hermes fires "🤔Thinking" on receive and swaps
|
||||
// it for "🥳Done" when the reply lands (gateway/platforms/dingtalk.py,
|
||||
// _send_emotion / _fire_done_reaction). During thinking there is no card at
|
||||
// all; the card only appears with the final content.
|
||||
|
||||
const (
|
||||
emotionThinking = "🤔Thinking"
|
||||
emotionDone = "🥳Done"
|
||||
)
|
||||
|
||||
// sendEmotion adds (or recalls) a text reaction on the user's message.
|
||||
// Failures are logged by callers at most — the reaction is decoration, never
|
||||
// worth failing the reply over. Payload contract per the robot_1_0 SDK:
|
||||
// POST /v1.0/robot/emotion/reply | /v1.0/robot/emotion/recall.
|
||||
func (c *aiCardClient) sendEmotion(ctx context.Context, conversationID, msgID, name string, recall bool) error {
|
||||
if conversationID == "" || msgID == "" {
|
||||
return fmt.Errorf("emotion needs openConversationId and openMsgId")
|
||||
}
|
||||
path := "/v1.0/robot/emotion/reply"
|
||||
if recall {
|
||||
path = "/v1.0/robot/emotion/recall"
|
||||
}
|
||||
return c.call(ctx, http.MethodPost, path, map[string]any{
|
||||
"robotCode": c.clientID,
|
||||
"openConversationId": conversationID,
|
||||
"openMsgId": msgID,
|
||||
"emotionType": 2,
|
||||
"emotionName": name,
|
||||
"textEmotion": map[string]any{
|
||||
"emotionId": "2659900",
|
||||
"emotionName": name,
|
||||
"text": name,
|
||||
"backgroundId": "im_bg_1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// markThinking fires the "🤔Thinking" chip on the user's message.
|
||||
func (c *aiCardClient) markThinking(ctx context.Context, conversationID, msgID string) error {
|
||||
return c.sendEmotion(ctx, conversationID, msgID, emotionThinking, false)
|
||||
}
|
||||
|
||||
// swapThinkingToDone replaces the chip with "🥳Done" after the reply landed.
|
||||
// Best-effort by design (mirrors hermes' fire-and-forget swap).
|
||||
func (c *aiCardClient) swapThinkingToDone(ctx context.Context, conversationID, msgID string) {
|
||||
_ = c.sendEmotion(ctx, conversationID, msgID, emotionThinking, true)
|
||||
_ = c.sendEmotion(ctx, conversationID, msgID, emotionDone, false)
|
||||
}
|
||||
|
||||
// ---- Markdown normalization for the AI-card renderer ----
|
||||
//
|
||||
// Ported from openclaw's normalizeForCard (messaging/card.ts): the 02fcf2f4
|
||||
// template renders <br> as visual line breaks in plain text, needs real \n
|
||||
// inside code fences and before Markdown block syntax, and a blank line
|
||||
// before tables.
|
||||
|
||||
var (
|
||||
cardTableDividerRe = regexp.MustCompile(`^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$`)
|
||||
cardTableRowRe = regexp.MustCompile(`^\s*\|?.*\|.*\|?\s*$`)
|
||||
cardBlockStartRe = regexp.MustCompile(`^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))`)
|
||||
cardFenceRe = regexp.MustCompile("^\\s{0,3}```")
|
||||
cardQuoteRe = regexp.MustCompile(`^\s{0,3}>\s?`)
|
||||
)
|
||||
|
||||
func normalizeCardLineEndings(text string) string {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
return strings.ReplaceAll(text, "\r", "\n")
|
||||
}
|
||||
|
||||
// ensureCardTableBlankLines inserts a blank line before a table header that
|
||||
// directly follows non-table text, which the card renderer requires.
|
||||
func ensureCardTableBlankLines(text string) string {
|
||||
lines := strings.Split(normalizeCardLineEndings(text), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
isDivider := func(line string) bool {
|
||||
return strings.Contains(line, "|") && cardTableDividerRe.MatchString(line)
|
||||
}
|
||||
for i, line := range lines {
|
||||
next := ""
|
||||
if i+1 < len(lines) {
|
||||
next = lines[i+1]
|
||||
}
|
||||
if cardTableRowRe.MatchString(line) && isDivider(next) && i > 0 &&
|
||||
strings.TrimSpace(lines[i-1]) != "" && !cardTableRowRe.MatchString(lines[i-1]) {
|
||||
out = append(out, "")
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// fixCardNewlines converts single \n to <br> for plain text while keeping
|
||||
// real newlines inside code fences and before Markdown block syntax, and
|
||||
// merges consecutive quote lines with <br> (lazy continuation).
|
||||
func fixCardNewlines(text string) string {
|
||||
normalized := normalizeCardLineEndings(text)
|
||||
|
||||
var merged []string
|
||||
var pendingQuote []string
|
||||
inCode := false
|
||||
flushQuote := func() {
|
||||
if len(pendingQuote) > 0 {
|
||||
merged = append(merged, strings.Join(pendingQuote, "<br>"))
|
||||
pendingQuote = nil
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(normalized, "\n") {
|
||||
isFence := cardFenceRe.MatchString(line)
|
||||
if inCode {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
if isFence {
|
||||
inCode = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isFence {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
inCode = true
|
||||
continue
|
||||
}
|
||||
if cardQuoteRe.MatchString(line) {
|
||||
if len(pendingQuote) == 0 {
|
||||
pendingQuote = append(pendingQuote, line)
|
||||
} else {
|
||||
pendingQuote = append(pendingQuote, cardQuoteRe.ReplaceAllString(line, ""))
|
||||
}
|
||||
} else {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
}
|
||||
}
|
||||
flushQuote()
|
||||
|
||||
inCode = false
|
||||
var b strings.Builder
|
||||
for i, line := range merged {
|
||||
nextInCode := inCode
|
||||
if cardFenceRe.MatchString(line) {
|
||||
nextInCode = !inCode
|
||||
}
|
||||
if i < len(merged)-1 {
|
||||
next := merged[i+1]
|
||||
keepNewline := nextInCode || line == "" || next == "" ||
|
||||
cardFenceRe.MatchString(next) || cardBlockStartRe.MatchString(next)
|
||||
b.WriteString(line)
|
||||
if keepNewline {
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("<br>")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(line)
|
||||
}
|
||||
inCode = nextInCode
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeForCard prepares agent output for the AI-card renderer.
|
||||
func normalizeForCard(content string) string {
|
||||
return fixCardNewlines(ensureCardTableBlankLines(content))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
)
|
||||
|
||||
// cardAPIRecorder fakes the DingTalk card endpoints and records every call.
|
||||
type cardAPIRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []string // "METHOD path"
|
||||
bodies []map[string]any // parsed request bodies, same order
|
||||
tokens int // accessToken request count
|
||||
fail map[string]int // "METHOD path" -> HTTP status to return
|
||||
}
|
||||
|
||||
func newCardAPIServer(t *testing.T) (*cardAPIRecorder, *httptest.Server) {
|
||||
t.Helper()
|
||||
rec := &cardAPIRecorder{fail: map[string]int{}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
key := r.Method + " " + r.URL.Path
|
||||
|
||||
rec.mu.Lock()
|
||||
if r.URL.Path == "/v1.0/oauth2/accessToken" {
|
||||
rec.tokens++
|
||||
} else {
|
||||
rec.calls = append(rec.calls, key)
|
||||
rec.bodies = append(rec.bodies, body)
|
||||
}
|
||||
status := rec.fail[key]
|
||||
rec.mu.Unlock()
|
||||
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"code":"boom"}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1.0/oauth2/accessToken" {
|
||||
_, _ = w.Write([]byte(`{"accessToken":"tok-1","expireIn":7200}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return rec, srv
|
||||
}
|
||||
|
||||
func withCardAPIBase(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
old := dingtalkCardAPIBase
|
||||
dingtalkCardAPIBase = base
|
||||
oldGap, oldRepair := aiCardFrameGap, aiCardRepairDelay
|
||||
aiCardFrameGap, aiCardRepairDelay = 0, 0
|
||||
t.Cleanup(func() {
|
||||
dingtalkCardAPIBase = old
|
||||
aiCardFrameGap, aiCardRepairDelay = oldGap, oldRepair
|
||||
})
|
||||
}
|
||||
|
||||
func groupCallback() *chatbot.BotCallbackDataModel {
|
||||
return &chatbot.BotCallbackDataModel{
|
||||
ConversationId: "cid-group-1",
|
||||
ConversationType: "2",
|
||||
SenderStaffId: "staff-1",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCreateFinalizeSequence verifies the hermes-contract happy path:
|
||||
// create (empty content = Thinking) → deliver → ONE finalized streaming frame.
|
||||
func TestAICardCreateFinalizeSequence(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
card, err := c.createAndDeliver(context.Background(), groupCallback())
|
||||
if err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(card.outTrackID, "dws_") {
|
||||
t.Fatalf("outTrackID = %q, want dws_ prefix", card.outTrackID)
|
||||
}
|
||||
if err := c.finalize(context.Background(), card, "你好,**答案是 42**"); err != nil {
|
||||
t.Fatalf("finalize: %v", err)
|
||||
}
|
||||
|
||||
wantSeq := []string{
|
||||
"POST /v1.0/card/instances",
|
||||
"POST /v1.0/card/instances/deliver",
|
||||
"PUT /v1.0/card/instances", // INPUTING + content
|
||||
"PUT /v1.0/card/streaming", // finalize frame
|
||||
"PUT /v1.0/card/instances", // FINISHED
|
||||
}
|
||||
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
|
||||
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
|
||||
}
|
||||
if rec.tokens != 1 {
|
||||
t.Fatalf("token requests = %d, want 1 (cached)", rec.tokens)
|
||||
}
|
||||
|
||||
// create: hermes template + empty "content" param (renders Thinking)
|
||||
create := rec.bodies[0]
|
||||
if create["cardTemplateId"] != defaultAICardTemplateID || create["callbackType"] != "STREAM" {
|
||||
t.Fatalf("create payload wrong: %v", create)
|
||||
}
|
||||
pm := create["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
if _, ok := pm["config"]; !ok {
|
||||
t.Fatalf("create cardParamMap = %v, want config key (openclaw contract)", pm)
|
||||
}
|
||||
|
||||
// deliver: group space + robotCode
|
||||
deliver := rec.bodies[1]
|
||||
if deliver["openSpaceId"] != "dtv1.card//IM_GROUP.cid-group-1" {
|
||||
t.Fatalf("deliver openSpaceId = %v", deliver["openSpaceId"])
|
||||
}
|
||||
if deliver["imGroupOpenDeliverModel"].(map[string]any)["robotCode"] != "ding-client" {
|
||||
t.Fatalf("deliver robotCode missing: %v", deliver)
|
||||
}
|
||||
|
||||
// INPUTING then FINISHED flowStatus around a finalized streaming frame
|
||||
statusOf := func(b map[string]any) string {
|
||||
pm := b["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
v, _ := pm["flowStatus"].(string)
|
||||
return v
|
||||
}
|
||||
if statusOf(rec.bodies[2]) != aiCardFlowInputing {
|
||||
t.Fatalf("flowStatus[2] = %v, want INPUTING", statusOf(rec.bodies[2]))
|
||||
}
|
||||
stream := rec.bodies[3]
|
||||
if stream["key"] != "msgContent" || stream["isFinalize"] != true || stream["isError"] != false {
|
||||
t.Fatalf("finalize frame wrong: %v", stream)
|
||||
}
|
||||
if statusOf(rec.bodies[4]) != aiCardFlowFinished {
|
||||
t.Fatalf("flowStatus[4] = %v, want FINISHED", statusOf(rec.bodies[4]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardDeliverOneToOne checks 1:1 messages deliver into the IM_ROBOT
|
||||
// space with the hermes shape (spaceType only, no robotCode).
|
||||
func TestAICardDeliverOneToOne(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
data := &chatbot.BotCallbackDataModel{ConversationType: "1", SenderStaffId: "staff-9"}
|
||||
if _, err := c.createAndDeliver(context.Background(), data); err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
deliver := rec.bodies[1]
|
||||
if deliver["openSpaceId"] != "dtv1.card//IM_ROBOT.staff-9" {
|
||||
t.Fatalf("deliver openSpaceId = %v, want IM_ROBOT.staff-9", deliver["openSpaceId"])
|
||||
}
|
||||
model := deliver["imRobotOpenDeliverModel"].(map[string]any)
|
||||
if model["spaceType"] != "IM_ROBOT" || model["robotCode"] != "ding-client" {
|
||||
t.Fatalf("imRobotOpenDeliverModel = %v", model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCreateFailure checks errors surface so callers fall back to plain
|
||||
// replies (never silently lost), and that missing staffId in 1:1 is rejected.
|
||||
func TestAICardCreateFailure(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
rec.fail["POST /v1.0/card/instances"] = 500
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err == nil {
|
||||
t.Fatal("want error when card create fails")
|
||||
}
|
||||
|
||||
rec.fail = map[string]int{}
|
||||
if _, err := c.createAndDeliver(context.Background(),
|
||||
&chatbot.BotCallbackDataModel{ConversationType: "1"}); err == nil {
|
||||
t.Fatal("want error for 1:1 without senderStaffId")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardMarkFailed checks the stuck-Thinking remedy: a finalized error
|
||||
// frame, errors swallowed.
|
||||
func TestAICardMarkFailed(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
c.markFailed(context.Background(), &aiCardInstance{outTrackID: "dws_x"})
|
||||
last := rec.bodies[len(rec.bodies)-1]
|
||||
pm := last["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
if pm["flowStatus"] != aiCardFlowFailed {
|
||||
t.Fatalf("markFailed frame wrong: %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectReplyCardFlag checks the flag default and dry-run surface.
|
||||
func TestRobotConnectReplyCardFlag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"default: no template -> text + emotions", nil, "thinking/done表态"},
|
||||
{"template configured -> ai-card", []string{"--card-template", "tpl-1.schema"}, `"replyStyle": "ai-card"`},
|
||||
{"public alias -> ai-card", []string{"--card-template", "public"}, `"replyStyle": "ai-card"`},
|
||||
{"explicit off", []string{"--reply-card=false"}, `"replyStyle": "text/markdown"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out strings.Builder
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
args := append([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"}, tc.args...)
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), tc.want) {
|
||||
t.Fatalf("missing %q in:\n%s", tc.want, out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardEmotions covers the Thinking chip contract: reply on receive,
|
||||
// recall+Done on completion (POST /v1.0/robot/emotion/*).
|
||||
func TestAICardEmotions(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
if err := c.markThinking(context.Background(), "cid-1", "msg-1"); err != nil {
|
||||
t.Fatalf("markThinking: %v", err)
|
||||
}
|
||||
c.swapThinkingToDone(context.Background(), "cid-1", "msg-1")
|
||||
|
||||
wantSeq := []string{
|
||||
"POST /v1.0/robot/emotion/reply", // Thinking
|
||||
"POST /v1.0/robot/emotion/recall", // Thinking recalled
|
||||
"POST /v1.0/robot/emotion/reply", // Done
|
||||
}
|
||||
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
|
||||
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
|
||||
}
|
||||
first := rec.bodies[0]
|
||||
if first["emotionName"] != "🤔Thinking" || first["openMsgId"] != "msg-1" ||
|
||||
first["robotCode"] != "ding-client" {
|
||||
t.Fatalf("thinking payload wrong: %v", first)
|
||||
}
|
||||
te := first["textEmotion"].(map[string]any)
|
||||
if te["emotionId"] != "2659900" || te["backgroundId"] != "im_bg_1" || te["text"] != "🤔Thinking" {
|
||||
t.Fatalf("textEmotion wrong: %v", te)
|
||||
}
|
||||
if rec.bodies[2]["emotionName"] != "🥳Done" {
|
||||
t.Fatalf("done payload wrong: %v", rec.bodies[2])
|
||||
}
|
||||
|
||||
// Missing ids must error (e.g. payloads without MsgId).
|
||||
if err := c.markThinking(context.Background(), "", "msg-1"); err == nil {
|
||||
t.Fatal("want error for missing conversation id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCustomTemplate checks --card-template plumbs through to create.
|
||||
func TestAICardCustomTemplate(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "my-own-template.schema")
|
||||
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
if rec.bodies[0]["cardTemplateId"] != "my-own-template.schema" {
|
||||
t.Fatalf("cardTemplateId = %v, want custom template", rec.bodies[0]["cardTemplateId"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// 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 "strings"
|
||||
|
||||
// CmdClass describes whether a dws command is read-only or mutating. It is the
|
||||
// signal a connector confirmation gate consumes to decide whether a robot may
|
||||
// run a command directly (read-only) or must first ask the principal to
|
||||
// approve it (write / mutating).
|
||||
//
|
||||
// SAFETY CONTRACT: callers MUST treat CmdClassUnknown conservatively, i.e. as
|
||||
// if it were CmdClassWrite (require confirmation). The classifier deliberately
|
||||
// returns Unknown rather than silently coercing it to Write so that the
|
||||
// confirmation gate keeps full information and can, for example, log/telemeter
|
||||
// "unclassified" commands separately. Never auto-allow an Unknown command.
|
||||
type CmdClass int
|
||||
|
||||
const (
|
||||
// CmdClassUnknown means the leaf verb was not recognised by the
|
||||
// heuristics or any override. Callers must default to requiring
|
||||
// confirmation (treat as write) for safety.
|
||||
CmdClassUnknown CmdClass = iota
|
||||
// CmdClassRead is a read-only / non-mutating command that may be run
|
||||
// without principal confirmation.
|
||||
CmdClassRead
|
||||
// CmdClassWrite is a mutating / state-changing command that must be
|
||||
// confirmed by the principal before it runs.
|
||||
CmdClassWrite
|
||||
)
|
||||
|
||||
// String renders the class as a stable lowercase token, handy for logs and
|
||||
// telemetry.
|
||||
func (c CmdClass) String() string {
|
||||
switch c {
|
||||
case CmdClassRead:
|
||||
return "read"
|
||||
case CmdClassWrite:
|
||||
return "write"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// readVerbs and writeVerbs are calibrated against the verbs that real dws
|
||||
// cobra commands expose (scanned from `Use:` definitions across the repo, e.g.
|
||||
// todo task create/list/get/update/delete/done, attendance get, approval
|
||||
// submit, chat send / recall, drive upload / download / mkdir / chmod, doc
|
||||
// export, contact search, etc). They are NOT invented: every entry below has
|
||||
// at least one real command using it (or a "<verb>-by-..." / "batch-<verb>"
|
||||
// compound that normalises to it).
|
||||
var (
|
||||
readVerbs = map[string]struct{}{
|
||||
"list": {},
|
||||
"get": {},
|
||||
"search": {},
|
||||
"read": {},
|
||||
"view": {},
|
||||
"query": {},
|
||||
"show": {},
|
||||
"detail": {},
|
||||
"details": {},
|
||||
"status": {},
|
||||
"download": {},
|
||||
"export": {},
|
||||
"fetch": {},
|
||||
"info": {},
|
||||
"find": {},
|
||||
"stats": {},
|
||||
"stat": {},
|
||||
"summary": {},
|
||||
"check": {},
|
||||
"inspect": {},
|
||||
"doctor": {},
|
||||
"version": {},
|
||||
"whoami": {},
|
||||
"ls": {},
|
||||
"cat": {},
|
||||
"describe": {},
|
||||
"diff": {},
|
||||
"preview": {},
|
||||
}
|
||||
|
||||
writeVerbs = map[string]struct{}{
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
"submit": {},
|
||||
"send": {},
|
||||
"done": {},
|
||||
"cancel": {},
|
||||
"offline": {},
|
||||
"online": {},
|
||||
"enable": {},
|
||||
"disable": {},
|
||||
"remove": {},
|
||||
"add": {},
|
||||
"set": {},
|
||||
"unset": {},
|
||||
"approve": {},
|
||||
"reject": {},
|
||||
"write": {},
|
||||
"upload": {},
|
||||
"move": {},
|
||||
"mv": {},
|
||||
"copy": {},
|
||||
"cp": {},
|
||||
"rename": {},
|
||||
"reply": {},
|
||||
"recall": {},
|
||||
"publish": {},
|
||||
"insert": {},
|
||||
"import": {},
|
||||
"install": {},
|
||||
"uninstall": {},
|
||||
"mkdir": {},
|
||||
"share": {},
|
||||
"unshare": {},
|
||||
"commit": {},
|
||||
"reset": {},
|
||||
"stop": {},
|
||||
"start": {},
|
||||
"restart": {},
|
||||
"hide": {},
|
||||
"unhide": {},
|
||||
"finalize": {},
|
||||
"execute": {},
|
||||
"run": {},
|
||||
"exec": {},
|
||||
"chmod": {},
|
||||
"rm": {},
|
||||
"clean": {},
|
||||
"clear": {},
|
||||
"refresh": {},
|
||||
"recover": {},
|
||||
"recovery": {},
|
||||
"login": {},
|
||||
"logout": {},
|
||||
"register": {},
|
||||
"connect": {},
|
||||
"disconnect": {},
|
||||
"upgrade": {},
|
||||
"setup": {},
|
||||
"generate": {},
|
||||
"batch": {},
|
||||
"apply": {},
|
||||
"patch": {},
|
||||
"put": {},
|
||||
"post": {},
|
||||
"sync": {},
|
||||
"link": {},
|
||||
"unlink": {},
|
||||
"grant": {},
|
||||
"revoke": {},
|
||||
"assign": {},
|
||||
"close": {},
|
||||
"open": {},
|
||||
"archive": {},
|
||||
"restore": {},
|
||||
"finish": {},
|
||||
}
|
||||
)
|
||||
|
||||
// defaultClassOverrides is the package-level override table. It is intentionally
|
||||
// empty by default: heuristics are expected to cover the common case, and the
|
||||
// override table exists only to correct individual misclassifications without
|
||||
// touching the heuristic verb tables. Mutate it via SetCmdClassOverride.
|
||||
//
|
||||
// Keys may be either a single verb ("download") or a full space-joined command
|
||||
// path ("doc export"); both are matched case-insensitively. A full-path key
|
||||
// wins over a single-verb key when both are present.
|
||||
var defaultClassOverrides = map[string]CmdClass{}
|
||||
|
||||
// SetCmdClassOverride registers a process-wide classification override for the
|
||||
// given key. The key may be a single verb or a space-joined command path; it is
|
||||
// normalised to lowercase. Passing CmdClassUnknown removes the override.
|
||||
func SetCmdClassOverride(key string, class CmdClass) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if class == CmdClassUnknown {
|
||||
delete(defaultClassOverrides, key)
|
||||
return
|
||||
}
|
||||
defaultClassOverrides[key] = class
|
||||
}
|
||||
|
||||
// ClassifyDwsCommand classifies a dws command given its path segments (e.g.
|
||||
// "todo", "task", "create"). It consults the package-level override table and
|
||||
// then the read/write verb heuristics, scanning segments right-to-left so the
|
||||
// leaf action verb dominates a container/noun segment.
|
||||
//
|
||||
// Remember the SAFETY CONTRACT on CmdClass: a CmdClassUnknown result MUST be
|
||||
// treated as write (require confirmation) by the caller.
|
||||
func ClassifyDwsCommand(parts ...string) CmdClass {
|
||||
return ClassifyDwsCommandWith(defaultClassOverrides, parts...)
|
||||
}
|
||||
|
||||
// ClassifyDwsCommandWith is like ClassifyDwsCommand but lets the caller supply
|
||||
// an explicit override table (e.g. a per-tenant or per-request map) instead of
|
||||
// the package-level one. A nil overrides map is allowed and means "no
|
||||
// overrides". Override lookups always win over the heuristics.
|
||||
func ClassifyDwsCommandWith(overrides map[string]CmdClass, parts ...string) CmdClass {
|
||||
norm := normalizeParts(parts)
|
||||
if len(norm) == 0 {
|
||||
return CmdClassUnknown
|
||||
}
|
||||
|
||||
// 1. Full-path override (most specific) wins.
|
||||
if overrides != nil {
|
||||
fullKey := strings.Join(norm, " ")
|
||||
if c, ok := overrides[fullKey]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk segments right-to-left: the leaf action verb is the most
|
||||
// meaningful, but a leaf may be a noun/placeholder (e.g. an id) that
|
||||
// we do not recognise, so fall back toward the root.
|
||||
for i := len(norm) - 1; i >= 0; i-- {
|
||||
seg := norm[i]
|
||||
if overrides != nil {
|
||||
if c, ok := overrides[seg]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
if c, ok := classifyVerb(seg); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return CmdClassUnknown
|
||||
}
|
||||
|
||||
// classifyVerb classifies a single segment, handling compound verbs such as
|
||||
// "send-by-bot", "list-forms", "batch-update" or "create_inline" by also
|
||||
// trying the token before the first separator. Returns (class, true) when the
|
||||
// segment maps to a known verb.
|
||||
func classifyVerb(seg string) (CmdClass, bool) {
|
||||
candidates := verbCandidates(seg)
|
||||
for _, v := range candidates {
|
||||
if _, ok := readVerbs[v]; ok {
|
||||
return CmdClassRead, true
|
||||
}
|
||||
if _, ok := writeVerbs[v]; ok {
|
||||
return CmdClassWrite, true
|
||||
}
|
||||
}
|
||||
return CmdClassUnknown, false
|
||||
}
|
||||
|
||||
// verbCandidates expands a segment into the tokens worth checking against the
|
||||
// verb tables, in priority order: the whole segment first, then the leading
|
||||
// token before the first '-' or '_' separator (so "batch-update" tries
|
||||
// "update"-as-prefix... no: it tries "batch" first which is itself a write
|
||||
// verb; and "send-by-bot" tries "send"). This handles both "<verb>-<qualifier>"
|
||||
// and "<verb>_<qualifier>" compounds where the action sits at the front.
|
||||
func verbCandidates(seg string) []string {
|
||||
out := []string{seg}
|
||||
if i := strings.IndexAny(seg, "-_"); i > 0 {
|
||||
out = append(out, seg[:i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeParts lowercases, trims and drops empty/whitespace segments so that
|
||||
// callers can pass raw argv-style slices without pre-cleaning them.
|
||||
func normalizeParts(parts []string) []string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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"
|
||||
|
||||
func TestClassifyDwsCommandReadVerbs(t *testing.T) {
|
||||
readLeaves := []string{
|
||||
"list", "get", "search", "read", "view", "query", "show",
|
||||
"detail", "status", "download", "export", "fetch", "info",
|
||||
"find", "summary",
|
||||
}
|
||||
for _, v := range readLeaves {
|
||||
if got := ClassifyDwsCommand("todo", "task", v); got != CmdClassRead {
|
||||
t.Errorf("verb %q: got %v, want CmdClassRead", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandWriteVerbs(t *testing.T) {
|
||||
writeLeaves := []string{
|
||||
"create", "update", "delete", "submit", "send", "done",
|
||||
"cancel", "offline", "enable", "disable", "remove", "add",
|
||||
"set", "approve", "reject", "write", "upload", "move", "copy",
|
||||
}
|
||||
for _, v := range writeLeaves {
|
||||
if got := ClassifyDwsCommand("approval", "instance", v); got != CmdClassWrite {
|
||||
t.Errorf("verb %q: got %v, want CmdClassWrite", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandUnknown(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{},
|
||||
{"todo"}, // container only, no action verb
|
||||
{"todo", "task"}, // still only containers
|
||||
{"frobnicate"}, // nonsense verb
|
||||
{"todo", "task", "wibble"}, // unknown leaf, no known ancestor
|
||||
{" ", ""}, // all empty after trim
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c...); got != CmdClassUnknown {
|
||||
t.Errorf("parts %v: got %v, want CmdClassUnknown", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandRealSamples(t *testing.T) {
|
||||
cases := []struct {
|
||||
parts []string
|
||||
want CmdClass
|
||||
}{
|
||||
// Real dws command paths sampled from the repo's cobra Use: defs.
|
||||
{[]string{"todo", "task", "create"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "list"}, CmdClassRead},
|
||||
{[]string{"todo", "task", "get"}, CmdClassRead},
|
||||
{[]string{"todo", "task", "update"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "delete"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "done"}, CmdClassWrite},
|
||||
{[]string{"approval", "instance", "submit"}, CmdClassWrite},
|
||||
{[]string{"attendance", "get"}, CmdClassRead},
|
||||
{[]string{"chat", "message", "send"}, CmdClassWrite},
|
||||
{[]string{"chat", "message", "list"}, CmdClassRead},
|
||||
{[]string{"drive", "file", "download"}, CmdClassRead},
|
||||
{[]string{"drive", "file", "upload"}, CmdClassWrite},
|
||||
{[]string{"drive", "dir", "mkdir"}, CmdClassWrite},
|
||||
{[]string{"doc", "export"}, CmdClassRead},
|
||||
{[]string{"contact", "user", "search"}, CmdClassRead},
|
||||
{[]string{"report", "status"}, CmdClassRead},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c.parts...); got != c.want {
|
||||
t.Errorf("cmd %v: got %v, want %v", c.parts, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandCompoundVerbs(t *testing.T) {
|
||||
cases := []struct {
|
||||
parts []string
|
||||
want CmdClass
|
||||
}{
|
||||
// Compound leaf verbs that exist in the repo.
|
||||
{[]string{"chat", "send-by-bot"}, CmdClassWrite},
|
||||
{[]string{"chat", "send-by-webhook"}, CmdClassWrite},
|
||||
{[]string{"chat", "recall-by-bot"}, CmdClassWrite},
|
||||
{[]string{"aitable", "form", "list-forms"}, CmdClassRead},
|
||||
{[]string{"wiki", "space", "list-spaces"}, CmdClassRead},
|
||||
{[]string{"record", "batch-update"}, CmdClassWrite},
|
||||
{[]string{"group", "add-bot"}, CmdClassWrite},
|
||||
{[]string{"group", "remove-bot"}, CmdClassWrite},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c.parts...); got != c.want {
|
||||
t.Errorf("compound %v: got %v, want %v", c.parts, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandLeafFallbackToAncestor(t *testing.T) {
|
||||
// When the true leaf is a placeholder/id segment we don't recognise,
|
||||
// classification falls back to the nearest known ancestor verb.
|
||||
if got := ClassifyDwsCommand("todo", "task", "get", "abc123"); got != CmdClassRead {
|
||||
t.Errorf("trailing id segment: got %v, want CmdClassRead", got)
|
||||
}
|
||||
if got := ClassifyDwsCommand("todo", "task", "create", "xyz"); got != CmdClassWrite {
|
||||
t.Errorf("trailing id segment: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdClassOverrideTable(t *testing.T) {
|
||||
// Suppose "download" should require confirmation for some tenant: a
|
||||
// full-path override flips a normally-read command to write without
|
||||
// touching the heuristic tables.
|
||||
overrides := map[string]CmdClass{
|
||||
"drive file download": CmdClassWrite, // full-path override
|
||||
"export": CmdClassWrite, // single-verb override
|
||||
}
|
||||
|
||||
if got := ClassifyDwsCommandWith(overrides, "drive", "file", "download"); got != CmdClassWrite {
|
||||
t.Errorf("full-path override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
// A different path whose leaf is "download" is NOT affected by the
|
||||
// full-path override and stays read.
|
||||
if got := ClassifyDwsCommandWith(overrides, "media", "download"); got != CmdClassRead {
|
||||
t.Errorf("unrelated download path: got %v, want CmdClassRead", got)
|
||||
}
|
||||
// Single-verb override flips every "export" leaf.
|
||||
if got := ClassifyDwsCommandWith(overrides, "doc", "export"); got != CmdClassWrite {
|
||||
t.Errorf("single-verb override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCmdClassOverrideProcessWide(t *testing.T) {
|
||||
defer SetCmdClassOverride("status", CmdClassUnknown) // cleanup
|
||||
|
||||
// Baseline: status is read.
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
|
||||
t.Fatalf("baseline status: got %v, want CmdClassRead", got)
|
||||
}
|
||||
// Register a process-wide override making status write.
|
||||
SetCmdClassOverride("STATUS", CmdClassWrite) // case-insensitive key
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassWrite {
|
||||
t.Errorf("after override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
// Removing the override restores the heuristic result.
|
||||
SetCmdClassOverride("status", CmdClassUnknown)
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
|
||||
t.Errorf("after override removal: got %v, want CmdClassRead", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdClassString(t *testing.T) {
|
||||
cases := map[CmdClass]string{
|
||||
CmdClassRead: "read",
|
||||
CmdClassWrite: "write",
|
||||
CmdClassUnknown: "unknown",
|
||||
}
|
||||
for c, want := range cases {
|
||||
if got := c.String(); got != want {
|
||||
t.Errorf("String(%d): got %q, want %q", c, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// 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 (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。"
|
||||
|
||||
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
||||
// keep one Codex thread per DingTalk conversation.
|
||||
type codexAppServerForwarder struct {
|
||||
bin string
|
||||
env []string
|
||||
timeout time.Duration
|
||||
workDir string
|
||||
model string
|
||||
sessions *codexThreadSessions
|
||||
}
|
||||
|
||||
func newCodexAppServerForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
||||
var sessions *codexThreadSessions
|
||||
if opts.Memory {
|
||||
// Scope the on-disk thread store by clientId so multiple bots on one
|
||||
// machine stay isolated; an empty clientId disables persistence and the
|
||||
// map stays in memory (original behaviour).
|
||||
sessions = newCodexThreadSessions(codexThreadStorePath(clientID))
|
||||
}
|
||||
return &codexAppServerForwarder{
|
||||
bin: bin,
|
||||
env: env,
|
||||
timeout: timeout,
|
||||
workDir: opts.WorkDir,
|
||||
model: opts.Model,
|
||||
sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
// codexThreadStorePath returns the on-disk location for a robot's codex
|
||||
// conversation→thread map, scoped by clientId so multiple bots on one machine
|
||||
// stay isolated: <config dir>/connect/<clientId>/codex-threads.json. It mirrors
|
||||
// the Claude-family connectSessionStorePath layout but uses a distinct filename
|
||||
// so the two stores never collide. An empty clientId means "do not persist"
|
||||
// (in-memory only). The clientId is sanitized with the same rule as the connect
|
||||
// lock file so it is always filesystem-safe.
|
||||
func codexThreadStorePath(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "codex-threads.json")
|
||||
}
|
||||
|
||||
// resetSession drops the conversation's Codex thread so the next message starts
|
||||
// a fresh one. Implements sessionResetter for the built-in /new and /clear
|
||||
// commands. A no-op when per-conversation memory is disabled.
|
||||
func (f *codexAppServerForwarder) resetSession(convID string) {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) canStream() bool { return true }
|
||||
|
||||
func (f *codexAppServerForwarder) label() string {
|
||||
memo := "stateless"
|
||||
if f.sessions != nil {
|
||||
memo = "thread-memory"
|
||||
}
|
||||
return fmt.Sprintf("codex-app-server:%s (%s)", f.bin, memo)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
return f.forwardAppServer(ctx, convID, text, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
var state *codexThreadState
|
||||
if f.sessions != nil {
|
||||
state = f.sessions.state(convID)
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
}
|
||||
|
||||
cli, err := newCodexAppServerClient(ctx, f.bin, f.env, f.cwd())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cli.close()
|
||||
|
||||
if err := cli.initialize(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_ = state // held only for its per-conversation turn lock (see above)
|
||||
threadID := ""
|
||||
if f.sessions != nil {
|
||||
threadID = f.sessions.threadID(convID)
|
||||
}
|
||||
if threadID != "" {
|
||||
resumed, err := cli.resumeThread(ctx, f.threadParams(threadID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][codex] resume thread %s 失败,重建会话: %v\n", threadID, err)
|
||||
threadID = ""
|
||||
f.sessions.setThreadID(convID, "")
|
||||
} else {
|
||||
threadID = resumed
|
||||
}
|
||||
}
|
||||
if threadID == "" {
|
||||
started, err := cli.startThread(ctx, f.threadParams(""))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
threadID = started
|
||||
if f.sessions != nil {
|
||||
f.sessions.setThreadID(convID, threadID)
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := cli.runTurn(ctx, threadID, text, onDelta)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) cwd() string {
|
||||
if f.workDir == "" {
|
||||
return connectWorkDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return f.workDir
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) threadParams(threadID string) map[string]any {
|
||||
params := map[string]any{
|
||||
"approvalPolicy": "never",
|
||||
"cwd": f.cwd(),
|
||||
"developerInstructions": codexRobotDeveloperInstructions,
|
||||
"sandbox": "read-only",
|
||||
}
|
||||
if f.model != "" {
|
||||
params["model"] = f.model
|
||||
}
|
||||
if threadID != "" {
|
||||
params["threadId"] = threadID
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// codexThreadSessions maps a DingTalk conversation to its Codex thread so
|
||||
// multi-turn context survives within a conversation. The convID→threadID map
|
||||
// (threads, guarded by mu) is the authoritative store and is persisted to disk
|
||||
// so the context also survives a connector restart — the codex equivalent of
|
||||
// the Claude-family convSessions store. An empty path keeps it in memory only
|
||||
// (persistence disabled), preserving the original behaviour exactly.
|
||||
//
|
||||
// states holds a per-conversation lock that serializes turns within one
|
||||
// conversation; it carries no thread identity of its own.
|
||||
type codexThreadSessions struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*codexThreadState // per-conversation turn lock
|
||||
threads map[string]string // convID→threadID, persisted
|
||||
path string // on-disk store; empty disables persistence
|
||||
}
|
||||
|
||||
// codexThreadState is a per-conversation turn lock. Holding it for the duration
|
||||
// of a turn keeps two messages in the same conversation from interleaving their
|
||||
// app-server calls; thread identity lives in codexThreadSessions.threads.
|
||||
type codexThreadState struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newCodexThreadSessions builds the session map, restoring any persisted
|
||||
// convID→threadID entries from path. A missing or corrupt file degrades to an
|
||||
// empty map (see loadConvSessionMap) — it never panics or blocks startup. An
|
||||
// empty path means in-memory only.
|
||||
func newCodexThreadSessions(path string) *codexThreadSessions {
|
||||
return &codexThreadSessions{
|
||||
states: make(map[string]*codexThreadState),
|
||||
threads: loadConvSessionMap(path),
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// codexConvKey normalizes a conversation ID into a stable, non-empty map key.
|
||||
func codexConvKey(convID string) string {
|
||||
key := strings.TrimSpace(convID)
|
||||
if key == "" {
|
||||
key = "_default"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// state returns the per-conversation turn lock, minting one on first sight.
|
||||
func (s *codexThreadSessions) state(convID string) *codexThreadState {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st, ok := s.states[key]; ok {
|
||||
return st
|
||||
}
|
||||
st := &codexThreadState{}
|
||||
s.states[key] = st
|
||||
return st
|
||||
}
|
||||
|
||||
// threadID returns the Codex thread bound to a conversation, or "" if none.
|
||||
func (s *codexThreadSessions) threadID(convID string) string {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.threads[key]
|
||||
}
|
||||
|
||||
// setThreadID binds a conversation to a Codex thread and persists the snapshot.
|
||||
// A "" threadID forgets the binding (same as reset). Persistence is best-effort:
|
||||
// a failed save only logs a warning and never blocks message handling.
|
||||
func (s *codexThreadSessions) setThreadID(convID, threadID string) {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
if threadID == "" {
|
||||
delete(s.threads, key)
|
||||
} else {
|
||||
s.threads[key] = threadID
|
||||
}
|
||||
snapshot := make(map[string]string, len(s.threads))
|
||||
for k, v := range s.threads {
|
||||
snapshot[k] = v
|
||||
}
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
saveConvSessionMap(path, snapshot)
|
||||
}
|
||||
|
||||
// reset forgets a conversation's thread so the next message starts a fresh one.
|
||||
// The removal is persisted so a restart does not resurrect the dropped thread.
|
||||
func (s *codexThreadSessions) reset(convID string) {
|
||||
s.setThreadID(convID, "")
|
||||
}
|
||||
|
||||
type codexAppServerClient struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
msgs chan codexRPCMessage
|
||||
readErr chan error
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
stderr *lockedBuffer
|
||||
nextID int
|
||||
}
|
||||
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
type codexRPCMessage struct {
|
||||
ID *int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *codexRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type codexRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, "app-server", "--stdio")
|
||||
cmd.Dir = cwd
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr := &lockedBuffer{}
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cli := &codexAppServerClient{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
msgs: make(chan codexRPCMessage, 64),
|
||||
readErr: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
stderr: stderr,
|
||||
nextID: 1,
|
||||
}
|
||||
go cli.readLoop(stdout)
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) readLoop(stdout io.Reader) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var msg codexRPCMessage
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
c.reportReadErr(fmt.Errorf("parse app-server JSONL: %w", err))
|
||||
close(c.msgs)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.msgs <- msg:
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.reportReadErr(err)
|
||||
} else {
|
||||
c.reportReadErr(io.EOF)
|
||||
}
|
||||
close(c.msgs)
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) close() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.done)
|
||||
_ = c.stdin.Close()
|
||||
if c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
_ = c.cmd.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) reportReadErr(err error) {
|
||||
select {
|
||||
case c.readErr <- err:
|
||||
case <-c.done:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) initialize(ctx context.Context) error {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": map[string]any{
|
||||
"capabilities": map[string]any{"experimentalApi": true},
|
||||
"clientInfo": map[string]any{
|
||||
"name": "dws-devapp-robot-connect",
|
||||
"title": "DWS DevApp Robot Connect",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.waitResponse(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.send(map[string]any{"method": "initialized", "params": map[string]any{}})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) startThread(ctx context.Context, params map[string]any) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{"id": id, "method": "thread/start", "params": params}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) resumeThread(ctx context.Context, params map[string]any) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{"id": id, "method": "thread/resume", "params": params}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, onDelta func(string)) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "turn/start",
|
||||
"params": map[string]any{
|
||||
"input": []map[string]string{{"type": "text", "text": text}},
|
||||
"threadId": threadID,
|
||||
},
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var acc strings.Builder
|
||||
for {
|
||||
msg, err := c.next(ctx)
|
||||
if err != nil {
|
||||
return "", c.withStderr("app-server stream ended before turn/completed", err)
|
||||
}
|
||||
if msg.ID != nil && msg.Method != "" {
|
||||
c.rejectServerRequest(*msg.ID, msg.Method)
|
||||
continue
|
||||
}
|
||||
if msg.ID != nil && *msg.ID == id && msg.Error != nil {
|
||||
return "", fmt.Errorf("turn/start: %s", msg.Error.Message)
|
||||
}
|
||||
switch msg.Method {
|
||||
case "item/agentMessage/delta":
|
||||
var p struct {
|
||||
Delta string `json:"delta"`
|
||||
ThreadID string `json:"threadId"`
|
||||
}
|
||||
if json.Unmarshal(msg.Params, &p) == nil && p.ThreadID == threadID && p.Delta != "" {
|
||||
acc.WriteString(p.Delta)
|
||||
if onDelta != nil {
|
||||
onDelta(acc.String())
|
||||
}
|
||||
}
|
||||
case "turn/completed":
|
||||
final, status, errMsg, ok := codexTurnCompletedText(msg.Params, threadID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if status == "failed" {
|
||||
if errMsg == "" {
|
||||
errMsg = "turn failed"
|
||||
}
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if final == "" {
|
||||
final = strings.TrimSpace(acc.String())
|
||||
}
|
||||
if final == "" {
|
||||
return "", fmt.Errorf("turn completed without agent message")
|
||||
}
|
||||
return final, nil
|
||||
case "error":
|
||||
return "", fmt.Errorf("app-server error notification: %s", truncateRunes(string(msg.Params), 300))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) requestID() int {
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) send(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(c.stdin, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) waitResponse(ctx context.Context, id int) (json.RawMessage, error) {
|
||||
for {
|
||||
msg, err := c.next(ctx)
|
||||
if err != nil {
|
||||
return nil, c.withStderr("app-server exited before response", err)
|
||||
}
|
||||
if msg.ID != nil && msg.Method != "" {
|
||||
c.rejectServerRequest(*msg.ID, msg.Method)
|
||||
continue
|
||||
}
|
||||
if msg.ID == nil || *msg.ID != id {
|
||||
continue
|
||||
}
|
||||
if msg.Error != nil {
|
||||
return nil, fmt.Errorf("%s", msg.Error.Message)
|
||||
}
|
||||
return msg.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) next(ctx context.Context) (codexRPCMessage, error) {
|
||||
// The read loop reports EOF before closing msgs, so when the process
|
||||
// exits right after its last frame both channels are ready and select
|
||||
// would pick one at random — drain buffered frames (e.g. the final
|
||||
// turn/completed) before honoring a read error.
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return codexRPCMessage{}, io.EOF
|
||||
}
|
||||
return msg, nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return codexRPCMessage{}, io.EOF
|
||||
}
|
||||
return msg, nil
|
||||
case err := <-c.readErr:
|
||||
return codexRPCMessage{}, err
|
||||
case <-c.done:
|
||||
return codexRPCMessage{}, io.EOF
|
||||
case <-ctx.Done():
|
||||
return codexRPCMessage{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) rejectServerRequest(id int, method string) {
|
||||
_ = c.send(map[string]any{
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": -32000,
|
||||
"message": "DWS robot connect does not support interactive Codex app-server request: " + method,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) withStderr(prefix string, err error) error {
|
||||
stderr := strings.TrimSpace(c.stderr.String())
|
||||
if stderr == "" {
|
||||
return fmt.Errorf("%s: %w", prefix, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w: %s", prefix, err, truncateRunes(stderr, 300))
|
||||
}
|
||||
|
||||
func codexThreadIDFromResult(result json.RawMessage, err error) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Thread struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"thread"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.Thread.ID == "" {
|
||||
return "", fmt.Errorf("app-server response missing thread.id")
|
||||
}
|
||||
return r.Thread.ID, nil
|
||||
}
|
||||
|
||||
func codexTurnCompletedText(params json.RawMessage, wantThreadID string) (final, status, errMsg string, ok bool) {
|
||||
var p struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
Turn struct {
|
||||
Status string `json:"status"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
AdditionalDetails string `json:"additionalDetails"`
|
||||
} `json:"error"`
|
||||
Items []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"items"`
|
||||
} `json:"turn"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil || p.ThreadID != wantThreadID {
|
||||
return "", "", "", false
|
||||
}
|
||||
for i := len(p.Turn.Items) - 1; i >= 0; i-- {
|
||||
if p.Turn.Items[i].Type == "agentMessage" && strings.TrimSpace(p.Turn.Items[i].Text) != "" {
|
||||
final = strings.TrimSpace(p.Turn.Items[i].Text)
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.Turn.Error != nil {
|
||||
errMsg = strings.TrimSpace(p.Turn.Error.Message)
|
||||
if p.Turn.Error.AdditionalDetails != "" {
|
||||
errMsg = strings.TrimSpace(errMsg + ": " + p.Turn.Error.AdditionalDetails)
|
||||
}
|
||||
}
|
||||
return final, p.Turn.Status, errMsg, true
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeShellExecutable(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
|
||||
t.Fatalf("write stub %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestForwarderForChannelCodexPrefersAppServer(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
stub := t.TempDir()
|
||||
writeShellExecutable(t, stub, "codex", "exit 0\n")
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
fwd, err := forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("codex forwarder: %v", err)
|
||||
}
|
||||
cf, ok := fwd.(*codexAppServerForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("codex should prefer app-server forwarder, got %T", fwd)
|
||||
}
|
||||
if cf.sessions == nil {
|
||||
t.Fatal("codex app-server should keep per-conversation thread memory by default")
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CODEX_APP_SERVER", "0")
|
||||
fwd, err = forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("codex forwarder with deprecated app-server env: %v", err)
|
||||
}
|
||||
if _, ok := fwd.(*codexAppServerForwarder); !ok {
|
||||
t.Fatalf("DWS_CODEX_APP_SERVER=0 should be ignored and keep app-server, got %T", fwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexConnectPlanIgnoresAppServerEnv(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
plan := buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("default codex plan method = %v", got)
|
||||
}
|
||||
payload := connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("default codex memory = %v", got)
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CODEX_APP_SERVER", "0")
|
||||
plan = buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("deprecated app-server env should be ignored, method = %v", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("deprecated app-server env should keep codex memory, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexConnectPlanIgnoresAgentCmdOverride(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_AGENT_CMD", "my-codex exec")
|
||||
plan := buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("codex should ignore DWS_AGENT_CMD and keep app-server plan, method = %v", got)
|
||||
}
|
||||
payload := connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("codex should ignore DWS_AGENT_CMD and keep app-server memory, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderStreamsAndRemembersThread(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "requests.log")
|
||||
codex := writeShellExecutable(t, dir, "codex", `
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$CODEX_STUB_LOG"
|
||||
case "$line" in
|
||||
*\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;;
|
||||
*\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
env: []string{"CODEX_STUB_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newCodexThreadSessions(""),
|
||||
}
|
||||
|
||||
var deltas []string
|
||||
reply, err := fwd.forwardStream(context.Background(), "conv-1", "第一问", func(s string) {
|
||||
deltas = append(deltas, s)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first forward: %v", err)
|
||||
}
|
||||
if reply != "你好" {
|
||||
t.Fatalf("first reply = %q, want 你好", reply)
|
||||
}
|
||||
if strings.Join(deltas, "|") != "你|你好" {
|
||||
t.Fatalf("deltas = %v, want [你 你好]", deltas)
|
||||
}
|
||||
|
||||
if _, err := fwd.forwardStream(context.Background(), "conv-1", "第二问", nil); err != nil {
|
||||
t.Fatalf("second forward: %v", err)
|
||||
}
|
||||
logBytes, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := string(logBytes)
|
||||
if strings.Count(log, `"method":"thread/start"`) != 1 {
|
||||
t.Fatalf("expected one thread/start, log:\n%s", log)
|
||||
}
|
||||
if strings.Count(log, `"method":"thread/resume"`) != 1 {
|
||||
t.Fatalf("expected one thread/resume, log:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderReturnsAppServerError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
codex := writeShellExecutable(t, dir, "codex", `
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*\"method\":\"initialize\"*)
|
||||
printf '%s\n' '{"id":1,"error":{"code":123,"message":"app-server-broken"}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
timeout: 10 * time.Second,
|
||||
workDir: dir,
|
||||
}
|
||||
|
||||
reply, err := fwd.forward(context.Background(), "conv-1", "hello")
|
||||
if err == nil {
|
||||
t.Fatal("forward should return the app-server error instead of falling back to exec")
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("reply = %q, want empty reply on app-server error", reply)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app-server-broken") {
|
||||
t.Fatalf("error = %v, want app-server-broken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerReadLoopStopsWhenClosedWithFullQueue(t *testing.T) {
|
||||
reader, writer := io.Pipe()
|
||||
c := &codexAppServerClient{
|
||||
msgs: make(chan codexRPCMessage, 1),
|
||||
readErr: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
loopDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(loopDone)
|
||||
c.readLoop(reader)
|
||||
}()
|
||||
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
for i := 0; i < 200; i++ {
|
||||
if _, err := fmt.Fprintf(writer, `{"method":"event/%d","params":{}}`+"\n", i); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(c.msgs) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(c.msgs) == 0 {
|
||||
t.Fatal("readLoop did not enqueue the first message")
|
||||
}
|
||||
|
||||
close(c.done)
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
select {
|
||||
case <-loopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("readLoop did not stop after client close")
|
||||
}
|
||||
select {
|
||||
case <-writerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer stayed blocked after pipe close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedBufferConcurrentReadWrite(t *testing.T) {
|
||||
var b lockedBuffer
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_, _ = b.Write([]byte("stderr\n"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
_ = b.String()
|
||||
}
|
||||
close(stop)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexTurnCompletedText(t *testing.T) {
|
||||
final, status, errMsg, ok := codexTurnCompletedText([]byte(`{"threadId":"thr","turn":{"status":"completed","items":[{"type":"agentMessage","text":"早"},{"type":"agentMessage","text":"最终"}]}}`), "thr")
|
||||
if !ok || status != "completed" || final != "最终" || errMsg != "" {
|
||||
t.Fatalf("completed = (%q,%q,%q,%v)", final, status, errMsg, ok)
|
||||
}
|
||||
|
||||
_, status, errMsg, ok = codexTurnCompletedText([]byte(`{"threadId":"thr","turn":{"status":"failed","error":{"message":"boom","additionalDetails":"detail"},"items":[]}}`), "thr")
|
||||
if !ok || status != "failed" || errMsg != "boom: detail" {
|
||||
t.Fatalf("failed = (%q,%q,%v)", status, errMsg, ok)
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCodexThreadSessionsPersist verifies that a codex conversation's thread
|
||||
// survives a connector restart: a second store opened on the same path restores
|
||||
// the convID→threadID mapping, and a reset is persisted too.
|
||||
func TestCodexThreadSessionsPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "codex-threads.json")
|
||||
|
||||
s := newCodexThreadSessions(path)
|
||||
if got := s.threadID("conv-1"); got != "" {
|
||||
t.Fatalf("fresh store threadID = %q, want empty", got)
|
||||
}
|
||||
s.setThreadID("conv-1", "thr_a")
|
||||
s.setThreadID("conv-2", "thr_b")
|
||||
|
||||
// Simulate a restart: a new store on the same path must restore both.
|
||||
restarted := newCodexThreadSessions(path)
|
||||
if got := restarted.threadID("conv-1"); got != "thr_a" {
|
||||
t.Errorf("after restart conv-1 = %q, want thr_a", got)
|
||||
}
|
||||
if got := restarted.threadID("conv-2"); got != "thr_b" {
|
||||
t.Errorf("after restart conv-2 = %q, want thr_b", got)
|
||||
}
|
||||
|
||||
// Reset is persisted: the dropped thread must not resurrect on restart.
|
||||
restarted.reset("conv-1")
|
||||
again := newCodexThreadSessions(path)
|
||||
if got := again.threadID("conv-1"); got != "" {
|
||||
t.Errorf("after reset+restart conv-1 = %q, want empty", got)
|
||||
}
|
||||
if got := again.threadID("conv-2"); got != "thr_b" {
|
||||
t.Errorf("after reset+restart conv-2 = %q, want thr_b (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexThreadSessionsInMemory verifies that an empty path keeps the store
|
||||
// purely in memory: it still works within the process but writes nothing to
|
||||
// disk, preserving the pre-persistence behaviour.
|
||||
func TestCodexThreadSessionsInMemory(t *testing.T) {
|
||||
s := newCodexThreadSessions("")
|
||||
s.setThreadID("conv-1", "thr_a")
|
||||
if got := s.threadID("conv-1"); got != "thr_a" {
|
||||
t.Errorf("in-memory conv-1 = %q, want thr_a", got)
|
||||
}
|
||||
// A separate in-memory store shares no state (nothing was persisted).
|
||||
other := newCodexThreadSessions("")
|
||||
if got := other.threadID("conv-1"); got != "" {
|
||||
t.Errorf("separate in-memory store conv-1 = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// connectControlAction is a built-in slash command a user can type in the chat
|
||||
// to control the connector's per-conversation session, instead of a message
|
||||
// that gets forwarded to the agent. Modelled on codex / OpenClaw connect's
|
||||
// preset commands: the set is fixed (never dynamically extended) so the bot's
|
||||
// behaviour stays predictable.
|
||||
type connectControlAction struct {
|
||||
// name is the canonical action ("new" | "clear"). Both reset the current
|
||||
// conversation's session today (the next message starts fresh with no prior
|
||||
// context), but they stay distinct actions so the ack wording can differ and
|
||||
// a future channel can diverge their behaviour.
|
||||
name string
|
||||
// ack is the user-facing confirmation sent back into the chat (Chinese, for
|
||||
// DingTalk users). It is never forwarded to the agent.
|
||||
ack string
|
||||
}
|
||||
|
||||
// resetsSession reports whether this action should drop the conversation's
|
||||
// agent session. Both built-in commands do today; kept as a predicate so adding
|
||||
// a non-resetting command later does not require touching the call site.
|
||||
func (a connectControlAction) resetsSession() bool {
|
||||
return a.name == "new" || a.name == "clear"
|
||||
}
|
||||
|
||||
// connectControlCommands maps the recognised slash tokens to their action. The
|
||||
// map is the single source of truth for "which commands exist" — both the
|
||||
// parser and any help text should read from it. "/new", "/start" and "/reset"
|
||||
// are aliases for opening a fresh session; "/clear" wipes the current one.
|
||||
var connectControlCommands = map[string]connectControlAction{
|
||||
"/new": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/start": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/reset": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/clear": {name: "clear", ack: "🧹 已清空当前对话的上下文,我们从头开始。"},
|
||||
}
|
||||
|
||||
// parseConnectControlCommand recognises a built-in slash command. It only
|
||||
// matches when the WHOLE trimmed message is exactly one known token (case
|
||||
// insensitive), so a normal question that merely starts with a slash — e.g.
|
||||
// "/new 这个功能怎么实现?" — is forwarded to the agent untouched rather than
|
||||
// silently swallowed as a command. Returns (action, true) on a match.
|
||||
func parseConnectControlCommand(text string) (connectControlAction, bool) {
|
||||
token := strings.ToLower(strings.TrimSpace(text))
|
||||
if token == "" || !strings.HasPrefix(token, "/") {
|
||||
return connectControlAction{}, false
|
||||
}
|
||||
// A command is a bare token: reject anything carrying arguments/whitespace.
|
||||
if strings.ContainsAny(token, " \t\n") {
|
||||
return connectControlAction{}, false
|
||||
}
|
||||
action, ok := connectControlCommands[token]
|
||||
return action, ok
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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"
|
||||
|
||||
func TestParseConnectControlCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantName string // "" means no match
|
||||
}{
|
||||
// Recognised commands and their aliases.
|
||||
{"/new", "new"},
|
||||
{"/start", "new"},
|
||||
{"/reset", "new"},
|
||||
{"/clear", "clear"},
|
||||
// Case-insensitive and trimmed.
|
||||
{"/NEW", "new"},
|
||||
{" /Clear ", "clear"},
|
||||
{"/New\n", "new"},
|
||||
// A normal question that merely starts with a slash is NOT a command —
|
||||
// it must be forwarded to the agent, not swallowed.
|
||||
{"/new 这个功能怎么实现?", ""},
|
||||
{"/clear the cache please", ""},
|
||||
{"/new x", ""},
|
||||
// Non-commands.
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"你好", ""},
|
||||
{"new", ""}, // missing slash
|
||||
{"/unknown", ""}, // unknown token
|
||||
{"/", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseConnectControlCommand(c.in)
|
||||
if c.wantName == "" {
|
||||
if ok {
|
||||
t.Errorf("parseConnectControlCommand(%q) = (%+v, true), want no match", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("parseConnectControlCommand(%q) = no match, want name %q", c.in, c.wantName)
|
||||
continue
|
||||
}
|
||||
if got.name != c.wantName {
|
||||
t.Errorf("parseConnectControlCommand(%q).name = %q, want %q", c.in, got.name, c.wantName)
|
||||
}
|
||||
if got.ack == "" {
|
||||
t.Errorf("parseConnectControlCommand(%q).ack is empty, want a user-facing confirmation", c.in)
|
||||
}
|
||||
if !got.resetsSession() {
|
||||
t.Errorf("parseConnectControlCommand(%q).resetsSession() = false, want true", c.in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// connect_daemon turns the foreground `robot connect` connector into a 7x24
|
||||
// background service. Three responsibilities live here and nowhere else (the
|
||||
// forwarding/session/knowledge logic in devapp_connect.go and connect_stream.go
|
||||
// is untouched):
|
||||
//
|
||||
// 1. detach: `connect --daemon` re-execs dws in supervisor mode in a new
|
||||
// session (POSIX setsid), prints pid + log path, and exits.
|
||||
// 2. supervise: the supervisor process runs the real connector as a worker
|
||||
// child and restarts it with exponential backoff when it crashes.
|
||||
// 3. status/stop: read the daemon pid file, probe liveness (reusing
|
||||
// processAlive from connect_lock.go), and signal a graceful stop.
|
||||
//
|
||||
// Two hidden internal flags select the mode of a re-exec:
|
||||
//
|
||||
// --daemon-supervise : run the supervisor loop (set by the --daemon parent)
|
||||
// --daemon-worker : run a single foreground connector (set by the supervisor)
|
||||
const (
|
||||
daemonSuperviseFlag = "daemon-supervise"
|
||||
daemonWorkerFlag = "daemon-worker"
|
||||
daemonFlag = "daemon"
|
||||
)
|
||||
|
||||
// daemonState is the JSON persisted to the daemon pid file. It records the
|
||||
// supervisor pid plus enough context for `status` to report without re-deriving
|
||||
// it (start time for uptime, log path, the dir key it was filed under).
|
||||
type daemonState struct {
|
||||
Pid int `json:"pid"`
|
||||
StartUnix int64 `json:"startUnix"`
|
||||
LogPath string `json:"logPath"`
|
||||
DirKey string `json:"dirKey"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
}
|
||||
|
||||
// connectDaemonDirOverride lets tests redirect the per-client daemon directory
|
||||
// away from the real ~/.dws tree. Empty means use config.DefaultConfigDir.
|
||||
var connectDaemonDirOverride string
|
||||
|
||||
// daemonDirKey derives a filesystem-safe directory key identifying a connector.
|
||||
// Priority: clientId (the robot's AppKey, the natural identity) > unifiedAppID.
|
||||
// Reuses sanitizeLockID (connect_lock.go) so the key matches the lock naming
|
||||
// convention. Returns "" when neither is available.
|
||||
func daemonDirKey(clientID, unifiedAppID string) string {
|
||||
if v := strings.TrimSpace(clientID); v != "" {
|
||||
return sanitizeLockID(v)
|
||||
}
|
||||
if v := strings.TrimSpace(unifiedAppID); v != "" {
|
||||
return "app-" + sanitizeLockID(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// connectDaemonDir returns <configDir>/connect/<dirKey>, creating it. This holds
|
||||
// daemon.pid and daemon.log for one connector.
|
||||
func connectDaemonDir(dirKey string) (string, error) {
|
||||
base := connectDaemonDirOverride
|
||||
if base == "" {
|
||||
base = config.DefaultConfigDir()
|
||||
}
|
||||
dir := filepath.Join(base, "connect", dirKey)
|
||||
if err := os.MkdirAll(dir, config.DirPerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
|
||||
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
|
||||
|
||||
// writeDaemonState atomically persists the daemon pid file (write temp + rename)
|
||||
// so a reader never sees a half-written file.
|
||||
func writeDaemonState(dir string, st daemonState) error {
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := daemonPidPath(dir) + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, daemonPidPath(dir))
|
||||
}
|
||||
|
||||
// readDaemonState loads the daemon pid file. A missing file yields (nil, nil) so
|
||||
// callers can treat "not running" distinctly from a real I/O error.
|
||||
func readDaemonState(dir string) (*daemonState, error) {
|
||||
data, err := os.ReadFile(daemonPidPath(dir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var st daemonState
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("daemon pid file %s is corrupt: %w", daemonPidPath(dir), err)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// backoffDelay computes the restart delay for the Nth consecutive worker
|
||||
// failure: base * 2^(n-1) capped at cap. Pure and unit-tested. n<=0 returns 0
|
||||
// (first start is immediate). Caller resets n when a worker stays healthy.
|
||||
func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Duration {
|
||||
if consecutiveFailures <= 0 {
|
||||
return 0
|
||||
}
|
||||
d := base
|
||||
for i := 1; i < consecutiveFailures; i++ {
|
||||
d *= 2
|
||||
if d >= maxDelay {
|
||||
return maxDelay
|
||||
}
|
||||
}
|
||||
if d > maxDelay {
|
||||
return maxDelay
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const (
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
// daemonMaxFastFailures is the consecutive fast-failure ceiling: after this
|
||||
// many crashes that each happened within daemonHealthyAfter, the supervisor
|
||||
// gives up rather than spin forever (e.g. bad credentials).
|
||||
daemonMaxFastFailures = 10
|
||||
// daemonHealthyAfter is how long a worker must run before the supervisor
|
||||
// considers it healthy and resets the failure counter.
|
||||
daemonHealthyAfter = 60 * time.Second
|
||||
// daemonStopTimeout bounds the graceful wait in `stop` before SIGKILL.
|
||||
daemonStopTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// buildWorkerArgs rewrites the supervisor's own argv into a worker argv: it
|
||||
// strips the daemon-control flags (--daemon / --daemon-supervise) and appends
|
||||
// --daemon-worker, preserving every other flag (credentials, channel, knowledge,
|
||||
// etc.) so the worker connects exactly as the foreground command would. Pure for
|
||||
// unit testing.
|
||||
func buildWorkerArgs(args []string) []string {
|
||||
out := make([]string, 0, len(args)+1)
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case a == "--"+daemonFlag, a == "--"+daemonSuperviseFlag, a == "--"+daemonWorkerFlag:
|
||||
continue
|
||||
case strings.HasPrefix(a, "--"+daemonFlag+"="),
|
||||
strings.HasPrefix(a, "--"+daemonSuperviseFlag+"="),
|
||||
strings.HasPrefix(a, "--"+daemonWorkerFlag+"="):
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
out = append(out, "--"+daemonWorkerFlag)
|
||||
return out
|
||||
}
|
||||
|
||||
// startDaemon implements `connect --daemon`: it re-execs dws in supervisor mode
|
||||
// detached from the terminal, writes nothing itself to the worker log (the
|
||||
// supervisor does), prints the pid + log path, and returns so the parent exits.
|
||||
func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
|
||||
if !daemonDetachSupported {
|
||||
return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead")
|
||||
}
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("create daemon dir: " + err.Error())
|
||||
}
|
||||
// Refuse to start a second supervisor for the same connector. The Stream
|
||||
// single-instance lock would also catch this at the worker layer, but a
|
||||
// pre-flight check gives a clearer message and avoids an orphaned supervisor.
|
||||
if st, _ := readDaemonState(dir); st != nil && st.Pid > 0 && processAlive(st.Pid) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("a connect daemon is already running for %s (pid %d); use `robot connect status`/`stop`", dirKey, st.Pid))
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
superviseArgs := buildSuperviseArgs(os.Args[1:])
|
||||
|
||||
logPath := daemonLogPath(dir)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("open daemon log: " + err.Error())
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
child := exec.Command(exe, superviseArgs...)
|
||||
child.Stdout = logFile
|
||||
child.Stderr = logFile
|
||||
child.Stdin = nil
|
||||
// Pass the resolved dir key + clientId through the environment so the
|
||||
// supervisor files its pid under the same key the parent computed (rather
|
||||
// than re-deriving and risking a mismatch).
|
||||
child.Env = append(os.Environ(),
|
||||
"DWS_CONNECT_DAEMON_DIRKEY="+dirKey,
|
||||
"DWS_CONNECT_DAEMON_CLIENTID="+clientID,
|
||||
)
|
||||
if connectDaemonDirOverride != "" {
|
||||
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_DIR="+connectDaemonDirOverride)
|
||||
}
|
||||
applyDetach(child)
|
||||
|
||||
if err := child.Start(); err != nil {
|
||||
return apperrors.NewInternal("start daemon: " + err.Error())
|
||||
}
|
||||
// Release the child so the parent can exit without leaving a zombie.
|
||||
pid := child.Process.Pid
|
||||
_ = child.Process.Release()
|
||||
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
|
||||
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
|
||||
fmt.Fprintf(w, " logs: %s\n", logPath)
|
||||
fmt.Fprintf(w, " status: dws devapp robot connect status%s\n", statusHintArgs(clientID, dirKey))
|
||||
fmt.Fprintf(w, " stop: dws devapp robot connect stop%s\n", statusHintArgs(clientID, dirKey))
|
||||
fmt.Fprint(w, connectLocalDebugNotice())
|
||||
}
|
||||
|
||||
// buildSuperviseArgs rewrites argv to run the supervisor: strip --daemon, append
|
||||
// --daemon-supervise. Pure for testing.
|
||||
func buildSuperviseArgs(args []string) []string {
|
||||
out := make([]string, 0, len(args)+1)
|
||||
for _, a := range args {
|
||||
if a == "--"+daemonFlag || strings.HasPrefix(a, "--"+daemonFlag+"=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
out = append(out, "--"+daemonSuperviseFlag)
|
||||
return out
|
||||
}
|
||||
|
||||
func statusHintArgs(clientID, dirKey string) string {
|
||||
if strings.TrimSpace(clientID) != "" {
|
||||
return " --robot-client-id " + clientID
|
||||
}
|
||||
if strings.HasPrefix(dirKey, "app-") {
|
||||
return " --unified-app-id " + strings.TrimPrefix(dirKey, "app-")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runSupervisor is the entry point when dws is started with --daemon-supervise.
|
||||
// It writes the daemon pid file, then loops launching the worker child and
|
||||
// restarting it with backoff until told to stop (SIGTERM/SIGINT) or it exhausts
|
||||
// the fast-failure budget. On stop it forwards the signal to the worker, waits,
|
||||
// and removes its pid file.
|
||||
func runSupervisor(cmd *cobra.Command) error {
|
||||
dirKey := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_DIRKEY"))
|
||||
if dirKey == "" {
|
||||
return apperrors.NewInternal("supervisor started without DWS_CONNECT_DAEMON_DIRKEY")
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_DIR")); v != "" {
|
||||
connectDaemonDirOverride = v
|
||||
}
|
||||
clientID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CLIENTID"))
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("create daemon dir: " + err.Error())
|
||||
}
|
||||
st := daemonState{
|
||||
Pid: os.Getpid(),
|
||||
StartUnix: time.Now().Unix(),
|
||||
LogPath: daemonLogPath(dir),
|
||||
DirKey: dirKey,
|
||||
ClientID: clientID,
|
||||
}
|
||||
if err := writeDaemonState(dir, st); err != nil {
|
||||
return apperrors.NewInternal("write daemon pid file: " + err.Error())
|
||||
}
|
||||
defer os.Remove(daemonPidPath(dir))
|
||||
|
||||
// Route worker stdout/stderr through the shared size-rotating writer
|
||||
// (logging.NewRotatingFile) so a long-running connector's logs don't grow
|
||||
// unbounded. The detached parent already redirected our own fds to the same
|
||||
// daemon.log; from here we append through the rotator. Fall back to stderr if
|
||||
// the rotator can't be opened.
|
||||
var out io.Writer = cmd.ErrOrStderr()
|
||||
if rot, rerr := logging.NewRotatingFile(daemonLogPath(dir)); rerr == nil {
|
||||
defer rot.Close()
|
||||
out = rot
|
||||
}
|
||||
|
||||
// The supervisor reacts to SIGTERM/SIGINT itself (cmd.Context from root is
|
||||
// already wired to these). We use an independent NotifyContext so cancelling
|
||||
// it stops the loop and lets us forward the signal to the worker explicitly.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
workerArgs := buildWorkerArgs(os.Args[1:])
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 {
|
||||
fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
worker := exec.Command(exe, workerArgs...)
|
||||
worker.Stdout = out
|
||||
worker.Stderr = out
|
||||
worker.Env = os.Environ()
|
||||
if err := worker.Start(); err != nil {
|
||||
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
|
||||
failures++
|
||||
if failures >= daemonMaxFastFailures {
|
||||
return apperrors.NewInternal("daemon worker failed to start too many times; giving up")
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
|
||||
|
||||
waitErr := superviseWait(ctx, worker)
|
||||
ran := time.Since(started)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
// We were asked to stop; the worker has been (or is being) signalled.
|
||||
fmt.Fprintln(out, "[daemon] stop requested, worker shut down; exiting supervisor")
|
||||
return nil
|
||||
}
|
||||
|
||||
if ran >= daemonHealthyAfter {
|
||||
failures = 0
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(out, "[daemon] worker exited after %s (err=%v); consecutive failures: %d\n", ran.Round(time.Second), waitErr, failures)
|
||||
if failures >= daemonMaxFastFailures {
|
||||
return apperrors.NewInternal(fmt.Sprintf("daemon worker crashed %d times in a row; giving up (check %s)", failures, daemonLogPath(dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// superviseWait waits for the worker to exit, but if the supervisor's ctx is
|
||||
// cancelled first it forwards SIGTERM to the worker for a graceful shutdown
|
||||
// (releasing the Stream single-instance lock) and then waits.
|
||||
func superviseWait(ctx context.Context, worker *exec.Cmd) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
_ = worker.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(daemonStopTimeout):
|
||||
_ = worker.Process.Kill()
|
||||
return <-done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonStatus reports the state of the connector daemon to w.
|
||||
func daemonStatus(w io.Writer, dirKey string) error {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil {
|
||||
fmt.Fprintf(w, "connect daemon: not running (no pid file under %s)\n", dir)
|
||||
return nil
|
||||
}
|
||||
if st.Pid <= 0 || !processAlive(st.Pid) {
|
||||
fmt.Fprintf(w, "connect daemon: not running (stale pid file for pid %d at %s)\n", st.Pid, daemonPidPath(dir))
|
||||
return nil
|
||||
}
|
||||
uptime := time.Since(time.Unix(st.StartUnix, 0)).Round(time.Second)
|
||||
fmt.Fprintf(w, "connect daemon: running\n")
|
||||
fmt.Fprintf(w, " pid: %d\n", st.Pid)
|
||||
fmt.Fprintf(w, " uptime: %s\n", uptime)
|
||||
fmt.Fprintf(w, " logs: %s\n", st.LogPath)
|
||||
if st.ClientID != "" {
|
||||
fmt.Fprintf(w, " client: %s\n", st.ClientID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
|
||||
// forwards to the worker, which releases the lock and Stream connection), poll
|
||||
// until it exits, escalate to SIGKILL on timeout, and clean up the pid file.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil || st.Pid <= 0 {
|
||||
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
|
||||
return nil
|
||||
}
|
||||
if !processAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(st.Pid)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
}
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
}
|
||||
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
|
||||
|
||||
deadline := time.Now().Add(daemonStopTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
// Graceful window elapsed; force kill.
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStatusCommand implements `dws devapp robot connect
|
||||
// status`: report whether a background connector daemon is running for the
|
||||
// robot identified by --robot-client-id (or --unified-app-id).
|
||||
func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看后台连接器守护进程状态(pid、运行时长、日志路径)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStatus(cmd.OutOrStdout(), dirKey)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStopCommand implements `dws devapp robot connect stop`:
|
||||
// gracefully stop the background connector daemon (SIGTERM, escalate to SIGKILL
|
||||
// on timeout) and clean up its pid file.
|
||||
func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "优雅停止后台连接器守护进程(释放单实例锁与 Stream 连接)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStop(cmd.OutOrStdout(), dirKey)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// connectDaemonDirKeyFromFlags resolves the daemon directory key from the
|
||||
// status/stop flags, requiring at least one identifier.
|
||||
func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
|
||||
clientID := devAppStringFlag(cmd, "robot-client-id")
|
||||
unifiedAppID := devAppStringFlag(cmd, "unified-app-id")
|
||||
dirKey := daemonDirKey(clientID, unifiedAppID)
|
||||
if dirKey == "" {
|
||||
return "", apperrors.NewValidation("需要 --robot-client-id 或 --unified-app-id 以定位守护进程")
|
||||
}
|
||||
return dirKey, nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffDelay(t *testing.T) {
|
||||
base := time.Second
|
||||
cap := 60 * time.Second
|
||||
cases := []struct {
|
||||
failures int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 0},
|
||||
{-1, 0},
|
||||
{1, time.Second},
|
||||
{2, 2 * time.Second},
|
||||
{3, 4 * time.Second},
|
||||
{4, 8 * time.Second},
|
||||
{5, 16 * time.Second},
|
||||
{6, 32 * time.Second},
|
||||
{7, cap}, // 64s capped to 60s
|
||||
{8, cap},
|
||||
{100, cap},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := backoffDelay(c.failures, base, cap); got != c.want {
|
||||
t.Errorf("backoffDelay(%d) = %v, want %v", c.failures, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonDirKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
clientID string
|
||||
unifiedAppID string
|
||||
want string
|
||||
}{
|
||||
{"clientABC", "", "clientABC"},
|
||||
{"client/with:bad*chars", "", "client_with_bad_chars"},
|
||||
{"", "app-123", "app-app-123"},
|
||||
{"", "u/n.id", "app-u_n_id"},
|
||||
{" ", " ", ""},
|
||||
{"cid", "uid", "cid"}, // clientID wins
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := daemonDirKey(c.clientID, c.unifiedAppID); got != c.want {
|
||||
t.Errorf("daemonDirKey(%q,%q) = %q, want %q", c.clientID, c.unifiedAppID, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
||||
got := buildWorkerArgs(in)
|
||||
joined := strings.Join(got, " ")
|
||||
if strings.Contains(joined, "--daemon ") || strings.HasSuffix(joined, "--daemon") {
|
||||
// only the appended --daemon-worker may contain "daemon"
|
||||
}
|
||||
if !strings.HasSuffix(joined, "--daemon-worker") {
|
||||
t.Errorf("worker args must end with --daemon-worker, got %q", joined)
|
||||
}
|
||||
for _, a := range got[:len(got)-1] {
|
||||
if a == "--daemon" || a == "--daemon-supervise" || a == "--daemon-worker" {
|
||||
t.Errorf("daemon-control flag leaked into worker args: %q", a)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(joined, "--robot-client-id abc") || !strings.Contains(joined, "--channel=claudecode") {
|
||||
t.Errorf("credential/channel flags must be preserved, got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSuperviseArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc"}
|
||||
got := buildSuperviseArgs(in)
|
||||
joined := strings.Join(got, " ")
|
||||
if !strings.HasSuffix(joined, "--daemon-supervise") {
|
||||
t.Errorf("supervise args must end with --daemon-supervise, got %q", joined)
|
||||
}
|
||||
if strings.Contains(joined, " --daemon ") || strings.Contains(joined, " --daemon\b") {
|
||||
t.Errorf("--daemon should be stripped, got %q", joined)
|
||||
}
|
||||
for _, a := range got[:len(got)-1] {
|
||||
if a == "--daemon" {
|
||||
t.Errorf("--daemon leaked into supervise args: %q", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConnectDaemonStartedIncludesLocalDebugNotice(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeConnectDaemonStarted(&buf, 1234, "/tmp/daemon.log", "cid", "cid")
|
||||
out := buf.String()
|
||||
for _, want := range []string{"connect daemon started", "/tmp/daemon.log", "本地调试", "不代表线上发布完成", "不会提交版本发布"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("daemon started output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStateRoundTrip(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
dir, err := connectDaemonDir("roundtrip")
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir: %v", err)
|
||||
}
|
||||
// No file yet → nil, nil.
|
||||
if st, err := readDaemonState(dir); err != nil || st != nil {
|
||||
t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err)
|
||||
}
|
||||
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid"}
|
||||
if err := writeDaemonState(dir, want); err != nil {
|
||||
t.Fatalf("writeDaemonState: %v", err)
|
||||
}
|
||||
got, err := readDaemonState(dir)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("readDaemonState: (%v,%v)", got, err)
|
||||
}
|
||||
if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath {
|
||||
t.Errorf("round trip mismatch: got %+v want %+v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDaemonStateCorrupt(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("corrupt")
|
||||
if err := os.WriteFile(daemonPidPath(dir), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readDaemonState(dir); err == nil {
|
||||
t.Error("expected error for corrupt pid file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "nope"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
t.Errorf("expected 'not running', got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusStalePid(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("stale")
|
||||
// pid that is essentially certain to be dead.
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), LogPath: "/l", DirKey: "stale"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "stale"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "stale pid file") {
|
||||
t.Errorf("expected stale pid report, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("live")
|
||||
// Use our own pid: guaranteed alive.
|
||||
writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "live"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"running", "pid:", "uptime:", "/l.log", "cidX"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status output missing %q; got %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "ghost"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
t.Errorf("expected 'not running', got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopStaleCleansPidFile(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("stalestop")
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "stalestop"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale pid file should be removed, stat err=%v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "stale") {
|
||||
t.Errorf("expected stale cleanup message, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// deadPid returns a pid that is not alive. It spawns `true`, waits for it to
|
||||
// exit, and returns its pid — guaranteed reaped and gone.
|
||||
func deadPid(t *testing.T) int {
|
||||
t.Helper()
|
||||
// A very high pid is almost never live; combine with a sanity probe.
|
||||
for _, candidate := range []int{999999, 524287, 99999} {
|
||||
if !processAlive(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
t.Skip("could not find a guaranteed-dead pid on this host")
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// daemonDetachSupported reports whether the current OS can detach a daemon.
|
||||
const daemonDetachSupported = true
|
||||
|
||||
// detachSysProcAttr returns the SysProcAttr that detaches a child from the
|
||||
// controlling terminal: Setsid starts a new session so the child survives the
|
||||
// parent shell closing (no SIGHUP) and has no controlling tty. Used for the
|
||||
// `connect --daemon` re-exec. Unix-only; Windows uses the stub.
|
||||
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
||||
// applyDetach configures cmd so it runs in its own session, detached from the
|
||||
// terminal.
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// daemonDetachSupported is false on Windows: the daemon uses POSIX setsid +
|
||||
// signal-based stop, which has no direct equivalent here. `connect --daemon`
|
||||
// errors out on Windows and users should run the foreground connector under a
|
||||
// Windows service wrapper instead.
|
||||
const daemonDetachSupported = false
|
||||
|
||||
// applyDetach is a no-op on Windows; daemon mode is rejected earlier.
|
||||
func applyDetach(_ *exec.Cmd) {}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Lightweight local retrieval for the Q&A scenario: --knowledge-dir loads
|
||||
// .md/.txt files, chunks them, and prepends the best-matching chunks to each
|
||||
// forwarded question. The agent CLI itself keeps running from the clean
|
||||
// scratch dir — retrieval injects knowledge without the workdir-scan slowdown
|
||||
// that would miss DingTalk's AI-assistant reply window (the connector's core
|
||||
// latency constraint, see execForwarder.forward).
|
||||
const (
|
||||
knowledgeTopK = 3
|
||||
knowledgeMaxChunkRunes = 1200
|
||||
knowledgeMaxPromptRunes = 3000
|
||||
knowledgeMaxFileBytes = 1 << 20
|
||||
)
|
||||
|
||||
type knowledgeChunk struct {
|
||||
source string
|
||||
text string
|
||||
terms map[string]int
|
||||
}
|
||||
|
||||
type knowledgeBase struct {
|
||||
chunks []knowledgeChunk
|
||||
}
|
||||
|
||||
// loadKnowledgeBase walks dir for .md/.txt files (hidden dirs skipped) and
|
||||
// indexes them into scored-retrieval chunks.
|
||||
// knowledgeTextExts is the set of plain-text extensions the retriever indexes.
|
||||
var knowledgeTextExts = []string{".md", ".markdown", ".mdx", ".txt", ".text"}
|
||||
|
||||
// isKnowledgeTextExt reports whether ext is an indexable plain-text extension.
|
||||
func isKnowledgeTextExt(ext string) bool {
|
||||
ext = strings.ToLower(ext)
|
||||
for _, e := range knowledgeTextExts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadKnowledgeBase(dir string) (*knowledgeBase, error) {
|
||||
kb := &knowledgeBase{}
|
||||
root := filepath.Clean(dir)
|
||||
indexed, skipped := 0, 0
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && strings.HasPrefix(d.Name(), ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isKnowledgeTextExt(filepath.Ext(path)) {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
if info, ierr := d.Info(); ierr == nil && info.Size() > knowledgeMaxFileBytes {
|
||||
fmt.Fprintf(os.Stderr, "[connect][knowledge] 跳过超大文件 %s\n", path)
|
||||
return nil
|
||||
}
|
||||
raw, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, chunk := range splitKnowledgeChunks(string(raw)) {
|
||||
kb.chunks = append(kb.chunks, knowledgeChunk{source: rel, text: chunk, terms: knowledgeTerms(chunk)})
|
||||
}
|
||||
indexed++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Tell the operator when files were skipped — a knowledge dir full of .pdf /
|
||||
// .docx / .json indexes nothing, which otherwise looks like "knowledge has no
|
||||
// effect" (issue #32). Only the .md/.txt text family is indexed.
|
||||
if skipped > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[connect][knowledge] 已索引 %d 个文本文件,跳过 %d 个非文本文件(仅索引 %s)\n",
|
||||
indexed, skipped, strings.Join(knowledgeTextExts, "/"))
|
||||
}
|
||||
if len(kb.chunks) == 0 {
|
||||
return nil, fmt.Errorf("知识目录 %s 下没有可用的文本内容(仅索引 %s;pdf/docx/json 等不会被索引)", dir, strings.Join(knowledgeTextExts, "/"))
|
||||
}
|
||||
return kb, nil
|
||||
}
|
||||
|
||||
// splitKnowledgeChunks cuts a document on markdown headings, merging lines up
|
||||
// to knowledgeMaxChunkRunes so a chunk carries enough context to answer with.
|
||||
func splitKnowledgeChunks(doc string) []string {
|
||||
var chunks []string
|
||||
var cur []string
|
||||
curLen := 0
|
||||
flush := func() {
|
||||
if s := strings.TrimSpace(strings.Join(cur, "\n")); s != "" {
|
||||
chunks = append(chunks, s)
|
||||
}
|
||||
cur = cur[:0]
|
||||
curLen = 0
|
||||
}
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "#") && curLen > 0 {
|
||||
flush()
|
||||
}
|
||||
cur = append(cur, line)
|
||||
curLen += len([]rune(line))
|
||||
if curLen >= knowledgeMaxChunkRunes {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
// knowledgeTerms tokenizes for matching: lowercase latin/digit words (len>=2)
|
||||
// plus CJK bigrams — CJK has no word boundaries and bigrams are the standard
|
||||
// cheap approximation.
|
||||
func knowledgeTerms(s string) map[string]int {
|
||||
terms := map[string]int{}
|
||||
var word []rune
|
||||
var prevCJK rune
|
||||
flushWord := func() {
|
||||
if len(word) >= 2 {
|
||||
terms[strings.ToLower(string(word))]++
|
||||
}
|
||||
word = word[:0]
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9':
|
||||
word = append(word, r)
|
||||
prevCJK = 0
|
||||
case unicode.Is(unicode.Han, r):
|
||||
flushWord()
|
||||
if prevCJK != 0 {
|
||||
terms[string([]rune{prevCJK, r})]++
|
||||
}
|
||||
prevCJK = r
|
||||
default:
|
||||
flushWord()
|
||||
prevCJK = 0
|
||||
}
|
||||
}
|
||||
flushWord()
|
||||
return terms
|
||||
}
|
||||
|
||||
// augment returns the prompt to forward: the original question, or the
|
||||
// question prefixed with the top-k matching knowledge chunks. No match means
|
||||
// no prefix — the agent answers from its own knowledge as before.
|
||||
func (kb *knowledgeBase) augment(question string) string {
|
||||
if kb == nil || len(kb.chunks) == 0 {
|
||||
return question
|
||||
}
|
||||
qTerms := knowledgeTerms(question)
|
||||
if len(qTerms) == 0 {
|
||||
return question
|
||||
}
|
||||
type scored struct {
|
||||
idx int
|
||||
hits int // distinct query terms present
|
||||
occ int // total occurrences, tie-break
|
||||
}
|
||||
var matches []scored
|
||||
for i, c := range kb.chunks {
|
||||
hits, occ := 0, 0
|
||||
for t := range qTerms {
|
||||
if n := c.terms[t]; n > 0 {
|
||||
hits++
|
||||
occ += n
|
||||
}
|
||||
}
|
||||
if hits > 0 {
|
||||
matches = append(matches, scored{i, hits, occ})
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
// Observability for "knowledge has no effect" (issue #32): the KB loaded
|
||||
// but nothing in it overlapped this question, so no context was injected.
|
||||
fmt.Fprintf(os.Stderr, "[connect][knowledge] 本条未命中知识库(%d 个片段中无相关内容)\n", len(kb.chunks))
|
||||
return question
|
||||
}
|
||||
sort.Slice(matches, func(a, b int) bool {
|
||||
if matches[a].hits != matches[b].hits {
|
||||
return matches[a].hits > matches[b].hits
|
||||
}
|
||||
return matches[a].occ > matches[b].occ
|
||||
})
|
||||
var b strings.Builder
|
||||
b.WriteString("以下是本地知识库中可能相关的资料,请优先依据它们回答;与问题无关时忽略:\n")
|
||||
used := 0
|
||||
for k := 0; k < len(matches) && k < knowledgeTopK; k++ {
|
||||
c := kb.chunks[matches[k].idx]
|
||||
text := truncateRunes(c.text, knowledgeMaxChunkRunes)
|
||||
used += len([]rune(text))
|
||||
if k > 0 && used > knowledgeMaxPromptRunes {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "\n【资料%d · %s】\n%s\n", k+1, c.source, text)
|
||||
}
|
||||
b.WriteString("\n用户问题:")
|
||||
b.WriteString(question)
|
||||
injected := len(matches)
|
||||
if injected > knowledgeTopK {
|
||||
injected = knowledgeTopK
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][knowledge] 本条命中并注入 %d 个知识片段\n", injected)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeKnowledgeDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
faq := `# 安装
|
||||
|
||||
dws 通过 brew install dws 安装,升级用 dws upgrade。
|
||||
|
||||
# 缓存问题
|
||||
|
||||
出现幽灵命令或命令缺失时,运行 dws cache refresh 强制刷新发现配置缓存。
|
||||
`
|
||||
bot := `# 机器人建联
|
||||
|
||||
用 dws devapp robot create 建号,dws devapp robot connect 建联。
|
||||
卡片停在数据加载中说明回复超过了 AI 助理应答窗口。
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "faq.md"), []byte(faq), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "bot.md"), []byte(bot), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Non-knowledge files are ignored.
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte{0x89, 0x50}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseLoadAndChunk(t *testing.T) {
|
||||
kb, err := loadKnowledgeBase(writeKnowledgeDir(t))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// faq.md has 2 heading-chunks, bot.md has 1.
|
||||
if len(kb.chunks) != 3 {
|
||||
t.Fatalf("chunks = %d, want 3", len(kb.chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAugmentPicksRelevantChunk(t *testing.T) {
|
||||
kb, err := loadKnowledgeBase(writeKnowledgeDir(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := kb.augment("机器人卡在数据加载中怎么办")
|
||||
if !strings.Contains(got, "AI 助理应答窗口") {
|
||||
t.Fatalf("augment missed the bot chunk:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "用户问题:机器人卡在数据加载中怎么办") {
|
||||
t.Fatalf("augment must keep the original question:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "bot.md") {
|
||||
t.Fatalf("augment must cite the source file:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAugmentEnglishTerms(t *testing.T) {
|
||||
kb, err := loadKnowledgeBase(writeKnowledgeDir(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := kb.augment("cache refresh 是干什么的")
|
||||
if !strings.Contains(got, "强制刷新发现配置缓存") {
|
||||
t.Fatalf("augment missed the cache chunk:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeAugmentNoMatchPassesThrough(t *testing.T) {
|
||||
kb, err := loadKnowledgeBase(writeKnowledgeDir(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := "qqqq zzzz xxxx"
|
||||
if got := kb.augment(q); got != q {
|
||||
t.Fatalf("no-match should pass through, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseEmptyDirErrors(t *testing.T) {
|
||||
if _, err := loadKnowledgeBase(t.TempDir()); err == nil {
|
||||
t.Fatal("empty dir should error so a typo'd --knowledge-dir is loud")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeNilBasePassesThrough(t *testing.T) {
|
||||
var kb *knowledgeBase
|
||||
if got := kb.augment("你好"); got != "你好" {
|
||||
t.Fatalf("nil kb should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnowledgeTextExt(t *testing.T) {
|
||||
for _, e := range []string{".md", ".MD", ".markdown", ".mdx", ".txt", ".text"} {
|
||||
if !isKnowledgeTextExt(e) {
|
||||
t.Fatalf("%q should be an indexable text ext", e)
|
||||
}
|
||||
}
|
||||
for _, e := range []string{".pdf", ".docx", ".json", ".png", ""} {
|
||||
if isKnowledgeTextExt(e) {
|
||||
t.Fatalf("%q should NOT be indexable", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseIndexesMarkdownFamily(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.markdown"), []byte("# 标题\n\n内容关于考勤制度的说明。"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.text"), []byte("纯文本资料一段。"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kb, err := loadKnowledgeBase(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(kb.chunks) == 0 {
|
||||
t.Fatal(".markdown/.text should be indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseNoTextFilesError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "doc.pdf"), []byte("%PDF-1.7"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := loadKnowledgeBase(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "没有可用的文本内容") {
|
||||
t.Fatalf("a dir with only non-text files should error clearly, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// connect_knowledge_wiki extends the local-file knowledge retriever
|
||||
// (connect_knowledge.go) to a DingTalk knowledge base (wiki) source. It does
|
||||
// NOT reimplement any DingTalk API call: it reuses the existing `doc` tools that
|
||||
// back `dws doc list` / `dws doc read` (list_nodes + get_document_content) via
|
||||
// the shared executor.Runner, dumps each document as markdown text, and feeds
|
||||
// that into the very same chunker/retriever the local-directory source uses.
|
||||
//
|
||||
// On startup the connector pulls the wiki space, caches it under
|
||||
// ~/.dws/connect/<clientId>/knowledge/wiki-<spaceId>/, and builds the knowledge
|
||||
// base from that directory. A pull failure (network / permission) degrades to a
|
||||
// stale cache if one exists, otherwise to an empty knowledge base — it never
|
||||
// blocks the connection.
|
||||
|
||||
// knowledgeSourceKind classifies a --knowledge-source value.
|
||||
type knowledgeSourceKind int
|
||||
|
||||
const (
|
||||
// knowledgeSourceDir is a plain local directory (the legacy behavior, kept
|
||||
// for any value without a recognized scheme prefix).
|
||||
knowledgeSourceDir knowledgeSourceKind = iota
|
||||
// knowledgeSourceWiki is a wiki:<spaceId> source — a whole knowledge space.
|
||||
knowledgeSourceWiki
|
||||
// knowledgeSourceDoc is a doc:<docId> source — a single document node.
|
||||
knowledgeSourceDoc
|
||||
)
|
||||
|
||||
type knowledgeSource struct {
|
||||
kind knowledgeSourceKind
|
||||
// ref is the directory path (dir), the spaceId (wiki) or the nodeId (doc).
|
||||
ref string
|
||||
}
|
||||
|
||||
// parseKnowledgeSource splits a --knowledge-source value into its kind and ref.
|
||||
// "wiki:<spaceId>" / "doc:<docId>" select the DingTalk knowledge base; anything
|
||||
// else (including bare paths) is treated as a local directory, so the legacy
|
||||
// --knowledge-dir semantics keep working when routed through here.
|
||||
func parseKnowledgeSource(raw string) (knowledgeSource, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return knowledgeSource{}, fmt.Errorf("empty knowledge source")
|
||||
}
|
||||
if rest, ok := cutKnowledgeScheme(value, "wiki"); ok {
|
||||
if rest == "" {
|
||||
return knowledgeSource{}, fmt.Errorf("knowledge source %q is missing a spaceId (expected wiki:<spaceId>)", raw)
|
||||
}
|
||||
return knowledgeSource{kind: knowledgeSourceWiki, ref: rest}, nil
|
||||
}
|
||||
if rest, ok := cutKnowledgeScheme(value, "doc"); ok {
|
||||
if rest == "" {
|
||||
return knowledgeSource{}, fmt.Errorf("knowledge source %q is missing a docId (expected doc:<docId>)", raw)
|
||||
}
|
||||
return knowledgeSource{kind: knowledgeSourceDoc, ref: rest}, nil
|
||||
}
|
||||
return knowledgeSource{kind: knowledgeSourceDir, ref: value}, nil
|
||||
}
|
||||
|
||||
// cutKnowledgeScheme returns the part after "scheme:" and true when value starts
|
||||
// with that scheme. The scheme match is case-insensitive; the ref is trimmed.
|
||||
func cutKnowledgeScheme(value, scheme string) (string, bool) {
|
||||
prefix := scheme + ":"
|
||||
if len(value) < len(prefix) || !strings.EqualFold(value[:len(prefix)], prefix) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(value[len(prefix):]), true
|
||||
}
|
||||
|
||||
// wikiNode is one document node listed under a wiki space.
|
||||
type wikiNode struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
|
||||
// wikiFetcher abstracts the two reused doc capabilities so the pull logic can be
|
||||
// unit-tested with a fake (no real DingTalk calls). The real implementation
|
||||
// (runnerWikiFetcher) drives executor.Runner.
|
||||
type wikiFetcher interface {
|
||||
// listNodes returns the document nodes under a wiki space.
|
||||
listNodes(ctx context.Context, spaceID string) ([]wikiNode, error)
|
||||
// readContent returns a node's content as plain markdown text.
|
||||
readContent(ctx context.Context, nodeID string) (string, error)
|
||||
}
|
||||
|
||||
// runnerWikiFetcher reuses the existing `doc` tools through the shared runner.
|
||||
// It calls the exact RPCs that back `dws doc list` (list_nodes) and
|
||||
// `dws doc read --content-format markdown` (get_document_content); no new API
|
||||
// surface is added here.
|
||||
type runnerWikiFetcher struct {
|
||||
runner executor.Runner
|
||||
}
|
||||
|
||||
const (
|
||||
// wikiListPageLimit bounds pagination so a huge / misconfigured space cannot
|
||||
// spin the pull loop forever at startup.
|
||||
wikiListPageLimit = 50
|
||||
// wikiListPageSize is the per-page request size for list_nodes.
|
||||
wikiListPageSize = 50
|
||||
)
|
||||
|
||||
func (f runnerWikiFetcher) listNodes(ctx context.Context, spaceID string) ([]wikiNode, error) {
|
||||
var nodes []wikiNode
|
||||
seen := map[string]struct{}{}
|
||||
pageToken := ""
|
||||
for page := 0; page < wikiListPageLimit; page++ {
|
||||
params := map[string]any{
|
||||
"workspaceId": spaceID,
|
||||
"pageSize": wikiListPageSize,
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["pageToken"] = pageToken
|
||||
}
|
||||
inv := executor.NewHelperInvocation("doc list", "doc", "list_nodes", params)
|
||||
res, err := f.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range collectWikiNodes(res.Response) {
|
||||
if n.id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[n.id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[n.id] = struct{}{}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
pageToken = firstStringForKeys(res.Response, "nextPageToken", "pageToken", "nextToken")
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (f runnerWikiFetcher) readContent(ctx context.Context, nodeID string) (string, error) {
|
||||
inv := executor.NewHelperInvocation("doc read", "doc", "get_document_content", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"format": "markdown",
|
||||
})
|
||||
res, err := f.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return firstStringForKeys(res.Response, "markdown", "content", "text", "data"), nil
|
||||
}
|
||||
|
||||
// loadWikiKnowledgeBase pulls a wiki space (or a single doc node) into the local
|
||||
// cache and builds the knowledge base from it. cacheRoot is typically
|
||||
// ~/.dws/connect/<clientId>/knowledge. On a pull failure it falls back to an
|
||||
// existing (stale) cache, and finally to an empty base — it never returns an
|
||||
// error, so the connector keeps starting. Progress / warnings go to logw.
|
||||
func loadWikiKnowledgeBase(ctx context.Context, fetcher wikiFetcher, src knowledgeSource, cacheRoot string, logw io.Writer) *knowledgeBase {
|
||||
if logw == nil {
|
||||
logw = io.Discard
|
||||
}
|
||||
cacheDir := filepath.Join(cacheRoot, wikiCacheLeaf(src))
|
||||
|
||||
docs, err := pullWikiDocs(ctx, fetcher, src)
|
||||
if err != nil {
|
||||
// Pull failed: try the previous cache before giving up.
|
||||
if kb, lerr := loadKnowledgeBase(cacheDir); lerr == nil {
|
||||
fmt.Fprintf(logw, "[connect][knowledge] 拉取知识库失败(%v),使用旧缓存兜底:%s\n", err, cacheDir)
|
||||
return kb
|
||||
}
|
||||
fmt.Fprintf(logw, "[connect][knowledge] 拉取知识库失败(%v)且无可用缓存,以空知识起,不阻断连接\n", err)
|
||||
return &knowledgeBase{}
|
||||
}
|
||||
|
||||
if writeErr := writeWikiCache(cacheDir, docs); writeErr != nil {
|
||||
// Cache write is best-effort: build straight from what we pulled so a
|
||||
// read-only / full disk does not break the feature.
|
||||
fmt.Fprintf(logw, "[connect][knowledge] 缓存写入失败(%v),改用内存构建\n", writeErr)
|
||||
return buildKnowledgeBaseFromDocs(docs)
|
||||
}
|
||||
|
||||
kb, lerr := loadKnowledgeBase(cacheDir)
|
||||
if lerr != nil {
|
||||
// Cache wrote but produced no usable chunks (e.g. all docs empty):
|
||||
// degrade to an empty base rather than failing the connector.
|
||||
fmt.Fprintf(logw, "[connect][knowledge] 知识库无可用内容(%v),以空知识起\n", lerr)
|
||||
return &knowledgeBase{}
|
||||
}
|
||||
fmt.Fprintf(logw, "[connect][knowledge] 知识库已拉取并缓存:%d 个文档 / %d 个片段(%s)\n", len(docs), len(kb.chunks), cacheDir)
|
||||
return kb
|
||||
}
|
||||
|
||||
// wikiDoc is one pulled document: a stable file stem plus its markdown text.
|
||||
type wikiDoc struct {
|
||||
stem string
|
||||
text string
|
||||
}
|
||||
|
||||
// pullWikiDocs fetches the documents for a source. For a wiki space it lists the
|
||||
// nodes then reads each; for a single doc it reads that one node. Per-node read
|
||||
// failures are skipped (logged by the caller via the returned set being smaller)
|
||||
// rather than aborting the whole pull. A hard failure (list error, or zero
|
||||
// readable docs) is returned so the caller can fall back to cache.
|
||||
func pullWikiDocs(ctx context.Context, fetcher wikiFetcher, src knowledgeSource) ([]wikiDoc, error) {
|
||||
switch src.kind {
|
||||
case knowledgeSourceDoc:
|
||||
text, err := fetcher.readContent(ctx, src.ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("doc %s 内容为空", src.ref)
|
||||
}
|
||||
return []wikiDoc{{stem: sanitizeWikiStem(src.ref), text: text}}, nil
|
||||
case knowledgeSourceWiki:
|
||||
nodes, err := fetcher.listNodes(ctx, src.ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("wiki space %s 下没有文档节点", src.ref)
|
||||
}
|
||||
var docs []wikiDoc
|
||||
for _, n := range nodes {
|
||||
text, rerr := fetcher.readContent(ctx, n.id)
|
||||
if rerr != nil || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
stem := n.name
|
||||
if strings.TrimSpace(stem) == "" {
|
||||
stem = n.id
|
||||
}
|
||||
docs = append(docs, wikiDoc{stem: sanitizeWikiStem(stem), text: text})
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil, fmt.Errorf("wiki space %s 下没有可读文档", src.ref)
|
||||
}
|
||||
return docs, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("source kind %d is not a wiki/doc source", src.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// buildKnowledgeBaseFromDocs chunks pulled docs directly into a knowledge base,
|
||||
// reusing the same chunker/term-index as the local-file path. Used when the
|
||||
// cache write fails but the pull succeeded.
|
||||
func buildKnowledgeBaseFromDocs(docs []wikiDoc) *knowledgeBase {
|
||||
kb := &knowledgeBase{}
|
||||
for _, d := range docs {
|
||||
source := d.stem + ".md"
|
||||
for _, chunk := range splitKnowledgeChunks(d.text) {
|
||||
kb.chunks = append(kb.chunks, knowledgeChunk{source: source, text: chunk, terms: knowledgeTerms(chunk)})
|
||||
}
|
||||
}
|
||||
return kb
|
||||
}
|
||||
|
||||
// writeWikiCache replaces the cache directory with the freshly pulled docs (one
|
||||
// .md per document) so loadKnowledgeBase can index it like any local directory.
|
||||
// It clears stale files first so deleted wiki docs do not linger in answers.
|
||||
func writeWikiCache(cacheDir string, docs []wikiDoc) error {
|
||||
if err := os.RemoveAll(cacheDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
used := map[string]int{}
|
||||
for _, d := range docs {
|
||||
stem := d.stem
|
||||
if stem == "" {
|
||||
stem = "doc"
|
||||
}
|
||||
// De-duplicate stems so two same-named docs do not overwrite each other.
|
||||
if n := used[stem]; n > 0 {
|
||||
used[stem] = n + 1
|
||||
stem = fmt.Sprintf("%s-%d", stem, n+1)
|
||||
} else {
|
||||
used[stem] = 1
|
||||
}
|
||||
path := filepath.Join(cacheDir, stem+".md")
|
||||
if err := os.WriteFile(path, []byte(d.text), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wikiCacheLeaf is the per-source cache subdirectory name, e.g. "wiki-<spaceId>"
|
||||
// or "doc-<docId>", with the id sanitized for filesystem safety.
|
||||
func wikiCacheLeaf(src knowledgeSource) string {
|
||||
prefix := "wiki"
|
||||
if src.kind == knowledgeSourceDoc {
|
||||
prefix = "doc"
|
||||
}
|
||||
return prefix + "-" + sanitizeWikiStem(src.ref)
|
||||
}
|
||||
|
||||
// sanitizeWikiStem maps an id/name to a safe file stem: path separators and
|
||||
// other awkward characters become '_', so a doc title or URL-ish id cannot
|
||||
// escape the cache directory.
|
||||
func sanitizeWikiStem(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "doc"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
stem := strings.Trim(b.String(), "._")
|
||||
if stem == "" {
|
||||
return "doc"
|
||||
}
|
||||
if len(stem) > 80 {
|
||||
stem = stem[:80]
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// collectWikiNodes walks a list_nodes response and gathers every {id, name}
|
||||
// node it can find. It is tolerant of wrapper layers (content/result/data) and
|
||||
// of the exact id/name field spelling. A map is treated as a node only when it
|
||||
// carries an id *directly* (shallow lookup, not the recursive extractor) —
|
||||
// otherwise a wrapper object would be mistaken for a single node and sibling
|
||||
// nodes in a list would be skipped.
|
||||
func collectWikiNodes(resp map[string]any) []wikiNode {
|
||||
var nodes []wikiNode
|
||||
var walk func(any)
|
||||
walk = func(cur any) {
|
||||
switch x := cur.(type) {
|
||||
case map[string]any:
|
||||
if id := shallowStringForKeys(x, "nodeId", "nodeID", "node_id", "id"); id != "" {
|
||||
name := shallowStringForKeys(x, "name", "title", "nodeName")
|
||||
nodes = append(nodes, wikiNode{id: id, name: name})
|
||||
return
|
||||
}
|
||||
for _, v := range x {
|
||||
walk(v)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
walk(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(resp)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// shallowStringForKeys returns the first non-empty string value for any of keys
|
||||
// at this map level only (no recursion), preserving key priority order.
|
||||
func shallowStringForKeys(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstStringForKeys returns the first non-empty string found for any of keys by
|
||||
// walking the value recursively (depth-first). It is the shared, source-shape
|
||||
// tolerant extractor used for both node ids and document content.
|
||||
func firstStringForKeys(v any, keys ...string) string {
|
||||
keySet := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
keySet[k] = struct{}{}
|
||||
}
|
||||
var walk func(any) string
|
||||
walk = func(cur any) string {
|
||||
switch x := cur.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
if s, ok := x[k].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for key, val := range x {
|
||||
if _, direct := keySet[key]; direct {
|
||||
continue
|
||||
}
|
||||
if found := walk(val); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
if found := walk(item); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return walk(v)
|
||||
}
|
||||
|
||||
// loadConnectKnowledgeSource resolves a --knowledge-source value and builds the
|
||||
// knowledge base. A bare path (or dir: semantics) reuses the strict local-file
|
||||
// loader (a typo'd directory stays loud, matching --knowledge-dir). A
|
||||
// wiki:/doc: source pulls from the DingTalk knowledge base, caches it under
|
||||
// ~/.dws/connect/<clientId>/knowledge, and never blocks the connection on a pull
|
||||
// failure. A nil knowledge base means "leave any existing base untouched".
|
||||
func loadConnectKnowledgeSource(cmd *cobra.Command, runner executor.Runner, clientID, raw string) (*knowledgeBase, error) {
|
||||
src, err := parseKnowledgeSource(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logw := cmd.ErrOrStderr()
|
||||
switch src.kind {
|
||||
case knowledgeSourceDir:
|
||||
kb, lerr := loadKnowledgeBase(src.ref)
|
||||
if lerr != nil {
|
||||
return nil, lerr
|
||||
}
|
||||
fmt.Fprintf(logw, "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), src.ref)
|
||||
return kb, nil
|
||||
case knowledgeSourceWiki, knowledgeSourceDoc:
|
||||
fetcher := runnerWikiFetcher{runner: runner}
|
||||
cacheRoot := connectKnowledgeCacheRoot(clientID)
|
||||
return loadWikiKnowledgeBase(cmd.Context(), fetcher, src, cacheRoot, logw), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported knowledge source kind %d", src.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// connectKnowledgeCacheRoot is the per-robot knowledge cache root:
|
||||
// ~/.dws/connect/<clientId>/knowledge. clientId is sanitized so it cannot escape
|
||||
// the directory; an empty clientId degrades to a shared "default" bucket.
|
||||
func connectKnowledgeCacheRoot(clientID string) string {
|
||||
id := sanitizeWikiStem(clientID)
|
||||
if id == "" {
|
||||
id = "default"
|
||||
}
|
||||
return filepath.Join(homeDir(), ".dws", "connect", id, "knowledge")
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeWikiFetcher is an in-test wikiFetcher: no DingTalk calls, fully scripted.
|
||||
type fakeWikiFetcher struct {
|
||||
nodes []wikiNode
|
||||
content map[string]string
|
||||
listErr error
|
||||
readErrFor map[string]error
|
||||
}
|
||||
|
||||
func (f fakeWikiFetcher) listNodes(_ context.Context, _ string) ([]wikiNode, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.nodes, nil
|
||||
}
|
||||
|
||||
func (f fakeWikiFetcher) readContent(_ context.Context, nodeID string) (string, error) {
|
||||
if f.readErrFor != nil {
|
||||
if err, ok := f.readErrFor[nodeID]; ok {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return f.content[nodeID], nil
|
||||
}
|
||||
|
||||
// ① --knowledge-source syntax parses wiki:<spaceId> / doc:<docId> correctly.
|
||||
func TestParseKnowledgeSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
wantKind knowledgeSourceKind
|
||||
wantRef string
|
||||
wantErr bool
|
||||
}{
|
||||
{"wiki:SPACE123", knowledgeSourceWiki, "SPACE123", false},
|
||||
{" wiki:SPACE123 ", knowledgeSourceWiki, "SPACE123", false},
|
||||
{"WIKI:Sp", knowledgeSourceWiki, "Sp", false}, // scheme is case-insensitive
|
||||
{"doc:NODE9", knowledgeSourceDoc, "NODE9", false},
|
||||
{"/var/knowledge", knowledgeSourceDir, "/var/knowledge", false},
|
||||
{"./local", knowledgeSourceDir, "./local", false},
|
||||
{"wiki:", 0, "", true},
|
||||
{"doc:", 0, "", true},
|
||||
{"", 0, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := parseKnowledgeSource(c.raw)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseKnowledgeSource(%q) expected error, got %+v", c.raw, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseKnowledgeSource(%q) unexpected error: %v", c.raw, err)
|
||||
continue
|
||||
}
|
||||
if got.kind != c.wantKind || got.ref != c.wantRef {
|
||||
t.Errorf("parseKnowledgeSource(%q) = {%d,%q}, want {%d,%q}", c.raw, got.kind, got.ref, c.wantKind, c.wantRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ A pulled wiki space lands in the retriever and the right chunk is retrieved.
|
||||
func TestLoadWikiKnowledgeBaseRetrieves(t *testing.T) {
|
||||
fetcher := fakeWikiFetcher{
|
||||
nodes: []wikiNode{
|
||||
{id: "n1", name: "安装指南"},
|
||||
{id: "n2", name: "缓存问题"},
|
||||
},
|
||||
content: map[string]string{
|
||||
"n1": "# 安装\n\ndws 通过 brew install dws 安装。",
|
||||
"n2": "# 缓存问题\n\n出现幽灵命令时运行 dws cache refresh 强制刷新发现配置缓存。",
|
||||
},
|
||||
}
|
||||
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
||||
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
||||
if kb == nil || len(kb.chunks) == 0 {
|
||||
t.Fatalf("expected a non-empty knowledge base, got %+v", kb)
|
||||
}
|
||||
got := kb.augment("cache refresh 是干什么的")
|
||||
if !strings.Contains(got, "强制刷新发现配置缓存") {
|
||||
t.Fatalf("augment missed the cache chunk pulled from wiki:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ A doc:<docId> source pulls a single node into the retriever.
|
||||
func TestLoadWikiKnowledgeBaseSingleDoc(t *testing.T) {
|
||||
fetcher := fakeWikiFetcher{
|
||||
content: map[string]string{
|
||||
"NODE9": "# 机器人建联\n\n卡片停在数据加载中说明回复超过了 AI 助理应答窗口。",
|
||||
},
|
||||
}
|
||||
src := knowledgeSource{kind: knowledgeSourceDoc, ref: "NODE9"}
|
||||
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
||||
if kb == nil || len(kb.chunks) == 0 {
|
||||
t.Fatalf("expected a non-empty knowledge base, got %+v", kb)
|
||||
}
|
||||
if got := kb.augment("数据加载中怎么办"); !strings.Contains(got, "AI 助理应答窗口") {
|
||||
t.Fatalf("augment missed the single doc chunk:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ② Pull failure with no prior cache degrades to an empty (non-nil) base and
|
||||
// does not panic.
|
||||
func TestLoadWikiKnowledgeBaseFailNoCacheEmpty(t *testing.T) {
|
||||
fetcher := fakeWikiFetcher{listErr: errors.New("network down")}
|
||||
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
||||
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
||||
if kb == nil {
|
||||
t.Fatal("expected a non-nil empty base, got nil")
|
||||
}
|
||||
if len(kb.chunks) != 0 {
|
||||
t.Fatalf("expected empty base on failure, got %d chunks", len(kb.chunks))
|
||||
}
|
||||
// augment on an empty base must pass the question through unchanged.
|
||||
if got := kb.augment("任意问题"); got != "任意问题" {
|
||||
t.Fatalf("empty base should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ② Pull failure falls back to a previously written (stale) cache.
|
||||
func TestLoadWikiKnowledgeBaseFailUsesStaleCache(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
||||
|
||||
// First pull succeeds and populates the cache.
|
||||
good := fakeWikiFetcher{
|
||||
nodes: []wikiNode{{id: "n1", name: "缓存问题"}},
|
||||
content: map[string]string{"n1": "# 缓存问题\n\n运行 dws cache refresh 强制刷新发现配置缓存。"},
|
||||
}
|
||||
if kb := loadWikiKnowledgeBase(context.Background(), good, src, cacheRoot, io.Discard); len(kb.chunks) == 0 {
|
||||
t.Fatal("warm-up pull should have populated the cache")
|
||||
}
|
||||
|
||||
// Second pull fails; must fall back to the stale cache, not go empty.
|
||||
bad := fakeWikiFetcher{listErr: errors.New("permission denied")}
|
||||
kb := loadWikiKnowledgeBase(context.Background(), bad, src, cacheRoot, io.Discard)
|
||||
if kb == nil || len(kb.chunks) == 0 {
|
||||
t.Fatalf("expected stale-cache fallback to keep chunks, got %+v", kb)
|
||||
}
|
||||
if got := kb.augment("cache refresh"); !strings.Contains(got, "强制刷新发现配置缓存") {
|
||||
t.Fatalf("stale cache content not retrievable:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node read failures are skipped without aborting the whole pull.
|
||||
func TestPullWikiDocsSkipsUnreadableNodes(t *testing.T) {
|
||||
fetcher := fakeWikiFetcher{
|
||||
nodes: []wikiNode{{id: "ok", name: "好文档"}, {id: "bad", name: "坏文档"}},
|
||||
content: map[string]string{
|
||||
"ok": "# 好文档\n\n这是可读内容。",
|
||||
"bad": "",
|
||||
},
|
||||
readErrFor: map[string]error{"bad": errors.New("403")},
|
||||
}
|
||||
docs, err := pullWikiDocs(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceWiki, ref: "S"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(docs) != 1 || !strings.Contains(docs[0].text, "可读内容") {
|
||||
t.Fatalf("expected only the readable doc, got %+v", docs)
|
||||
}
|
||||
}
|
||||
|
||||
// collectWikiNodes is tolerant of wrapper layers and id/name field spellings.
|
||||
func TestCollectWikiNodesTolerant(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"content": map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{"nodeId": "a", "name": "Alpha"},
|
||||
map[string]any{"id": "b", "title": "Beta"},
|
||||
},
|
||||
},
|
||||
}
|
||||
nodes := collectWikiNodes(resp)
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d (%+v)", len(nodes), nodes)
|
||||
}
|
||||
if nodes[0].id != "a" || nodes[0].name != "Alpha" || nodes[1].id != "b" || nodes[1].name != "Beta" {
|
||||
t.Fatalf("node extraction wrong: %+v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeWikiStem(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"SPACE123": "SPACE123",
|
||||
"a/b/../c": "a_b_.._c",
|
||||
"https://x/nodes/N9?q=1": "https___x_nodes_N9_q_1",
|
||||
"": "doc",
|
||||
"...": "doc",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeWikiStem(in); got != want {
|
||||
t.Errorf("sanitizeWikiStem(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// connectLockDir is a var so tests can isolate lock files.
|
||||
var connectLockDir = os.TempDir()
|
||||
|
||||
// acquireConnectLock guards against two local connectors holding Stream
|
||||
// connections for the same robot. DingTalk load-balances pushes across
|
||||
// connections sharing one clientId, so a duplicate connector makes the bot
|
||||
// answer intermittently — the hardest-to-diagnose failure mode this command
|
||||
// has (verified live). The lock is a pid file; a stale file whose owning
|
||||
// process is gone is taken over silently.
|
||||
func acquireConnectLock(clientID string) (release func(), err error) {
|
||||
path := filepath.Join(connectLockDir, "dws-connect-"+sanitizeLockID(clientID)+".pid")
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if openErr == nil {
|
||||
fmt.Fprintf(f, "%d", os.Getpid())
|
||||
f.Close()
|
||||
return func() { _ = os.Remove(path) }, nil
|
||||
}
|
||||
if !os.IsExist(openErr) {
|
||||
return nil, openErr
|
||||
}
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr == nil {
|
||||
if pid, perr := strconv.Atoi(strings.TrimSpace(string(raw))); perr == nil && pid > 0 && processAlive(pid) {
|
||||
return nil, fmt.Errorf("机器人 %s 已有连接器在本机运行 (pid %d)。同一机器人的多个 Stream 连接会随机分流消息,表现为机器人时灵时不灵;请先停掉旧进程,或确认后删除 %s", clientID, pid, path)
|
||||
}
|
||||
}
|
||||
// Stale lock (owner gone or file unreadable): take over.
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return nil, fmt.Errorf("无法获取连接器锁 %s", path)
|
||||
}
|
||||
|
||||
// sanitizeLockID keeps the lock filename filesystem-safe.
|
||||
func sanitizeLockID(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, s)
|
||||
}
|
||||
|
||||
// processAlive reports whether pid refers to a live process (unix signal-0
|
||||
// probe; a permission error still means "alive").
|
||||
func processAlive(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withLockDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
old := connectLockDir
|
||||
dir := t.TempDir()
|
||||
connectLockDir = dir
|
||||
t.Cleanup(func() { connectLockDir = old })
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestConnectLockBlocksSecond: a live lock (this test process's pid) must
|
||||
// reject a second acquire for the same robot, with the dual-connection
|
||||
// explanation in the error.
|
||||
func TestConnectLockBlocksSecond(t *testing.T) {
|
||||
withLockDir(t)
|
||||
release, err := acquireConnectLock("ding-bot-1")
|
||||
if err != nil {
|
||||
t.Fatalf("first acquire: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
if _, err := acquireConnectLock("ding-bot-1"); err == nil {
|
||||
t.Fatal("second acquire succeeded, want rejection")
|
||||
} else if !strings.Contains(err.Error(), "已有连接器在本机运行") {
|
||||
t.Fatalf("error = %v, want dual-connection explanation", err)
|
||||
}
|
||||
|
||||
// A different robot is unaffected.
|
||||
release2, err := acquireConnectLock("ding-bot-2")
|
||||
if err != nil {
|
||||
t.Fatalf("other robot acquire: %v", err)
|
||||
}
|
||||
release2()
|
||||
}
|
||||
|
||||
// TestConnectLockReleaseAndReacquire: release removes the pid file and the
|
||||
// same robot can be locked again.
|
||||
func TestConnectLockReleaseAndReacquire(t *testing.T) {
|
||||
dir := withLockDir(t)
|
||||
release, err := acquireConnectLock("ding-bot-1")
|
||||
if err != nil {
|
||||
t.Fatalf("acquire: %v", err)
|
||||
}
|
||||
release()
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
||||
t.Fatalf("lock file not removed: %v", entries)
|
||||
}
|
||||
release2, err := acquireConnectLock("ding-bot-1")
|
||||
if err != nil {
|
||||
t.Fatalf("reacquire after release: %v", err)
|
||||
}
|
||||
release2()
|
||||
}
|
||||
|
||||
// TestConnectLockStaleTakeover: a lock owned by a dead pid is taken over.
|
||||
func TestConnectLockStaleTakeover(t *testing.T) {
|
||||
dir := withLockDir(t)
|
||||
// pid far beyond any OS pid range — guaranteed not alive.
|
||||
stale := filepath.Join(dir, "dws-connect-ding-bot-1.pid")
|
||||
if err := os.WriteFile(stale, []byte("999999999"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release, err := acquireConnectLock("ding-bot-1")
|
||||
if err != nil {
|
||||
t.Fatalf("stale takeover failed: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
// TestConnectLockGarbageFile: an unreadable/garbage lock is treated as stale.
|
||||
func TestConnectLockGarbageFile(t *testing.T) {
|
||||
dir := withLockDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "dws-connect-ding-bot-1.pid"), []byte("not-a-pid"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release, err := acquireConnectLock("ding-bot-1")
|
||||
if err != nil {
|
||||
t.Fatalf("garbage lock takeover failed: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// mediaMaxDownloadBytes caps a single inbound image download (screenshots are
|
||||
// well under this; the cap is a hostile-input guard).
|
||||
const mediaMaxDownloadBytes = 20 << 20
|
||||
|
||||
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
||||
// loosely-typed content payload (the stream SDK models Content as
|
||||
// interface{}).
|
||||
func pictureDownloadCode(content interface{}) string {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"downloadCode", "pictureDownloadCode"} {
|
||||
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// downloadMessageFile resolves a chatbot media callback (picture etc.) to a
|
||||
// local temp file via the robot messageFiles/download API. Error-screenshot
|
||||
// questions are the top Q&A inbound; without this the connector silently
|
||||
// drops every picture message.
|
||||
func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downloadCode string) (string, error) {
|
||||
raw, err := c.callRaw(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"downloadCode": downloadCode,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.DownloadUrl) == "" {
|
||||
return "", fmt.Errorf("messageFiles/download 未返回 downloadUrl: %s", truncateRunes(raw, 200))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.DownloadUrl, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("媒体下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type")))
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// mediaExt picks a file extension from the response content type, falling
|
||||
// back to the URL path, then ".png" (DingTalk screenshots default to png).
|
||||
func mediaExt(rawURL, contentType string) string {
|
||||
ct := strings.ToLower(contentType)
|
||||
switch {
|
||||
case strings.Contains(ct, "png"):
|
||||
return ".png"
|
||||
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
|
||||
return ".jpg"
|
||||
case strings.Contains(ct, "gif"):
|
||||
return ".gif"
|
||||
case strings.Contains(ct, "webp"):
|
||||
return ".webp"
|
||||
}
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
if ext := strings.ToLower(filepath.Ext(u.Path)); len(ext) >= 2 && len(ext) <= 5 {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
return ".png"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPictureDownloadCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content interface{}
|
||||
want string
|
||||
}{
|
||||
{"downloadCode", map[string]interface{}{"downloadCode": "dc-1"}, "dc-1"},
|
||||
{"pictureDownloadCode fallback", map[string]interface{}{"pictureDownloadCode": "dc-2"}, "dc-2"},
|
||||
{"blank ignored", map[string]interface{}{"downloadCode": " "}, ""},
|
||||
{"wrong type", "not-a-map", ""},
|
||||
{"nil", nil, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := pictureDownloadCode(tc.content); got != tc.want {
|
||||
t.Fatalf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadMessageFile drives the full resolve-then-fetch flow against a
|
||||
// fake API: token → messageFiles/download (must carry robotCode+downloadCode)
|
||||
// → presigned GET → local temp file.
|
||||
func TestDownloadMessageFile(t *testing.T) {
|
||||
var gotDownloadReq map[string]any
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-acs-dingtalk-access-token") != "tok-1" {
|
||||
t.Error("missing access token header")
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotDownloadReq)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"downloadUrl": srv.URL + "/file"})
|
||||
})
|
||||
mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("PNGDATA"))
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
path, err := c.downloadMessageFile(context.Background(), "ding-client", "dc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadMessageFile: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
if gotDownloadReq["robotCode"] != "ding-client" || gotDownloadReq["downloadCode"] != "dc-1" {
|
||||
t.Fatalf("download request payload = %v", gotDownloadReq)
|
||||
}
|
||||
if !strings.HasSuffix(path, ".png") {
|
||||
t.Fatalf("path = %q, want .png suffix from content type", path)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "PNGDATA" {
|
||||
t.Fatalf("saved file = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileNoURL(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
if _, err := c.downloadMessageFile(context.Background(), "ding-client", "dc-1"); err == nil {
|
||||
t.Fatal("missing downloadUrl should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaExt(t *testing.T) {
|
||||
cases := []struct{ url, ct, want string }{
|
||||
{"https://x/y", "image/png", ".png"},
|
||||
{"https://x/y", "image/jpeg", ".jpg"},
|
||||
{"https://x/y.webp?sig=1", "", ".webp"},
|
||||
{"https://x/y", "", ".png"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := mediaExt(tc.url, tc.ct); got != tc.want {
|
||||
t.Fatalf("mediaExt(%q, %q) = %q, want %q", tc.url, tc.ct, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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 (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
// connectCreds carries credentials resolved by the interactive onboarding flow.
|
||||
type connectCreds struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
source string // how they were resolved, for the [connect] log line
|
||||
}
|
||||
|
||||
// connectStdinInteractive reports whether stdin is a real terminal (char
|
||||
// device), so the onboarding prompt only runs interactively and never blocks a
|
||||
// script, a daemon, or a piped invocation — those keep the original
|
||||
// "need a flag" error.
|
||||
func connectStdinInteractive() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// runConnectOnboarding interactively resolves robot credentials when `connect`
|
||||
// was run without any (no --robot-client-id/secret, no --unified-app-id). It
|
||||
// asks whether to provision a NEW robot app or reuse an EXISTING one, then
|
||||
// collects the minimum input per path. in/out are injected for testability.
|
||||
//
|
||||
// The "new app" path has a REAL side effect: it provisions a real robot app via
|
||||
// the dev-app create/poll tools. The "existing app" path only reads identifiers
|
||||
// and reuses the same credentials path as --unified-app-id.
|
||||
func runConnectOnboarding(runner executor.Runner, cmd *cobra.Command, in io.Reader, out io.Writer) (connectCreds, error) {
|
||||
r := bufio.NewReader(in)
|
||||
fmt.Fprintln(out, "未检测到机器人凭证,进入建联引导。")
|
||||
choice, err := connectPromptLine(r, out, "新建应用还是已有应用?输入 1=新建应用并建号,2=使用已有应用 [2]: ")
|
||||
if err != nil {
|
||||
return connectCreds{}, err
|
||||
}
|
||||
switch choice {
|
||||
case "1":
|
||||
return onboardNewApp(runner, cmd, r, out)
|
||||
case "2", "":
|
||||
return onboardExistingApp(runner, cmd, r, out)
|
||||
default:
|
||||
return connectCreds{}, apperrors.NewValidation(fmt.Sprintf("无效选项 %q:只能输入 1 或 2", choice))
|
||||
}
|
||||
}
|
||||
|
||||
// connectPromptLine writes prompt to out and reads one trimmed line from r.
|
||||
func connectPromptLine(r *bufio.Reader, out io.Writer, prompt string) (string, error) {
|
||||
fmt.Fprint(out, prompt)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", apperrors.NewValidation("读取输入失败或已取消")
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
// onboardExistingApp resolves credentials for an existing robot app: it asks for
|
||||
// a unified app id (reusing the --unified-app-id credentials path) or, failing
|
||||
// that, an explicit clientId/clientSecret pair.
|
||||
func onboardExistingApp(runner executor.Runner, cmd *cobra.Command, r *bufio.Reader, out io.Writer) (connectCreds, error) {
|
||||
appID, err := connectPromptLine(r, out, "输入统一应用 ID(unified-app-id,留空改为直接填凭证): ")
|
||||
if err != nil {
|
||||
return connectCreds{}, err
|
||||
}
|
||||
if appID != "" {
|
||||
id, secret, ferr := devAppFetchCredentials(runner, cmd, appID)
|
||||
if ferr != nil {
|
||||
return connectCreds{}, ferr
|
||||
}
|
||||
if id == "" || secret == "" {
|
||||
return connectCreds{}, apperrors.NewInternal("credentials get 未返回 clientId/clientSecret;clientSecret 可能仅建号时返回一次,请改用现成机器人凭证")
|
||||
}
|
||||
return connectCreds{clientID: id, clientSecret: secret, source: "onboarding:unified-app-id"}, nil
|
||||
}
|
||||
id, _ := connectPromptLine(r, out, "输入机器人 clientId(AppKey): ")
|
||||
secret, _ := connectPromptLine(r, out, "输入机器人 clientSecret(AppSecret): ")
|
||||
if id == "" || secret == "" {
|
||||
return connectCreds{}, apperrors.NewValidation("clientId/clientSecret 不能为空")
|
||||
}
|
||||
return connectCreds{clientID: id, clientSecret: secret, source: "onboarding:robot-client"}, nil
|
||||
}
|
||||
|
||||
// onboardNewApp provisions a NEW robot app: it collects the app name, robot
|
||||
// display name and description, submits the async create task, then polls for
|
||||
// the result to obtain credentials. This creates a real robot app.
|
||||
func onboardNewApp(runner executor.Runner, cmd *cobra.Command, r *bufio.Reader, out io.Writer) (connectCreds, error) {
|
||||
name, _ := connectPromptLine(r, out, "应用名称(2-20 字,企业内唯一): ")
|
||||
robotName, _ := connectPromptLine(r, out, "机器人名称(客户端展示用): ")
|
||||
desc, _ := connectPromptLine(r, out, "机器人功能描述: ")
|
||||
if name == "" || robotName == "" || desc == "" {
|
||||
return connectCreds{}, apperrors.NewValidation("应用名称、机器人名称、功能描述均为必填")
|
||||
}
|
||||
fmt.Fprintln(out, "正在提交建号任务…(这会创建一个真实的机器人应用)")
|
||||
taskID, err := submitRobotCreateTask(runner, cmd, name, robotName, desc)
|
||||
if err != nil {
|
||||
return connectCreds{}, err
|
||||
}
|
||||
if taskID == "" {
|
||||
return connectCreds{}, apperrors.NewInternal("建号任务未返回 taskId")
|
||||
}
|
||||
fmt.Fprintf(out, "建号任务已提交 taskId=%s,等待结果…\n", taskID)
|
||||
return pollRobotCreateResult(runner, cmd, taskID, out)
|
||||
}
|
||||
|
||||
// submitRobotCreateTask calls the async robot-create tool and returns its taskId.
|
||||
func submitRobotCreateTask(runner executor.Runner, cmd *cobra.Command, name, robotName, desc string) (string, error) {
|
||||
params := map[string]any{
|
||||
"name": name,
|
||||
"robotName": robotName,
|
||||
"desc": desc,
|
||||
"iconMediaId": "",
|
||||
"previewMediaId": "",
|
||||
}
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), devAppProduct, devAppRobotSubmitTool, params)
|
||||
res, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := devAppConnectUnwrap(res.Response)
|
||||
return devAppConnectFirst(payload, "taskId", "taskID", "task_id"), nil
|
||||
}
|
||||
|
||||
// pollRobotCreateResult polls the async create-result tool until credentials are
|
||||
// available or the attempt budget is exhausted. Provisioning may take a few
|
||||
// seconds (and possibly admin approval), so it polls with a bounded budget and
|
||||
// degrades to an actionable hint on timeout.
|
||||
func pollRobotCreateResult(runner executor.Runner, cmd *cobra.Command, taskID string, out io.Writer) (connectCreds, error) {
|
||||
const maxAttempts = 10
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), devAppProduct, devAppRobotResultTool, map[string]any{"taskId": taskID})
|
||||
res, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return connectCreds{}, err
|
||||
}
|
||||
payload := devAppConnectUnwrap(res.Response)
|
||||
id := devAppConnectFirst(payload, "clientId", "appKey", "clientID")
|
||||
secret := devAppConnectFirst(payload, "clientSecret", "appSecret")
|
||||
if id != "" && secret != "" {
|
||||
return connectCreds{clientID: id, clientSecret: secret, source: "onboarding:provisioned"}, nil
|
||||
}
|
||||
status := devAppConnectFirst(payload, "status", "taskStatus", "state")
|
||||
if isRobotCreateFailed(status) {
|
||||
return connectCreds{}, apperrors.NewInternal(fmt.Sprintf("建号失败,taskId=%s 状态=%s", taskID, status))
|
||||
}
|
||||
if attempt < maxAttempts {
|
||||
fmt.Fprintf(out, " 建号进行中(%d/%d,状态=%s),稍候…\n", attempt, maxAttempts, status)
|
||||
_ = sleepCtx(cmd.Context(), 3*time.Second)
|
||||
}
|
||||
}
|
||||
return connectCreds{}, apperrors.NewInternal(fmt.Sprintf("建号结果轮询超时,taskId=%s;可稍后用 `dws dev app robot result --task-id %s` 继续查询", taskID, taskID))
|
||||
}
|
||||
|
||||
func isRobotCreateFailed(status string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
return s == "fail" || s == "failed" || s == "error"
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// onboardSeqRunner returns canned responses in call order and records each
|
||||
// invocation so tests can assert which tool was called with what params, with
|
||||
// no real network/side effect.
|
||||
type onboardSeqRunner struct {
|
||||
responses []map[string]any
|
||||
calls []executor.Invocation
|
||||
}
|
||||
|
||||
func (r *onboardSeqRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
r.calls = append(r.calls, inv)
|
||||
var resp map[string]any
|
||||
if i := len(r.calls) - 1; i < len(r.responses) {
|
||||
resp = r.responses[i]
|
||||
}
|
||||
return executor.Result{Response: resp}, nil
|
||||
}
|
||||
|
||||
func TestRunConnectOnboardingExistingUnifiedApp(t *testing.T) {
|
||||
runner := &onboardSeqRunner{responses: []map[string]any{
|
||||
{"clientId": "cid-1", "clientSecret": "sec-1"}, // credentials get
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
creds, err := runConnectOnboarding(runner, &cobra.Command{}, strings.NewReader("2\nUNIFIED-123\n"), &out)
|
||||
if err != nil {
|
||||
t.Fatalf("onboarding: %v", err)
|
||||
}
|
||||
if creds.clientID != "cid-1" || creds.clientSecret != "sec-1" {
|
||||
t.Fatalf("creds = %+v, want cid-1/sec-1", creds)
|
||||
}
|
||||
if len(runner.calls) != 1 || runner.calls[0].Params["unifiedAppId"] != "UNIFIED-123" {
|
||||
t.Fatalf("expected one credentials-get with unifiedAppId=UNIFIED-123, got %+v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectOnboardingExistingRawCreds(t *testing.T) {
|
||||
runner := &onboardSeqRunner{}
|
||||
var out bytes.Buffer
|
||||
// choice 2, blank unified id, then raw clientId/secret.
|
||||
creds, err := runConnectOnboarding(runner, &cobra.Command{}, strings.NewReader("2\n\nCID\nCSECRET\n"), &out)
|
||||
if err != nil {
|
||||
t.Fatalf("onboarding: %v", err)
|
||||
}
|
||||
if creds.clientID != "CID" || creds.clientSecret != "CSECRET" {
|
||||
t.Fatalf("creds = %+v, want CID/CSECRET", creds)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Fatalf("raw creds path should not call the runner, got %d calls", len(runner.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectOnboardingNewApp(t *testing.T) {
|
||||
runner := &onboardSeqRunner{responses: []map[string]any{
|
||||
{"taskId": "task-1"}, // submit
|
||||
{"clientId": "cid-2", "clientSecret": "sec-2"}, // result poll #1
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
creds, err := runConnectOnboarding(runner, &cobra.Command{}, strings.NewReader("1\nMy App\nMy Bot\nHandles QA\n"), &out)
|
||||
if err != nil {
|
||||
t.Fatalf("onboarding: %v", err)
|
||||
}
|
||||
if creds.clientID != "cid-2" || creds.clientSecret != "sec-2" {
|
||||
t.Fatalf("creds = %+v, want cid-2/sec-2", creds)
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected submit+result (2 calls), got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[0].Tool != devAppRobotSubmitTool {
|
||||
t.Errorf("call 0 tool = %q, want %q", runner.calls[0].Tool, devAppRobotSubmitTool)
|
||||
}
|
||||
if runner.calls[0].Params["name"] != "My App" || runner.calls[0].Params["robotName"] != "My Bot" {
|
||||
t.Errorf("submit params = %+v, want name=My App robotName=My Bot", runner.calls[0].Params)
|
||||
}
|
||||
if runner.calls[1].Tool != devAppRobotResultTool {
|
||||
t.Errorf("call 1 tool = %q, want %q", runner.calls[1].Tool, devAppRobotResultTool)
|
||||
}
|
||||
if runner.calls[1].Params["taskId"] != "task-1" {
|
||||
t.Errorf("result taskId = %v, want task-1", runner.calls[1].Params["taskId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectOnboardingInvalidChoice(t *testing.T) {
|
||||
runner := &onboardSeqRunner{}
|
||||
var out bytes.Buffer
|
||||
if _, err := runConnectOnboarding(runner, &cobra.Command{}, strings.NewReader("9\n"), &out); err == nil {
|
||||
t.Fatal("expected error for invalid choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectOnboardingNewAppMissingField(t *testing.T) {
|
||||
runner := &onboardSeqRunner{}
|
||||
var out bytes.Buffer
|
||||
// new app but blank robot name → validation error, no runner call (no side effect).
|
||||
if _, err := runConnectOnboarding(runner, &cobra.Command{}, strings.NewReader("1\nMy App\n\nDesc\n"), &out); err == nil {
|
||||
t.Fatal("expected validation error for blank robot name")
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Fatalf("should not submit with missing field, got %d calls", len(runner.calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
opencodeServerUsername = "opencode"
|
||||
opencodeServerHealthPath = "/global/health"
|
||||
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
||||
opencodeServerStartupWait = 20 * time.Second
|
||||
opencodeServerPollInterval = 150 * time.Millisecond
|
||||
// opencodeHealthProbeTimeout bounds a single /global/health probe so startup
|
||||
// detection stays snappy even though the shared http.Client has no overall
|
||||
// deadline (message turns rely on the per-turn ctx instead).
|
||||
opencodeHealthProbeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var errOpencodeSessionMissing = errors.New("opencode session missing")
|
||||
|
||||
// opencodeForwarder forwards DingTalk messages to a local `opencode serve`
|
||||
// instance. DWS owns the local server process and keeps a per-conversation
|
||||
// mapping to opencode's native session id; opencode owns the actual message
|
||||
// history in its own storage.
|
||||
type opencodeForwarder struct {
|
||||
bin string
|
||||
env []string
|
||||
timeout time.Duration
|
||||
workDir string
|
||||
model string
|
||||
sessions *opencodeSessions // convID→opencode sessionID, persisted; nil = stateless
|
||||
server *opencodeServer
|
||||
}
|
||||
|
||||
func newOpencodeForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
||||
var sessions *opencodeSessions
|
||||
if opts.Memory {
|
||||
sessions = newOpencodeSessions(opencodeSessionStorePath(clientID))
|
||||
}
|
||||
f := &opencodeForwarder{
|
||||
bin: bin,
|
||||
env: env,
|
||||
timeout: timeout,
|
||||
workDir: opts.WorkDir,
|
||||
model: opts.Model,
|
||||
sessions: sessions,
|
||||
}
|
||||
f.server = newOpencodeServer(bin, env, f.cwd())
|
||||
return f
|
||||
}
|
||||
|
||||
// Group-chat bots answer once per message. opencode server still supports
|
||||
// events, but DWS deliberately uses the synchronous message API here.
|
||||
func (f *opencodeForwarder) canStream() bool { return false }
|
||||
|
||||
func (f *opencodeForwarder) label() string {
|
||||
memo := "stateless"
|
||||
if f.sessions != nil {
|
||||
memo = "server-session-memory"
|
||||
}
|
||||
return fmt.Sprintf("opencode-server:%s (%s)", f.bin, memo)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
client, err := f.server.ensure(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reply, err := f.forwardWithClient(ctx, client, convID, text)
|
||||
if errors.Is(err, errOpencodeSessionMissing) && f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
reply, err = f.forwardWithClient(ctx, client, convID, text)
|
||||
}
|
||||
return reply, err
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string) (string, error) {
|
||||
sessionID := ""
|
||||
if f.sessions != nil {
|
||||
sessionID = f.sessions.id(convID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
created, err := client.createSession(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sessionID = created
|
||||
if f.sessions != nil {
|
||||
f.sessions.set(convID, sessionID)
|
||||
}
|
||||
}
|
||||
reply, err := client.sendMessage(ctx, sessionID, text, f.model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if agentReplyIsError(reply) {
|
||||
return agentBackendErrorReply(reply), nil
|
||||
}
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
return opencodeNoTextReply, nil
|
||||
}
|
||||
return brandReply(f.name(), reply), nil
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) name() string { return "opencode" }
|
||||
|
||||
func (f *opencodeForwarder) cwd() string {
|
||||
if f.workDir == "" {
|
||||
return connectWorkDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return f.workDir
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) close() error {
|
||||
if f.server != nil {
|
||||
return f.server.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetSession drops the conversation's opencode session so the next message
|
||||
// starts a fresh one, leaving the old session on the opencode server (it stays
|
||||
// resumable by id). Backs the /new command. A no-op when memory is disabled.
|
||||
func (f *opencodeForwarder) resetSession(convID string) {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
}
|
||||
|
||||
// clearSession backs the /clear command: it actively deletes the conversation's
|
||||
// session on the opencode server (DELETE /session/:id) and then drops the local
|
||||
// mapping, so the context is truly wiped rather than just forgotten. A missing
|
||||
// server session is treated as already cleared. Implements sessionClearer.
|
||||
func (f *opencodeForwarder) clearSession(ctx context.Context, convID string) error {
|
||||
if f.sessions == nil {
|
||||
return nil
|
||||
}
|
||||
sessionID := f.sessions.id(convID)
|
||||
f.sessions.reset(convID)
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil
|
||||
}
|
||||
client, err := f.server.ensure(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.deleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
type opencodeServer struct {
|
||||
bin string
|
||||
env []string
|
||||
workDir string
|
||||
mu sync.Mutex
|
||||
baseURL string
|
||||
password string
|
||||
cmd *exec.Cmd
|
||||
done chan error
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newOpencodeServer(bin string, env []string, workDir string) *opencodeServer {
|
||||
return &opencodeServer{
|
||||
bin: bin,
|
||||
env: env,
|
||||
workDir: workDir,
|
||||
// No client-level Timeout: a turn can legitimately run for minutes, so the
|
||||
// per-request ctx (forwardStream's f.timeout, default 300s) governs instead.
|
||||
// A hardcoded 30s here used to abort long agent replies mid-flight with
|
||||
// "Client.Timeout exceeded while awaiting headers".
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.httpClient == nil {
|
||||
s.httpClient = &http.Client{}
|
||||
}
|
||||
client := &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
||||
if s.baseURL != "" && client.health(ctx) == nil {
|
||||
return client, nil
|
||||
}
|
||||
if s.cmd != nil {
|
||||
_ = s.closeLocked()
|
||||
}
|
||||
|
||||
port, err := freeLocalPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password := randomHex(24)
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
cmd := exec.Command(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port))
|
||||
if s.workDir != "" {
|
||||
cmd.Dir = s.workDir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), s.env...)
|
||||
cmd.Env = append(cmd.Env,
|
||||
"OPENCODE_SERVER_USERNAME="+opencodeServerUsername,
|
||||
"OPENCODE_SERVER_PASSWORD="+password,
|
||||
)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("启动 opencode serve 失败:%w", err)
|
||||
}
|
||||
|
||||
s.baseURL = baseURL
|
||||
s.password = password
|
||||
s.cmd = cmd
|
||||
s.done = make(chan error, 1)
|
||||
go func() {
|
||||
s.done <- cmd.Wait()
|
||||
}()
|
||||
client = &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
||||
if err := s.waitHealthy(ctx, client); err != nil {
|
||||
_ = s.closeLocked()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error {
|
||||
deadline := time.Now().Add(opencodeServerStartupWait)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.done != nil {
|
||||
select {
|
||||
case err := <-s.done:
|
||||
s.done = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("opencode serve 已退出:%w", err)
|
||||
}
|
||||
return fmt.Errorf("opencode serve 已退出")
|
||||
default:
|
||||
}
|
||||
}
|
||||
if err := client.health(ctx); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(opencodeServerPollInterval)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("等待 opencode serve 就绪超时:%w", lastErr)
|
||||
}
|
||||
return fmt.Errorf("等待 opencode serve 就绪超时")
|
||||
}
|
||||
|
||||
func (s *opencodeServer) close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.closeLocked()
|
||||
}
|
||||
|
||||
func (s *opencodeServer) closeLocked() error {
|
||||
if s.cmd == nil || s.cmd.Process == nil {
|
||||
s.cmd = nil
|
||||
s.done = nil
|
||||
return nil
|
||||
}
|
||||
err := s.cmd.Process.Kill()
|
||||
if s.done != nil {
|
||||
<-s.done
|
||||
}
|
||||
s.cmd = nil
|
||||
s.done = nil
|
||||
s.baseURL = ""
|
||||
s.password = ""
|
||||
return err
|
||||
}
|
||||
|
||||
type opencodeHTTPClient struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) health(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, opencodeHealthProbeTimeout)
|
||||
defer cancel()
|
||||
var out struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodGet, opencodeServerHealthPath, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !out.Healthy {
|
||||
return fmt.Errorf("opencode serve health=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) createSession(ctx context.Context) (string, error) {
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/session", map[string]any{}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(out.ID) == "" {
|
||||
return "", fmt.Errorf("opencode server response missing session id")
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
// deleteSession disposes a session on the opencode server (DELETE /session/:id).
|
||||
// A 404 (session already gone) is treated as success so /clear stays idempotent.
|
||||
func (c *opencodeHTTPClient) deleteSession(ctx context.Context, sessionID string) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodDelete, "/session/"+sessionID, nil, nil); err != nil {
|
||||
if errors.Is(err, errOpencodeSessionMissing) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) sendMessage(ctx context.Context, sessionID, text, model string) (string, error) {
|
||||
body := map[string]any{
|
||||
"parts": []map[string]any{{"type": "text", "text": text}},
|
||||
}
|
||||
if m := opencodeModelRef(model); m != nil {
|
||||
body["model"] = m
|
||||
}
|
||||
var out struct {
|
||||
Parts []opencodePart `json:"parts"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/session/"+sessionID+"/message", body, &out); err != nil {
|
||||
if errors.Is(err, errOpencodeSessionMissing) {
|
||||
return "", err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(opencodePartsText(out.Parts)), nil
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) doJSON(ctx context.Context, method, path string, in any, out any) error {
|
||||
if strings.TrimSpace(c.baseURL) == "" {
|
||||
return fmt.Errorf("opencode server URL is empty")
|
||||
}
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
data, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL, "/")+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.password != "" {
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound && strings.HasPrefix(path, "/session/") {
|
||||
return errOpencodeSessionMissing
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
return fmt.Errorf("opencode server %s %s 失败:%s", method, path, truncateRunes(msg, 300))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type opencodePart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Delta string `json:"delta"`
|
||||
Parts []opencodePart `json:"parts"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func opencodePartsText(parts []opencodePart) string {
|
||||
var b strings.Builder
|
||||
var walk func([]opencodePart)
|
||||
walk = func(items []opencodePart) {
|
||||
for _, p := range items {
|
||||
if p.Type == "text" {
|
||||
if p.Text != "" {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
if p.Delta != "" {
|
||||
b.WriteString(p.Delta)
|
||||
}
|
||||
if len(p.Data) > 0 {
|
||||
var nested struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(p.Data, &nested) == nil && nested.Text != "" {
|
||||
b.WriteString(nested.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(p.Parts) > 0 {
|
||||
walk(p.Parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(parts)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func opencodeModelRef(model string) map[string]string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
provider, modelID, ok := strings.Cut(model, "/")
|
||||
if !ok || strings.TrimSpace(provider) == "" || strings.TrimSpace(modelID) == "" {
|
||||
return map[string]string{"modelID": model}
|
||||
}
|
||||
return map[string]string{"providerID": provider, "modelID": modelID}
|
||||
}
|
||||
|
||||
func freeLocalPort() (int, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer ln.Close()
|
||||
return ln.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
func randomHex(bytesLen int) string {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// opencodeSessionStorePath returns the on-disk location for a robot's opencode
|
||||
// conversation→session map, scoped by clientId so multiple bots on one machine
|
||||
// stay isolated: <config dir>/connect/<clientId>/opencode-sessions.json. An
|
||||
// empty clientId means "do not persist" (in-memory only).
|
||||
func opencodeSessionStorePath(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "opencode-sessions.json")
|
||||
}
|
||||
|
||||
// opencodeSessions maps a DingTalk conversation to the opencode session id
|
||||
// returned by the server API. The map is the authoritative store (guarded by mu)
|
||||
// and is persisted to disk so context can survive a connector restart when
|
||||
// --agent-memory is enabled.
|
||||
type opencodeSessions struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
path string
|
||||
}
|
||||
|
||||
func newOpencodeSessions(path string) *opencodeSessions {
|
||||
return &opencodeSessions{m: loadConvSessionMap(path), path: path}
|
||||
}
|
||||
|
||||
func opencodeConvKey(convID string) string {
|
||||
key := strings.TrimSpace(convID)
|
||||
if key == "" {
|
||||
key = "_default"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// id returns the opencode session bound to a conversation, or "" if none.
|
||||
func (s *opencodeSessions) id(convID string) string {
|
||||
key := opencodeConvKey(convID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.m[key]
|
||||
}
|
||||
|
||||
// set binds a conversation to an opencode session and persists the snapshot.
|
||||
// Best-effort: a failed save only logs a warning and never blocks message
|
||||
// handling.
|
||||
func (s *opencodeSessions) set(convID, sessionID string) {
|
||||
key := opencodeConvKey(convID)
|
||||
s.mu.Lock()
|
||||
if sessionID == "" {
|
||||
delete(s.m, key)
|
||||
} else {
|
||||
s.m[key] = sessionID
|
||||
}
|
||||
snapshot := make(map[string]string, len(s.m))
|
||||
for k, v := range s.m {
|
||||
snapshot[k] = v
|
||||
}
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
saveConvSessionMap(path, snapshot)
|
||||
}
|
||||
|
||||
// reset forgets a conversation's session so the next message starts a fresh one.
|
||||
// The removal is persisted so a restart does not resurrect the dropped session.
|
||||
func (s *opencodeSessions) reset(convID string) {
|
||||
s.set(convID, "")
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpencodeForwarderUsesServerSessionAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
var calls []string
|
||||
var prompts []string
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true,"version":"test"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_server"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_server/message":
|
||||
var req struct {
|
||||
Parts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode message request: %v", err)
|
||||
}
|
||||
if len(req.Parts) != 1 || req.Parts[0].Type != "text" {
|
||||
t.Fatalf("message parts = %#v, want one text part", req.Parts)
|
||||
}
|
||||
prompts = append(prompts, req.Parts[0].Text)
|
||||
_, _ = w.Write([]byte(`{"info":{"id":"msg_1"},"parts":[{"type":"text","text":"reply ` + req.Parts[0].Text + `"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(storePath),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "hi", func(string) {
|
||||
t.Fatal("opencode server mode must not stream deltas")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first forward: %v", err)
|
||||
}
|
||||
if reply != "reply hi" {
|
||||
t.Fatalf("first reply = %q, want reply hi", reply)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "ses_server" {
|
||||
t.Fatalf("captured session = %q, want ses_server", got)
|
||||
}
|
||||
|
||||
reply, err = f.forwardStream(context.Background(), "conv-1", "again", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("second forward: %v", err)
|
||||
}
|
||||
if reply != "reply again" {
|
||||
t.Fatalf("second reply = %q, want reply again", reply)
|
||||
}
|
||||
if got := strings.Join(calls, "\n"); strings.Count(got, "POST /session\n") != 1 && strings.Count(got, "POST /session") != 1 {
|
||||
t.Fatalf("POST /session should be called once, calls:\n%s", got)
|
||||
}
|
||||
if got := strings.Join(prompts, ","); got != "hi,again" {
|
||||
t.Fatalf("prompts = %q, want hi,again", got)
|
||||
}
|
||||
if f.canStream() {
|
||||
t.Fatal("opencode server mode should be one-shot for group chat replies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeForwarderRecreatesMissingServerSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
var created int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true,"version":"test"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
created++
|
||||
_, _ = w.Write([]byte(`{"id":"ses_new"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_stale/message":
|
||||
http.Error(w, `{"error":"missing"}`, http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_new/message":
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"fresh"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(storePath),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
f.sessions.set("conv-1", "ses_stale")
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "again", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("forward after missing session: %v", err)
|
||||
}
|
||||
if reply != "fresh" {
|
||||
t.Fatalf("reply = %q, want fresh", reply)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "ses_new" {
|
||||
t.Fatalf("session after retry = %q, want ses_new", got)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("created sessions = %d, want 1", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeForwarderKeepsSessionWhenServerReturnsNoText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true,"version":"test"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_keep/message":
|
||||
_, _ = w.Write([]byte(`{"parts":[]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(storePath),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
f.sessions.set("conv-1", "ses_keep")
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "question", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("forward with empty response: %v", err)
|
||||
}
|
||||
if reply != "(本地 agent 无文本输出)" {
|
||||
t.Fatalf("reply = %q, want no-text hint", reply)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "ses_keep" {
|
||||
t.Fatalf("session after no-text response = %q, want ses_keep", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeForwarderOnlyReturnsTextParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true,"version":"test"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_parts"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_parts/message":
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"reasoning","text":"hidden reasoning"},{"type":"text","text":"visible"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(storePath),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "question", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if reply != "visible" {
|
||||
t.Fatalf("reply = %q, want visible", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpencodeSessionsPersist verifies the convID→sessionID store survives a
|
||||
// restart and that reset is persisted.
|
||||
func TestOpencodeSessionsPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "opencode-sessions.json")
|
||||
|
||||
s := newOpencodeSessions(path)
|
||||
s.set("conv-1", "ses_a")
|
||||
s.set("conv-2", "ses_b")
|
||||
|
||||
restarted := newOpencodeSessions(path)
|
||||
if got := restarted.id("conv-1"); got != "ses_a" {
|
||||
t.Errorf("after restart conv-1 = %q, want ses_a", got)
|
||||
}
|
||||
if got := restarted.id("conv-2"); got != "ses_b" {
|
||||
t.Errorf("after restart conv-2 = %q, want ses_b", got)
|
||||
}
|
||||
|
||||
restarted.reset("conv-1")
|
||||
again := newOpencodeSessions(path)
|
||||
if got := again.id("conv-1"); got != "" {
|
||||
t.Errorf("after reset+restart conv-1 = %q, want empty", got)
|
||||
}
|
||||
if got := again.id("conv-2"); got != "ses_b" {
|
||||
t.Errorf("after reset+restart conv-2 = %q, want ses_b (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewOpencodeServerHasNoClientTimeout pins the fix: the shared HTTP client
|
||||
// must not carry an overall deadline, otherwise long agent turns get aborted
|
||||
// with "Client.Timeout exceeded while awaiting headers". The per-turn ctx is
|
||||
// the only thing allowed to bound a message round-trip.
|
||||
func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
|
||||
s := newOpencodeServer("opencode", nil, "")
|
||||
if s.httpClient == nil {
|
||||
t.Fatal("httpClient must be initialized")
|
||||
}
|
||||
if s.httpClient.Timeout != 0 {
|
||||
t.Fatalf("opencode http client Timeout = %s, want 0 (per-turn ctx governs long agent replies)", s.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpencodeForwarderMessageGovernedByTurnCtx verifies a slow reply is not
|
||||
// cut by a fixed client timeout (the old 30s bug) and that the per-turn ctx is
|
||||
// the real governor instead.
|
||||
func TestOpencodeForwarderMessageGovernedByTurnCtx(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const replyDelay = 200 * time.Millisecond
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true}`))
|
||||
case r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_slow"}`))
|
||||
case r.URL.Path == "/session/ses_slow/message":
|
||||
select {
|
||||
case <-time.After(replyDelay):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"slow ok"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
newForwarder := func(turn time.Duration) *opencodeForwarder {
|
||||
return &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: turn,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(filepath.Join(dir, "s.json")),
|
||||
// &http.Client{} mirrors the fixed production default (no overall cap).
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: &http.Client{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Generous turn budget: the slow reply must come back, not be cut by a cap.
|
||||
reply, err := newForwarder(5*time.Second).forwardStream(context.Background(), "conv-ok", "hi", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("slow reply within the turn budget should succeed, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(reply, "slow ok") {
|
||||
t.Fatalf("reply = %q, want it to contain slow ok", reply)
|
||||
}
|
||||
|
||||
// Turn budget shorter than the reply delay: the per-turn ctx is what bounds
|
||||
// the round-trip, so this must error out (proving ctx, not a client cap, wins).
|
||||
if _, err := newForwarder(50*time.Millisecond).forwardStream(context.Background(), "conv-cut", "hi", nil); err == nil {
|
||||
t.Fatal("a reply slower than the turn ctx should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// opencodeForwarder must satisfy sessionClearer so /clear gets a real delete.
|
||||
var _ sessionClearer = (*opencodeForwarder)(nil)
|
||||
|
||||
// TestOpencodeForwarderClearDeletesServerSession pins /clear semantics: it issues
|
||||
// a real DELETE /session/:id on the opencode server and drops the local mapping.
|
||||
func TestOpencodeForwarderClearDeletesServerSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var deleted []string
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_clear"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_clear/message":
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"hi"}]}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/session/ses_clear":
|
||||
deleted = append(deleted, r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(filepath.Join(dir, "s.json")),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
|
||||
if _, err := f.forwardStream(context.Background(), "conv-1", "hi", nil); err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "ses_clear" {
|
||||
t.Fatalf("session = %q, want ses_clear", got)
|
||||
}
|
||||
|
||||
if err := f.clearSession(context.Background(), "conv-1"); err != nil {
|
||||
t.Fatalf("clearSession: %v", err)
|
||||
}
|
||||
if len(deleted) != 1 || deleted[0] != "/session/ses_clear" {
|
||||
t.Fatalf("server deletes = %v, want one DELETE /session/ses_clear", deleted)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "" {
|
||||
t.Fatalf("session after /clear = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpencodeForwarderResetKeepsServerSession pins /new semantics: resetSession
|
||||
// only drops the local mapping and must NOT delete the server-side session, so
|
||||
// the old session stays resumable (the /new vs /clear distinction).
|
||||
func TestOpencodeForwarderResetKeepsServerSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deletes := 0
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_keep"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_keep/message":
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"hi"}]}`))
|
||||
case r.Method == http.MethodDelete:
|
||||
deletes++
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode",
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newOpencodeSessions(filepath.Join(dir, "s.json")),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
|
||||
if _, err := f.forwardStream(context.Background(), "conv-1", "hi", nil); err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
f.resetSession("conv-1")
|
||||
if deletes != 0 {
|
||||
t.Fatalf("resetSession issued %d DELETE(s), want 0 (old session must stay resumable)", deletes)
|
||||
}
|
||||
if got := f.sessions.id("conv-1"); got != "" {
|
||||
t.Fatalf("session after /new = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeClaudeSettings drops a $CLAUDE_CONFIG_DIR/settings.json whose env block
|
||||
// carries a custom provider base URL, mirroring how cc-switch and similar tools
|
||||
// store third-party Anthropic-compatible credentials (issue #10 / #14).
|
||||
func writeClaudeSettings(t *testing.T, baseURL string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
body := `{"env":{"ANTHROPIC_BASE_URL":"` + baseURL + `","ANTHROPIC_AUTH_TOKEN":"sk-test"}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write settings: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestForwarderProviderBaseURLDropsHaiku is the issue #14 root-cause fix: when a
|
||||
// custom provider base URL is injected and the user did not pick a model, the
|
||||
// built-in --model claude-haiku-... pin must be dropped (the provider may not
|
||||
// map it, returning a 422). An explicit --agent-model still wins, and an
|
||||
// official-login user (no base URL) keeps the pin.
|
||||
func TestForwarderProviderBaseURLDropsHaiku(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
t.Setenv("DWS_AGENT_MODEL", "")
|
||||
// Make sure the connector's own env does not carry a base URL for the
|
||||
// no-provider subtest.
|
||||
t.Setenv("ANTHROPIC_BASE_URL", "")
|
||||
os.Unsetenv("ANTHROPIC_BASE_URL")
|
||||
|
||||
stub := t.TempDir()
|
||||
if err := writeExecStub(stub, "claude"); err != nil {
|
||||
t.Fatalf("stub claude: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
argvOf := func(fwd forwarder) string {
|
||||
ef, ok := fwd.(*execForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("not an execForwarder: %T", fwd)
|
||||
}
|
||||
return strings.Join(ef.argv, " ")
|
||||
}
|
||||
streamArgvOf := func(fwd forwarder) string {
|
||||
ef := fwd.(*execForwarder)
|
||||
return strings.Join(ef.streamArgv, " ")
|
||||
}
|
||||
|
||||
// 1) Provider base URL injected via Claude settings, no --agent-model:
|
||||
// haiku pin dropped from both argv and streamArgv.
|
||||
cfg := writeClaudeSettings(t, "https://my-proxy.example.com")
|
||||
t.Setenv("CLAUDE_CONFIG_DIR", cfg)
|
||||
fwd, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarder (provider): %v", err)
|
||||
}
|
||||
if a := argvOf(fwd); strings.Contains(a, "--model") || strings.Contains(a, "haiku") {
|
||||
t.Fatalf("provider+no-model: argv should drop haiku pin, got: %s", a)
|
||||
}
|
||||
if a := streamArgvOf(fwd); strings.Contains(a, "--model") || strings.Contains(a, "haiku") {
|
||||
t.Fatalf("provider+no-model: streamArgv should drop haiku pin, got: %s", a)
|
||||
}
|
||||
|
||||
// 2) Provider base URL injected but user picked a model: user's model wins.
|
||||
fwd2, err := forwarderForChannel("claudecode", "", connectAgentOptions{Model: "my-custom-model"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarder (provider+model): %v", err)
|
||||
}
|
||||
if a := argvOf(fwd2); !strings.Contains(a, "--model my-custom-model") {
|
||||
t.Fatalf("provider+model: argv should carry user model, got: %s", a)
|
||||
}
|
||||
if a := argvOf(fwd2); strings.Contains(a, "haiku") {
|
||||
t.Fatalf("provider+model: haiku pin should be replaced, got: %s", a)
|
||||
}
|
||||
|
||||
// 3) No provider (official login): built-in haiku pin kept.
|
||||
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) // empty dir -> no settings.json
|
||||
fwd3, err := forwarderForChannel("claudecode", "", connectAgentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarder (official): %v", err)
|
||||
}
|
||||
if a := argvOf(fwd3); !strings.Contains(a, "--model claude-haiku-4-5-20251001") {
|
||||
t.Fatalf("official login: built-in haiku pin must be kept, got: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardReturnsHintOnAPIError covers the issue #14 anti-echo guard: an
|
||||
// agent that prints "API Error: 422 ..." to stdout (and exits 0) must NOT have
|
||||
// that raw error forwarded as the answer; forward returns an actionable hint.
|
||||
func TestForwardReturnsHintOnAPIError(t *testing.T) {
|
||||
stub := t.TempDir()
|
||||
bin := filepath.Join(stub, "fakeagent")
|
||||
// Print a provider 422 to stdout and exit 0, exactly like claude does when
|
||||
// the upstream provider rejects the model.
|
||||
script := "#!/bin/sh\necho 'API Error: 422 未找到匹配的自定义供应商配置,请检查模型名称'\nexit 0\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake agent: %v", err)
|
||||
}
|
||||
|
||||
f := &execForwarder{name: "claudecode", argv: []string{bin}, timeout: 10_000_000_000}
|
||||
got, err := f.forward(context.Background(), "conv-1", "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("forward returned err: %v", err)
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(got), "API Error:") {
|
||||
t.Fatalf("raw backend error was forwarded as the answer: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "AI 后端调用失败") || !strings.Contains(got, "--agent-model") {
|
||||
t.Fatalf("expected actionable hint, got: %q", got)
|
||||
}
|
||||
|
||||
// A normal reply is untouched.
|
||||
okBin := filepath.Join(stub, "okagent")
|
||||
if err := os.WriteFile(okBin, []byte("#!/bin/sh\necho '你好,这是正常回复'\n"), 0o755); err != nil {
|
||||
t.Fatalf("write ok agent: %v", err)
|
||||
}
|
||||
f2 := &execForwarder{name: "claudecode", argv: []string{okBin}, timeout: 10_000_000_000}
|
||||
got2, err := f2.forward(context.Background(), "conv-2", "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("forward (normal) err: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(got2) != "你好,这是正常回复" {
|
||||
t.Fatalf("normal reply altered: %q", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentBackendErrorHelpers locks the heuristic and message shape.
|
||||
func TestAgentBackendErrorHelpers(t *testing.T) {
|
||||
if !agentReplyIsError("API Error: 422 boom") {
|
||||
t.Fatal("API Error prefix should be detected")
|
||||
}
|
||||
if agentReplyIsError("这是正常回复,里面提到 API Error 不算") {
|
||||
t.Fatal("mid-text mention must not be flagged")
|
||||
}
|
||||
msg := agentBackendErrorReply("API Error: 422 line one\nline two ignored")
|
||||
if strings.Contains(msg, "line two") {
|
||||
t.Fatalf("only first line should be kept: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "--agent-model") {
|
||||
t.Fatalf("hint must mention --agent-model: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripModelArg covers removing a flag+value and the absent-flag no-op.
|
||||
func TestStripModelArg(t *testing.T) {
|
||||
got := stripModelArg([]string{"claude", "-p", "--model", "haiku", "--strict-mcp-config"}, "--model")
|
||||
want := "claude -p --strict-mcp-config"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Fatalf("strip: got %v, want %s", got, want)
|
||||
}
|
||||
noop := stripModelArg([]string{"claude", "-p"}, "--model")
|
||||
if strings.Join(noop, " ") != "claude -p" {
|
||||
t.Fatalf("no-op: got %v", noop)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// 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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const qoderInitializeTimeout = 60 * time.Second
|
||||
|
||||
// qoderStreamForwarder keeps one qodercli stream-json subprocess alive for the
|
||||
// lifetime of `dws dev connect`. DWS sends each DingTalk turn as a JSON user
|
||||
// message with a per-conversation session_id, so Qoder keeps context without a
|
||||
// fresh CLI startup on every chat message.
|
||||
type qoderStreamForwarder struct {
|
||||
name string
|
||||
bin string
|
||||
env []string
|
||||
timeout time.Duration
|
||||
workDir string
|
||||
model string
|
||||
sessions *convSessions
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
lines chan string
|
||||
done chan error
|
||||
stderr *lockedStringBuffer
|
||||
}
|
||||
|
||||
func newQoderStreamForwarder(name, bin string, env []string, timeout time.Duration, opts connectAgentOptions, sessions *convSessions) forwarder {
|
||||
return &qoderStreamForwarder{
|
||||
name: name,
|
||||
bin: bin,
|
||||
env: env,
|
||||
timeout: timeout,
|
||||
workDir: opts.WorkDir,
|
||||
model: opts.Model,
|
||||
sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) canStream() bool { return true }
|
||||
|
||||
func (f *qoderStreamForwarder) label() string {
|
||||
memo := "stateless"
|
||||
if f.sessions != nil {
|
||||
memo = "stream-session-memory"
|
||||
}
|
||||
return fmt.Sprintf("qoder-stream:%s (%s)", f.bin, memo)
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if err := f.ensureLocked(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sessionID := uuid.NewString()
|
||||
if f.sessions != nil {
|
||||
sessionID = f.sessions.id(convID)
|
||||
}
|
||||
msg := map[string]any{
|
||||
"type": "user",
|
||||
"message": map[string]any{"role": "user", "content": text},
|
||||
"parent_tool_use_id": nil,
|
||||
"session_id": sessionID,
|
||||
}
|
||||
if err := f.writeJSONLocked(msg); err != nil {
|
||||
f.closeLocked()
|
||||
return "", fmt.Errorf("写入 Qoder 请求失败:%w", err)
|
||||
}
|
||||
|
||||
var acc strings.Builder
|
||||
for {
|
||||
line, err := f.readLineLocked(ctx)
|
||||
if err != nil {
|
||||
f.closeLocked()
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if f.handleControlRequestLocked(line) {
|
||||
continue
|
||||
}
|
||||
delta, final, done := parseQoderPersistentLine(line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
if onDelta != nil {
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
}
|
||||
if !done {
|
||||
continue
|
||||
}
|
||||
final = strings.TrimSpace(final)
|
||||
if final == "" {
|
||||
final = strings.TrimSpace(acc.String())
|
||||
}
|
||||
if agentReplyIsError(final) {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(final), nil
|
||||
}
|
||||
if final == "" {
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
}
|
||||
return brandReply(f.name, final), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) resetSession(convID string) {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.closeLocked()
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) commandArgs() []string {
|
||||
args := []string{
|
||||
"--print",
|
||||
"--output-format", "stream-json",
|
||||
"--input-format", "stream-json",
|
||||
"--system-prompt", "",
|
||||
"--setting-sources", "",
|
||||
"--disable-builtin-skills",
|
||||
}
|
||||
if f.model != "" {
|
||||
args = append(args, "--model", f.model)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) cwd() string {
|
||||
if f.workDir == "" {
|
||||
return connectWorkDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return f.workDir
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error {
|
||||
if f.cmd != nil {
|
||||
select {
|
||||
case err := <-f.done:
|
||||
msg := ""
|
||||
if f.stderr != nil {
|
||||
msg = strings.TrimSpace(f.stderr.String())
|
||||
}
|
||||
f.clearProcessLocked()
|
||||
if err != nil {
|
||||
if msg != "" {
|
||||
fmt.Fprintf(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启:%s\n", truncateRunes(msg, 300))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启:%v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "[connect][qoder] stream-json 进程已退出,尝试重启")
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(f.bin, f.commandArgs()...)
|
||||
cmd.Dir = f.cwd()
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr := &lockedStringBuffer{}
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 Qoder stream-json 失败:%w", err)
|
||||
}
|
||||
|
||||
f.cmd = cmd
|
||||
f.stdin = stdin
|
||||
f.lines = make(chan string, 1024)
|
||||
f.done = make(chan error, 1)
|
||||
f.stderr = stderr
|
||||
|
||||
go scanQoderLines(stdout, f.lines)
|
||||
go func() {
|
||||
f.done <- cmd.Wait()
|
||||
}()
|
||||
return f.initializeLocked(ctx)
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) initializeLocked(parent context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(parent, qoderInitializeTimeout)
|
||||
defer cancel()
|
||||
requestID := "dws_init_" + uuid.NewString()
|
||||
request := map[string]any{
|
||||
"type": "control_request",
|
||||
"request_id": requestID,
|
||||
"request": map[string]any{
|
||||
"type": "initialize",
|
||||
"subtype": "initialize",
|
||||
"modelPolicyProvider": false,
|
||||
"initializeTimeoutMs": int(qoderInitializeTimeout / time.Millisecond),
|
||||
},
|
||||
}
|
||||
if err := f.writeJSONLocked(request); err != nil {
|
||||
f.closeLocked()
|
||||
return fmt.Errorf("初始化 Qoder stream-json 失败:%w", err)
|
||||
}
|
||||
for {
|
||||
line, err := f.readLineLocked(ctx)
|
||||
if err != nil {
|
||||
f.closeLocked()
|
||||
return err
|
||||
}
|
||||
if f.handleControlRequestLocked(line) {
|
||||
continue
|
||||
}
|
||||
matched, err := qoderControlResponse(line, requestID)
|
||||
if err != nil {
|
||||
f.closeLocked()
|
||||
return err
|
||||
}
|
||||
if matched {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) writeJSONLocked(v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.stdin.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) readLineLocked(ctx context.Context) (string, error) {
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-f.lines:
|
||||
if !ok {
|
||||
msg := "Qoder stream-json stdout 已关闭"
|
||||
if f.stderr != nil {
|
||||
if s := strings.TrimSpace(f.stderr.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
return line, nil
|
||||
case err := <-f.done:
|
||||
msg := ""
|
||||
if f.stderr != nil {
|
||||
msg = strings.TrimSpace(f.stderr.String())
|
||||
}
|
||||
f.clearProcessLocked()
|
||||
if err != nil {
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 已退出", f.name)
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool {
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Request map[string]any `json:"request"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" {
|
||||
return false
|
||||
}
|
||||
response := map[string]any{
|
||||
"type": "control_response",
|
||||
"response": map[string]any{
|
||||
"subtype": "error",
|
||||
"request_id": ev.RequestID,
|
||||
"error": "DWS qoder connector does not provide host callbacks",
|
||||
"code": "unsupported_control_request",
|
||||
},
|
||||
}
|
||||
_ = f.writeJSONLocked(response)
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) closeLocked() error {
|
||||
var killErr error
|
||||
if f.stdin != nil {
|
||||
_ = f.stdin.Close()
|
||||
}
|
||||
if f.cmd != nil && f.cmd.Process != nil {
|
||||
killErr = f.cmd.Process.Kill()
|
||||
}
|
||||
if f.done != nil {
|
||||
select {
|
||||
case <-f.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
f.clearProcessLocked()
|
||||
return killErr
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) clearProcessLocked() {
|
||||
f.cmd = nil
|
||||
f.stdin = nil
|
||||
f.lines = nil
|
||||
f.done = nil
|
||||
f.stderr = nil
|
||||
}
|
||||
|
||||
func scanQoderLines(stdout io.Reader, lines chan<- string) {
|
||||
defer close(lines)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines <- scanner.Text()
|
||||
}
|
||||
}
|
||||
|
||||
func qoderControlResponse(line, requestID string) (bool, error) {
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Response struct {
|
||||
Subtype string `json:"subtype"`
|
||||
RequestID string `json:"request_id"`
|
||||
Error string `json:"error"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_response" {
|
||||
return false, nil
|
||||
}
|
||||
if ev.Response.RequestID != requestID {
|
||||
return false, nil
|
||||
}
|
||||
if ev.Response.Subtype == "error" {
|
||||
if ev.Response.Error == "" {
|
||||
ev.Response.Error = "unknown initialize error"
|
||||
}
|
||||
return true, fmt.Errorf("初始化 Qoder stream-json 失败:%s", truncateRunes(ev.Response.Error, 300))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func parseQoderPersistentLine(line string) (delta, final string, done bool) {
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
Error string `json:"error"`
|
||||
Result string `json:"result"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &ev) != nil {
|
||||
return "", "", false
|
||||
}
|
||||
text := func() string {
|
||||
var b strings.Builder
|
||||
for _, c := range ev.Message.Content {
|
||||
if c.Type == "text" {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
switch {
|
||||
case ev.Type == "assistant":
|
||||
if t := text(); t != "" {
|
||||
return t + "\n\n", "", false
|
||||
}
|
||||
case ev.Type == "result":
|
||||
if ev.Subtype != "" && ev.Subtype != "success" {
|
||||
if ev.Error != "" {
|
||||
return "", ev.Error, true
|
||||
}
|
||||
}
|
||||
if t := text(); t != "" {
|
||||
return "", t, true
|
||||
}
|
||||
return "", strings.TrimSpace(ev.Result), true
|
||||
case ev.Type == "error":
|
||||
if ev.Error != "" {
|
||||
return "", ev.Error, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
type lockedStringBuffer struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (b *lockedStringBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.b.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedStringBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.b.String()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeQoderStreamStub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
logPath := filepath.Join(dir, "qoder-starts.log")
|
||||
script := `#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(os.environ["DWS_QODER_STUB_LOG"], "a", encoding="utf-8") as f:
|
||||
f.write("START " + json.dumps(sys.argv[1:], ensure_ascii=False) + "\n")
|
||||
|
||||
if "--input-format" in sys.argv:
|
||||
for raw in sys.stdin:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
msg = json.loads(raw)
|
||||
if msg.get("type") == "control_request":
|
||||
rid = msg.get("request_id", "")
|
||||
print(json.dumps({
|
||||
"type": "control_response",
|
||||
"response": {
|
||||
"subtype": "success",
|
||||
"request_id": rid,
|
||||
"response": {"agents": []},
|
||||
},
|
||||
}), flush=True)
|
||||
continue
|
||||
if msg.get("type") == "user":
|
||||
content = msg.get("message", {}).get("content", "")
|
||||
print(json.dumps({
|
||||
"type": "assistant",
|
||||
"message": {"content": [{"type": "text", "text": "seen " + content}]},
|
||||
}), flush=True)
|
||||
print(json.dumps({
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"message": {"content": [{"type": "text", "text": "done " + content}]},
|
||||
}), flush=True)
|
||||
else:
|
||||
prompt = sys.argv[-1] if len(sys.argv) > 1 else ""
|
||||
print(json.dumps({
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"message": {"content": [{"type": "text", "text": "one-shot " + prompt}]},
|
||||
}))
|
||||
`
|
||||
path := filepath.Join(dir, "qodercli")
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write qoder stub: %v", err)
|
||||
}
|
||||
return logPath
|
||||
}
|
||||
|
||||
func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("DWS_AGENT_TIMEOUT_MS", "3000")
|
||||
stubDir := t.TempDir()
|
||||
logPath := writeQoderStreamStub(t, stubDir)
|
||||
t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("DWS_QODER_STUB_LOG", logPath)
|
||||
|
||||
fwd, err := forwarderForChannel("qoder", "qoder-client", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(qoder): %v", err)
|
||||
}
|
||||
if closer, ok := fwd.(forwarderCloser); ok {
|
||||
defer closer.close()
|
||||
}
|
||||
sf, ok := fwd.(streamingForwarder)
|
||||
if !ok || !sf.canStream() {
|
||||
t.Fatalf("qoder forwarder should support streaming, got %T", fwd)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
got1, err := sf.forwardStream(ctx, "conv-a", "first", func(string) {})
|
||||
if err != nil {
|
||||
t.Fatalf("first forward: %v", err)
|
||||
}
|
||||
got2, err := sf.forwardStream(ctx, "conv-a", "second", func(string) {})
|
||||
if err != nil {
|
||||
t.Fatalf("second forward: %v", err)
|
||||
}
|
||||
if got1 != "done first" || got2 != "done second" {
|
||||
t.Fatalf("replies = %q / %q, want done first / done second", got1, got2)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read qoder stub log: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("qodercli starts = %d, want 1; log:\n%s", len(lines), raw)
|
||||
}
|
||||
if !strings.Contains(lines[0], "--input-format") || !strings.Contains(lines[0], "stream-json") {
|
||||
t.Fatalf("qodercli should start in stream-json input mode, log:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQoderPersistentLineReadsResultField(t *testing.T) {
|
||||
delta, final, done := parseQoderPersistentLine(`{"type":"result","subtype":"success","result":"OK"}`)
|
||||
if delta != "" || final != "OK" || !done {
|
||||
t.Fatalf("delta=%q final=%q done=%v, want final OK done", delta, final, done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 "sync"
|
||||
|
||||
// convQueue serialises message handling per conversation: same-chat messages
|
||||
// run in arrival order — a Q&A follow-up ("还是不行") must see the previous
|
||||
// turn's session state, and two parallel agent CLIs racing on one --resume
|
||||
// session corrupt the transcript. Different conversations stay parallel.
|
||||
// Ported from the hermes gateway's per-session promise chain
|
||||
// (gateway/run.py session queues).
|
||||
type convQueue struct {
|
||||
mu sync.Mutex
|
||||
tails map[string]chan struct{}
|
||||
}
|
||||
|
||||
func newConvQueue() *convQueue {
|
||||
return &convQueue{tails: map[string]chan struct{}{}}
|
||||
}
|
||||
|
||||
// run schedules fn after the conversation's previous task and returns
|
||||
// immediately (the Stream callback must ack fast). The chain entry is removed
|
||||
// once the queue for that conversation drains, so idle chats hold no memory.
|
||||
func (q *convQueue) run(convID string, fn func()) {
|
||||
q.mu.Lock()
|
||||
prev := q.tails[convID]
|
||||
done := make(chan struct{})
|
||||
q.tails[convID] = done
|
||||
q.mu.Unlock()
|
||||
go func() {
|
||||
defer func() {
|
||||
close(done)
|
||||
q.mu.Lock()
|
||||
if q.tails[convID] == done {
|
||||
delete(q.tails, convID)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
}()
|
||||
if prev != nil {
|
||||
<-prev
|
||||
}
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestConvQueueSerialSameConversation: tasks of one conversation run in
|
||||
// arrival order even when earlier tasks are slow.
|
||||
func TestConvQueueSerialSameConversation(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var mu sync.Mutex
|
||||
var order []int
|
||||
var wg sync.WaitGroup
|
||||
for i := 1; i <= 5; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() {
|
||||
defer wg.Done()
|
||||
if i == 1 {
|
||||
time.Sleep(50 * time.Millisecond) // slow head must not be overtaken
|
||||
}
|
||||
mu.Lock()
|
||||
order = append(order, i)
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
for i, v := range order {
|
||||
if v != i+1 {
|
||||
t.Fatalf("order = %v, want 1..5 in sequence", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvQueueParallelAcrossConversations: a slow conversation must not
|
||||
// block another conversation.
|
||||
func TestConvQueueParallelAcrossConversations(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
slowRelease := make(chan struct{})
|
||||
slowStarted := make(chan struct{})
|
||||
q.run("conv-slow", func() {
|
||||
close(slowStarted)
|
||||
<-slowRelease
|
||||
})
|
||||
<-slowStarted
|
||||
|
||||
fastDone := make(chan struct{})
|
||||
q.run("conv-fast", func() { close(fastDone) })
|
||||
select {
|
||||
case <-fastDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("fast conversation blocked behind slow one")
|
||||
}
|
||||
close(slowRelease)
|
||||
}
|
||||
|
||||
// TestConvQueueDrainsEntries: once a conversation's queue drains, its chain
|
||||
// entry is dropped (idle chats hold no memory).
|
||||
func TestConvQueueDrainsEntries(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() { wg.Done() })
|
||||
wg.Wait()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
q.mu.Lock()
|
||||
n := len(q.tails)
|
||||
q.mu.Unlock()
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("queue entries not cleaned up: %d left", n)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Role configuration is a standalone building block for the "digital employee"
|
||||
// evolution of the DingTalk connector: one role == one dedicated bot
|
||||
// (clientId-isolated). Each role carries its own knowledge scope, capability
|
||||
// boundary and persona so a role answers within its lane (the HR assistant must
|
||||
// not touch code repositories). This file only defines the schema, loader and
|
||||
// validation — it deliberately wires into no runtime path so it can be reused
|
||||
// freely by the later runtime-integration wave.
|
||||
|
||||
// ConfirmPolicy controls how a role asks its owner before taking an action.
|
||||
type ConfirmPolicy string
|
||||
|
||||
const (
|
||||
// ConfirmManual asks the owner to confirm every action.
|
||||
ConfirmManual ConfirmPolicy = "manual"
|
||||
// ConfirmAuto lets the bot judge for itself whether confirmation is needed.
|
||||
ConfirmAuto ConfirmPolicy = "auto"
|
||||
// ConfirmRemember reuses the owner's previous choice for the same kind of
|
||||
// operation.
|
||||
ConfirmRemember ConfirmPolicy = "remember"
|
||||
)
|
||||
|
||||
// defaultConfirmPolicy is the safest default when confirm_policy is omitted:
|
||||
// confirm everything, so an unconfigured role never acts silently.
|
||||
const defaultConfirmPolicy = ConfirmManual
|
||||
|
||||
// valid reports whether p is one of the recognized confirm policies.
|
||||
func (p ConfirmPolicy) valid() bool {
|
||||
switch p {
|
||||
case ConfirmManual, ConfirmAuto, ConfirmRemember:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RoleConfig is the on-disk (YAML) definition of one digital-employee role.
|
||||
// It maps 1:1 to a single bot via ClientID.
|
||||
type RoleConfig struct {
|
||||
// Name is the human-facing role name, e.g. "人事助理".
|
||||
Name string `yaml:"name"`
|
||||
// ClientID is the DingTalk bot clientId this role is bound to. One role owns
|
||||
// exactly one bot, so this is also the unique key across a role set.
|
||||
ClientID string `yaml:"client_id"`
|
||||
// Persona is a professional prompt fragment merged into the agent's system
|
||||
// prompt to shape tone and expertise.
|
||||
Persona string `yaml:"persona"`
|
||||
// KnowledgeSources lists knowledge sources using the existing
|
||||
// --knowledge-source syntax: a bare path is a local directory, "wiki:<spaceId>"
|
||||
// a whole knowledge space, "doc:<docId>" a single document.
|
||||
KnowledgeSources []string `yaml:"knowledge_sources"`
|
||||
// AllowedScopes names the dws capabilities/products this role may use, e.g.
|
||||
// ["todo", "approval", "attendance"]. Consumed by later permission checks.
|
||||
AllowedScopes []string `yaml:"allowed_scopes"`
|
||||
// OwnerUserID is the userId of the role's owner; confirmation requests go here.
|
||||
OwnerUserID string `yaml:"owner_user_id"`
|
||||
// ConfirmPolicy selects the confirmation strategy; empty defaults to manual.
|
||||
ConfirmPolicy ConfirmPolicy `yaml:"confirm_policy"`
|
||||
// Extra is an open-ended bag for forward-compatible keys, so the schema can
|
||||
// grow without a breaking change. Intentionally minimal — not a config DSL.
|
||||
Extra map[string]string `yaml:"extra"`
|
||||
}
|
||||
|
||||
// RoleConfigExample is a complete, copy-pasteable role definition users can
|
||||
// follow when authoring their own. It is also parsed in tests to keep the
|
||||
// example honest against the schema.
|
||||
const RoleConfigExample = `# Role: HR assistant (人事助理)
|
||||
# One role == one dedicated bot (its own clientId).
|
||||
name: 人事助理
|
||||
client_id: dingxxxxxxxxxxxxxxxx
|
||||
owner_user_id: "012345678901234567"
|
||||
# Confirmation strategy: manual | auto | remember
|
||||
confirm_policy: manual
|
||||
persona: |
|
||||
You are the company HR assistant. Answer questions about leave, attendance,
|
||||
benefits and onboarding in a warm, precise tone. Never touch code
|
||||
repositories or engineering systems — that is out of your lane.
|
||||
# Knowledge sources reuse the --knowledge-source syntax:
|
||||
# - a bare path is a local directory
|
||||
# - wiki:<spaceId> is a whole DingTalk knowledge space
|
||||
# - doc:<docId> is a single document node
|
||||
knowledge_sources:
|
||||
- ./knowledge/hr
|
||||
- wiki:1234567890
|
||||
# Capabilities this role may exercise (consumed by later permission checks).
|
||||
allowed_scopes:
|
||||
- attendance
|
||||
- approval
|
||||
- todo
|
||||
`
|
||||
|
||||
// LoadRoleConfig reads, parses and validates a single role YAML file.
|
||||
func LoadRoleConfig(path string) (*RoleConfig, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read role config %q: %w", path, err)
|
||||
}
|
||||
var cfg RoleConfig
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse role config %q: %w", path, err)
|
||||
}
|
||||
if err := cfg.normalizeAndValidate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid role config %q: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// normalizeAndValidate trims required fields, applies defaults and reports the
|
||||
// first violation with a clear, actionable message. It never panics.
|
||||
func (c *RoleConfig) normalizeAndValidate() error {
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
c.ClientID = strings.TrimSpace(c.ClientID)
|
||||
c.OwnerUserID = strings.TrimSpace(c.OwnerUserID)
|
||||
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("field %q is required", "name")
|
||||
}
|
||||
if c.ClientID == "" {
|
||||
return fmt.Errorf("field %q is required", "client_id")
|
||||
}
|
||||
if c.OwnerUserID == "" {
|
||||
return fmt.Errorf("field %q is required", "owner_user_id")
|
||||
}
|
||||
|
||||
if c.ConfirmPolicy == "" {
|
||||
c.ConfirmPolicy = defaultConfirmPolicy
|
||||
}
|
||||
if !c.ConfirmPolicy.valid() {
|
||||
return fmt.Errorf("field %q must be one of [%s, %s, %s], got %q",
|
||||
"confirm_policy", ConfirmManual, ConfirmAuto, ConfirmRemember, c.ConfirmPolicy)
|
||||
}
|
||||
|
||||
for i, src := range c.KnowledgeSources {
|
||||
trimmed := strings.TrimSpace(src)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("knowledge_sources[%d] is empty", i)
|
||||
}
|
||||
// Reuse the connector's own source grammar so role config stays in lockstep
|
||||
// with --knowledge-source (bare path / wiki:<spaceId> / doc:<docId>).
|
||||
if _, err := parseKnowledgeSource(trimmed); err != nil {
|
||||
return fmt.Errorf("knowledge_sources[%d]: %w", i, err)
|
||||
}
|
||||
c.KnowledgeSources[i] = trimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRoleConfig folds a loaded role into the connector options, filling only
|
||||
// the fields the operator did not set explicitly — an explicit flag/env ALWAYS
|
||||
// wins over the role profile, so a role config is a reusable baseline that ad-hoc
|
||||
// flags can still override. It is a pure function (no I/O) so it is trivially
|
||||
// testable; the caller validates client_id match and reports the merge.
|
||||
//
|
||||
// Mapped fields:
|
||||
// - OwnerUserID ← role.OwnerUserID (only when opts has no owner yet)
|
||||
// - Persona ← role.Persona (only when opts has no persona yet)
|
||||
// - KnowledgeSources ← role.KnowledgeSources (appended; the role's sources are
|
||||
// additive to any --knowledge-source/--knowledge-dir already configured)
|
||||
//
|
||||
// AllowedScopes and ConfirmPolicy are intentionally NOT mapped here: scope
|
||||
// enforcement and the auto/remember confirm strategies are a later slice, and we
|
||||
// do not want to carry options the runtime does not yet honor. The caller logs
|
||||
// them so they are visible.
|
||||
func applyRoleConfig(opts connectAgentOptions, role *RoleConfig) connectAgentOptions {
|
||||
if role == nil {
|
||||
return opts
|
||||
}
|
||||
if opts.OwnerUserID == "" {
|
||||
opts.OwnerUserID = role.OwnerUserID
|
||||
}
|
||||
if opts.Persona == "" {
|
||||
opts.Persona = strings.TrimSpace(role.Persona)
|
||||
}
|
||||
if len(role.KnowledgeSources) > 0 {
|
||||
opts.KnowledgeSources = append(opts.KnowledgeSources, role.KnowledgeSources...)
|
||||
}
|
||||
if len(opts.RoleScopes) == 0 && len(role.AllowedScopes) > 0 {
|
||||
opts.RoleScopes = append([]string(nil), role.AllowedScopes...)
|
||||
}
|
||||
if opts.ConfirmPolicy == "" && role.ConfirmPolicy != "" {
|
||||
opts.ConfirmPolicy = string(role.ConfirmPolicy)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// LoadRoleConfigs loads every *.yaml / *.yml file directly under dir and indexes
|
||||
// the roles by ClientID — the lookup a multi-bot, one-role-per-bot deployment
|
||||
// needs. A duplicate ClientID is an error: two roles must not share one bot.
|
||||
func LoadRoleConfigs(dir string) (map[string]*RoleConfig, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read role config dir %q: %w", dir, err)
|
||||
}
|
||||
// Sort for deterministic load order and stable error reporting.
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext == ".yaml" || ext == ".yml" {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
roles := make(map[string]*RoleConfig, len(names))
|
||||
for _, name := range names {
|
||||
cfg, err := LoadRoleConfig(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, dup := roles[cfg.ClientID]; dup {
|
||||
return nil, fmt.Errorf("duplicate client_id %q: roles %q and %q both bind the same bot",
|
||||
cfg.ClientID, existing.Name, cfg.Name)
|
||||
}
|
||||
roles[cfg.ClientID] = cfg
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return nil, fmt.Errorf("no role config (*.yaml/*.yml) found in %q", dir)
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeRoleFile(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestLoadRoleConfig_Full(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `
|
||||
name: 人事助理
|
||||
client_id: dinghr0001
|
||||
owner_user_id: "012345678901"
|
||||
confirm_policy: remember
|
||||
persona: |
|
||||
You are the HR assistant.
|
||||
knowledge_sources:
|
||||
- ./knowledge/hr
|
||||
- wiki:1234567890
|
||||
- doc:abcDEF
|
||||
allowed_scopes:
|
||||
- attendance
|
||||
- approval
|
||||
extra:
|
||||
team: people-ops
|
||||
`
|
||||
p := writeRoleFile(t, dir, "hr.yaml", body)
|
||||
cfg, err := LoadRoleConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Name != "人事助理" {
|
||||
t.Errorf("name = %q", cfg.Name)
|
||||
}
|
||||
if cfg.ClientID != "dinghr0001" {
|
||||
t.Errorf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.OwnerUserID != "012345678901" {
|
||||
t.Errorf("owner_user_id = %q", cfg.OwnerUserID)
|
||||
}
|
||||
if cfg.ConfirmPolicy != ConfirmRemember {
|
||||
t.Errorf("confirm_policy = %q", cfg.ConfirmPolicy)
|
||||
}
|
||||
if !strings.Contains(cfg.Persona, "HR assistant") {
|
||||
t.Errorf("persona = %q", cfg.Persona)
|
||||
}
|
||||
if len(cfg.KnowledgeSources) != 3 {
|
||||
t.Fatalf("knowledge_sources = %v", cfg.KnowledgeSources)
|
||||
}
|
||||
if len(cfg.AllowedScopes) != 2 || cfg.AllowedScopes[0] != "attendance" {
|
||||
t.Errorf("allowed_scopes = %v", cfg.AllowedScopes)
|
||||
}
|
||||
if cfg.Extra["team"] != "people-ops" {
|
||||
t.Errorf("extra = %v", cfg.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfig_DefaultConfirmPolicy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `
|
||||
name: 前端分身
|
||||
client_id: dingfe0001
|
||||
owner_user_id: "999"
|
||||
`
|
||||
p := writeRoleFile(t, dir, "fe.yaml", body)
|
||||
cfg, err := LoadRoleConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.ConfirmPolicy != ConfirmManual {
|
||||
t.Errorf("default confirm_policy = %q, want manual", cfg.ConfirmPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfig_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
body: "client_id: a\nowner_user_id: b\n",
|
||||
wantSub: `"name" is required`,
|
||||
},
|
||||
{
|
||||
name: "missing client_id",
|
||||
body: "name: x\nowner_user_id: b\n",
|
||||
wantSub: `"client_id" is required`,
|
||||
},
|
||||
{
|
||||
name: "missing owner_user_id",
|
||||
body: "name: x\nclient_id: a\n",
|
||||
wantSub: `"owner_user_id" is required`,
|
||||
},
|
||||
{
|
||||
name: "bad confirm_policy",
|
||||
body: "name: x\nclient_id: a\nowner_user_id: b\nconfirm_policy: sometimes\n",
|
||||
wantSub: `"confirm_policy" must be one of`,
|
||||
},
|
||||
{
|
||||
name: "empty knowledge source",
|
||||
body: "name: x\nclient_id: a\nowner_user_id: b\nknowledge_sources:\n - \"\"\n",
|
||||
wantSub: "knowledge_sources[0] is empty",
|
||||
},
|
||||
{
|
||||
name: "bad wiki source",
|
||||
body: "name: x\nclient_id: a\nowner_user_id: b\nknowledge_sources:\n - \"wiki:\"\n",
|
||||
wantSub: "missing a spaceId",
|
||||
},
|
||||
{
|
||||
name: "malformed yaml",
|
||||
body: "name: [unterminated\n",
|
||||
wantSub: "parse role config",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeRoleFile(t, dir, "r.yaml", tc.body)
|
||||
_, err := LoadRoleConfig(p)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantSub)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfig_MissingFile(t *testing.T) {
|
||||
_, err := LoadRoleConfig(filepath.Join(t.TempDir(), "nope.yaml"))
|
||||
if err == nil || !strings.Contains(err.Error(), "read role config") {
|
||||
t.Fatalf("expected read error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleConfigExample_Parses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeRoleFile(t, dir, "example.yaml", RoleConfigExample)
|
||||
cfg, err := LoadRoleConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("example YAML must be valid: %v", err)
|
||||
}
|
||||
if cfg.ConfirmPolicy != ConfirmManual {
|
||||
t.Errorf("example confirm_policy = %q", cfg.ConfirmPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfigs_IndexByClientID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRoleFile(t, dir, "hr.yaml", "name: HR\nclient_id: dinghr\nowner_user_id: o1\n")
|
||||
writeRoleFile(t, dir, "fin.yml", "name: Finance\nclient_id: dingfin\nowner_user_id: o2\n")
|
||||
// Non-yaml and subdirectory entries must be ignored.
|
||||
writeRoleFile(t, dir, "notes.txt", "ignore me")
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := LoadRoleConfigs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(roles) != 2 {
|
||||
t.Fatalf("got %d roles, want 2", len(roles))
|
||||
}
|
||||
if roles["dinghr"].Name != "HR" {
|
||||
t.Errorf("dinghr -> %q", roles["dinghr"].Name)
|
||||
}
|
||||
if roles["dingfin"].Name != "Finance" {
|
||||
t.Errorf("dingfin -> %q", roles["dingfin"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfigs_DuplicateClientID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRoleFile(t, dir, "a.yaml", "name: A\nclient_id: dingdup\nowner_user_id: o1\n")
|
||||
writeRoleFile(t, dir, "b.yaml", "name: B\nclient_id: dingdup\nowner_user_id: o2\n")
|
||||
_, err := LoadRoleConfigs(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate client_id") {
|
||||
t.Fatalf("expected duplicate client_id error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfigs_Empty(t *testing.T) {
|
||||
_, err := LoadRoleConfigs(t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "no role config") {
|
||||
t.Fatalf("expected empty-dir error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoleConfigs_MissingDir(t *testing.T) {
|
||||
_, err := LoadRoleConfigs(filepath.Join(t.TempDir(), "nope"))
|
||||
if err == nil || !strings.Contains(err.Error(), "read role config dir") {
|
||||
t.Fatalf("expected dir read error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRoleConfig_FillsEmptyOptions verifies the role fills options that
|
||||
// were not set explicitly: owner, persona, and (additive) knowledge sources.
|
||||
func TestApplyRoleConfig_FillsEmptyOptions(t *testing.T) {
|
||||
role := &RoleConfig{
|
||||
Name: "人事助理",
|
||||
ClientID: "dingbot1",
|
||||
OwnerUserID: "owner-from-role",
|
||||
Persona: " You are HR. ",
|
||||
KnowledgeSources: []string{"wiki:1", "./kb/hr"},
|
||||
AllowedScopes: []string{"todo", "approval"},
|
||||
}
|
||||
got := applyRoleConfig(connectAgentOptions{}, role)
|
||||
|
||||
if got.OwnerUserID != "owner-from-role" {
|
||||
t.Fatalf("owner = %q, want filled from role", got.OwnerUserID)
|
||||
}
|
||||
if got.Persona != "You are HR." { // trimmed
|
||||
t.Fatalf("persona = %q, want trimmed role persona", got.Persona)
|
||||
}
|
||||
if strings.Join(got.KnowledgeSources, ",") != "wiki:1,./kb/hr" {
|
||||
t.Fatalf("knowledge sources = %v, want role's two", got.KnowledgeSources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRoleConfig_ExplicitFlagsWin is the headline contract: an option set
|
||||
// explicitly (by flag/env) is never overwritten by the role, and role knowledge
|
||||
// sources are appended to — not replacing — any already configured.
|
||||
func TestApplyRoleConfig_ExplicitFlagsWin(t *testing.T) {
|
||||
role := &RoleConfig{
|
||||
ClientID: "dingbot1",
|
||||
OwnerUserID: "owner-from-role",
|
||||
Persona: "role persona",
|
||||
KnowledgeSources: []string{"wiki:role"},
|
||||
}
|
||||
opts := connectAgentOptions{
|
||||
OwnerUserID: "owner-from-flag",
|
||||
Persona: "flag persona",
|
||||
KnowledgeSources: []string{"wiki:flag"},
|
||||
}
|
||||
got := applyRoleConfig(opts, role)
|
||||
|
||||
if got.OwnerUserID != "owner-from-flag" {
|
||||
t.Fatalf("owner = %q, want explicit flag to win", got.OwnerUserID)
|
||||
}
|
||||
if got.Persona != "flag persona" {
|
||||
t.Fatalf("persona = %q, want explicit flag to win", got.Persona)
|
||||
}
|
||||
if strings.Join(got.KnowledgeSources, ",") != "wiki:flag,wiki:role" {
|
||||
t.Fatalf("knowledge sources = %v, want flag then role appended", got.KnowledgeSources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRoleConfig_NilRole is a defensive no-op guard.
|
||||
func TestApplyRoleConfig_NilRole(t *testing.T) {
|
||||
opts := connectAgentOptions{OwnerUserID: "x"}
|
||||
if got := applyRoleConfig(opts, nil); got.OwnerUserID != "x" {
|
||||
t.Fatalf("nil role must be a no-op, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// connectSessionStorePath returns the on-disk location for a robot's
|
||||
// conversation→session map, scoped by clientId so multiple bots on one machine
|
||||
// stay isolated: <config dir>/connect/<clientId>/sessions.json. An empty
|
||||
// clientId means "do not persist" (the caller keeps the map in memory only,
|
||||
// preserving the pre-persistence behaviour). The clientId is sanitized with the
|
||||
// same rule as the connect lock file so it is always filesystem-safe.
|
||||
func connectSessionStorePath(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "sessions.json")
|
||||
}
|
||||
|
||||
// loadConvSessionMap reads a persisted conversation→session map from path. It is
|
||||
// deliberately forgiving: a missing file (first run) or a corrupt/unparseable
|
||||
// file degrades to an empty map plus a warning, never an error or panic, so a
|
||||
// bad file on disk can never block the connector from coming up. An empty path
|
||||
// (persistence disabled) also yields an empty map.
|
||||
func loadConvSessionMap(path string) map[string]string {
|
||||
if path == "" {
|
||||
return make(map[string]string)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 读取会话存档失败,按空会话起:%v\n", err)
|
||||
}
|
||||
return make(map[string]string)
|
||||
}
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 会话存档已损坏,按空会话起:%v\n", err)
|
||||
return make(map[string]string)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// saveConvSessionMap atomically persists m to path with 0600 perms. It is
|
||||
// best-effort: any failure (mkdir, marshal, write, rename) only logs a warning
|
||||
// and returns — message handling must never block or fail because the session
|
||||
// snapshot could not be written. The write is atomic (temp file in the same
|
||||
// directory + rename) so a crash mid-write can never leave a half-written,
|
||||
// later-unparseable file. An empty path is a no-op (persistence disabled).
|
||||
func saveConvSessionMap(path string, m map[string]string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话存档目录失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
// map[string]string never fails to marshal, but stay defensive.
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 序列化会话失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "sessions-*.json.tmp")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 设置会话文件权限失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 写入会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 关闭会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 原子替换会话存档失败,跳过本次落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConvSessionsPersistAndReload verifies a minted session survives a
|
||||
// "restart": a fresh convSessions pointed at the same store recovers the map
|
||||
// and resumes (--resume) instead of minting a new --session-id.
|
||||
func TestConvSessionsPersistAndReload(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
path := connectSessionStorePath("robot-abc")
|
||||
if path == "" {
|
||||
t.Fatal("store path should be non-empty for a real clientId")
|
||||
}
|
||||
|
||||
s := newConvSessions(path)
|
||||
first := s.args("conv-1")
|
||||
if first[0] != "--session-id" {
|
||||
t.Fatalf("first call should mint: %v", first)
|
||||
}
|
||||
id := first[1]
|
||||
|
||||
// The mint must have hit disk already (persist happens inside args).
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("session store not written: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a connector restart: a brand-new map from the same path.
|
||||
reloaded := newConvSessions(path)
|
||||
got := reloaded.args("conv-1")
|
||||
if got[0] != "--resume" || got[1] != id {
|
||||
t.Fatalf("after reload want [--resume %s], got %v", id, got)
|
||||
}
|
||||
|
||||
// reset must persist the removal: a further restart re-mints.
|
||||
reloaded.reset("conv-1")
|
||||
afterReset := newConvSessions(path)
|
||||
fresh := afterReset.args("conv-1")
|
||||
if fresh[0] != "--session-id" || fresh[1] == id {
|
||||
t.Fatalf("after reset+reload want a fresh --session-id != %s, got %v", id, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvSessionsCorruptFileDegrades verifies a corrupt store file degrades to
|
||||
// an empty map (fresh start) without panicking or erroring.
|
||||
func TestConvSessionsCorruptFileDegrades(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "connect", "robot", "sessions.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{ this is not valid json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newConvSessions(path) // must not panic
|
||||
got := s.args("conv-1")
|
||||
if got[0] != "--session-id" {
|
||||
t.Fatalf("corrupt store should start empty (mint), got %v", got)
|
||||
}
|
||||
// And it should self-heal: the next load reads the now-valid file.
|
||||
reloaded := newConvSessions(path)
|
||||
if r := reloaded.args("conv-1"); r[0] != "--resume" || r[1] != got[1] {
|
||||
t.Fatalf("after rewrite want [--resume %s], got %v", got[1], r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvSessionsEmptyPathInMemory verifies an empty path keeps everything in
|
||||
// memory: nothing is written and behaviour matches the pre-persistence map.
|
||||
func TestConvSessionsEmptyPathInMemory(t *testing.T) {
|
||||
if p := connectSessionStorePath(""); p != "" {
|
||||
t.Fatalf("empty clientId must yield empty path, got %q", p)
|
||||
}
|
||||
s := newConvSessions("")
|
||||
if first := s.args("c"); first[0] != "--session-id" {
|
||||
t.Fatalf("mint expected, got %v", first)
|
||||
}
|
||||
if again := s.args("c"); again[0] != "--resume" {
|
||||
t.Fatalf("resume expected, got %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvSessionsConcurrentAccess exercises the lock under concurrent
|
||||
// args/reset across many conversations, persisting to a real file, to catch
|
||||
// data races (run with -race). It must not panic and must leave a parseable
|
||||
// store.
|
||||
func TestConvSessionsConcurrentAccess(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
s := newConvSessions(connectSessionStorePath("robot-race"))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
conv := "conv-" + string(rune('a'+n%8))
|
||||
for j := 0; j < 50; j++ {
|
||||
_ = s.args(conv)
|
||||
if j%7 == 0 {
|
||||
s.reset(conv)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// The store must still be valid JSON after the storm.
|
||||
reloaded := newConvSessions(connectSessionStorePath("robot-race"))
|
||||
_ = reloaded.args("conv-z") // must not panic
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
// 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.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBrandReply covers the qoderwork identity rewrite using the exact replies
|
||||
// captured from a real qodercli (QoderWork.app) headless run.
|
||||
func TestBrandReply(t *testing.T) {
|
||||
const persona = "我是 QoderWork 助手,钉钉群里的智能助手。"
|
||||
cases := []struct {
|
||||
name string
|
||||
channel string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "real reply 1 — whole sentence is the self-intro",
|
||||
channel: "qoderwork",
|
||||
in: "我是 Qoder,一个帮助你完成软件工程任务的交互式命令行工具。",
|
||||
want: persona,
|
||||
},
|
||||
{
|
||||
name: "real reply 2 — self-intro then capability text kept",
|
||||
channel: "qoderwork",
|
||||
in: "我是 Qoder,一个交互式命令行工具,帮助用户完成软件工程任务。我可以协助你编写代码、调试问题。",
|
||||
want: persona + "我可以协助你编写代码、调试问题。",
|
||||
},
|
||||
{
|
||||
name: "real reply 3 — self-intro then newline block kept",
|
||||
channel: "qoderwork",
|
||||
in: "我是 Qoder,一个交互式命令行工具,主要帮助用户完成软件工程相关的任务。\n\n我的核心能力包括:",
|
||||
want: persona + "\n\n我的核心能力包括:",
|
||||
},
|
||||
{
|
||||
name: "english self-intro",
|
||||
channel: "qoderwork",
|
||||
in: "I am Qoder, an interactive CLI tool that helps with software engineering.",
|
||||
want: persona,
|
||||
},
|
||||
{
|
||||
name: "mid-text Qoder mention is NOT rewritten",
|
||||
channel: "qoderwork",
|
||||
in: "Qoder 是一家做 AI 编程工具的公司,它的产品不错。",
|
||||
want: "Qoder 是一家做 AI 编程工具的公司,它的产品不错。",
|
||||
},
|
||||
{
|
||||
name: "normal answer untouched",
|
||||
channel: "qoderwork",
|
||||
in: "1+1 等于 2。",
|
||||
want: "1+1 等于 2。",
|
||||
},
|
||||
{
|
||||
name: "other channels pass through even if they say Qoder",
|
||||
channel: "codebuddy",
|
||||
in: "我是 Qoder,一个命令行工具。",
|
||||
want: "我是 Qoder,一个命令行工具。",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := brandReply(tc.channel, tc.in); got != tc.want {
|
||||
t.Fatalf("brandReply(%q, %q)\n got: %q\n want: %q", tc.channel, tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeUserSettingsEnv covers the third-party-provider auth passthrough
|
||||
// for the claudecode channel (issue PeterGuy326#10): the env block of the
|
||||
// user-level Claude settings must surface as process env entries, without
|
||||
// clobbering variables the operator already exported.
|
||||
func TestClaudeUserSettingsEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("CLAUDE_CONFIG_DIR", dir)
|
||||
|
||||
// No settings file → no injection.
|
||||
if got := claudeUserSettingsEnv(); len(got) != 0 {
|
||||
t.Fatalf("expected no env without settings.json, got %v", got)
|
||||
}
|
||||
|
||||
writeSettings := func(body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed JSON → fail open with no injection.
|
||||
writeSettings(`{not json`)
|
||||
if got := claudeUserSettingsEnv(); len(got) != 0 {
|
||||
t.Fatalf("expected no env for malformed settings, got %v", got)
|
||||
}
|
||||
|
||||
// Provider credentials in the env block are exposed; a key already present
|
||||
// in the process environment is left alone.
|
||||
t.Setenv("ANTHROPIC_MODEL", "from-shell")
|
||||
writeSettings(`{"env":{"ANTHROPIC_BASE_URL":"https://relay.example","ANTHROPIC_AUTH_TOKEN":"tok","ANTHROPIC_MODEL":"from-settings"}}`)
|
||||
got := claudeUserSettingsEnv()
|
||||
want := map[string]bool{
|
||||
"ANTHROPIC_BASE_URL=https://relay.example": false,
|
||||
"ANTHROPIC_AUTH_TOKEN=tok": false,
|
||||
}
|
||||
for _, kv := range got {
|
||||
if strings.HasPrefix(kv, "ANTHROPIC_MODEL=") {
|
||||
t.Fatalf("process env must win over settings env, got %q", kv)
|
||||
}
|
||||
if _, ok := want[kv]; ok {
|
||||
want[kv] = true
|
||||
}
|
||||
}
|
||||
for kv, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("missing %q in %v", kv, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Incremental forwarding: channels whose CLI can emit stream-json get their
|
||||
// reply streamed into the AI card as it is produced (the hermes/openclaw
|
||||
// experience), instead of one shot at the end.
|
||||
//
|
||||
// Verified stdout protocols (run live against each CLI):
|
||||
// - "cc" (claude / codebuddy): JSONL; visible text arrives as
|
||||
// {"type":"stream_event","event":{"type":"content_block_delta",
|
||||
// "delta":{"type":"text_delta","text":"…"}}} (thinking_delta frames are
|
||||
// internal reasoning and are NOT shown); the final authoritative text is
|
||||
// {"type":"result","result":"…"}.
|
||||
// - "qoder" (qodercli): JSONL; per-turn snapshots arrive as
|
||||
// {"type":"assistant","message":{"content":[{"type":"text","text":"…"}]}}
|
||||
// and the final answer as {"type":"result","subtype":"success",
|
||||
// "message":{"content":[…]}}.
|
||||
|
||||
// canStream reports whether this forwarder has an incremental-output mode.
|
||||
func (f *execForwarder) canStream() bool {
|
||||
return len(f.streamArgv) > 0 && f.parser != ""
|
||||
}
|
||||
|
||||
// forwardStream runs the agent in incremental-output mode, calling onDelta
|
||||
// with the full visible text so far whenever it grows, and returns the final
|
||||
// reply. Falls back to the one-shot forward when streaming is unsupported.
|
||||
func (f *execForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
if !f.canStream() || onDelta == nil {
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
var args []string
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
args = append(args, f.streamArgv[1:]...)
|
||||
args = append(args, text)
|
||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
||||
if f.workDir != "" {
|
||||
cmd.Dir = f.workDir
|
||||
} else {
|
||||
cmd.Dir = connectWorkDir()
|
||||
}
|
||||
if len(f.env) > 0 {
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
||||
finalText := ""
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
delta, final := parseStreamLine(f.parser, line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
if final != "" {
|
||||
finalText = final
|
||||
}
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(acc.String())
|
||||
}
|
||||
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
||||
// forwarded as the answer (issue #14): return an actionable hint instead.
|
||||
if agentReplyIsError(finalText) {
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(finalText), nil
|
||||
}
|
||||
if finalText != "" {
|
||||
return brandReply(f.name, finalText), nil
|
||||
}
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
if waitErr != nil {
|
||||
msg := waitErr.Error()
|
||||
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
}
|
||||
|
||||
// parseStreamLine extracts (visible-text delta, final text) from one JSONL
|
||||
// line, per parser protocol. Either return value may be empty.
|
||||
func parseStreamLine(parser, line string) (delta, final string) {
|
||||
switch parser {
|
||||
case "cc":
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Event struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
} `json:"event"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &ev) != nil {
|
||||
return "", ""
|
||||
}
|
||||
switch ev.Type {
|
||||
case "stream_event":
|
||||
if ev.Event.Type == "content_block_delta" && ev.Event.Delta.Type == "text_delta" {
|
||||
return ev.Event.Delta.Text, ""
|
||||
}
|
||||
case "result":
|
||||
return "", strings.TrimSpace(ev.Result)
|
||||
}
|
||||
case "qoder":
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
Result string `json:"result"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &ev) != nil {
|
||||
return "", ""
|
||||
}
|
||||
text := func() string {
|
||||
var b strings.Builder
|
||||
for _, c := range ev.Message.Content {
|
||||
if c.Type == "text" {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
switch {
|
||||
case ev.Type == "result" && ev.Subtype == "success":
|
||||
if t := text(); t != "" {
|
||||
return "", t
|
||||
}
|
||||
return "", strings.TrimSpace(ev.Result)
|
||||
case ev.Type == "assistant":
|
||||
if t := text(); t != "" {
|
||||
// Turn-level snapshot: append as a paragraph.
|
||||
return t + "\n\n", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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"
|
||||
|
||||
func TestParseStreamLineCC(t *testing.T) {
|
||||
d, f := parseStreamLine("cc", `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}}}`)
|
||||
if d != "你好" || f != "" {
|
||||
t.Fatalf("text_delta: d=%q f=%q", d, f)
|
||||
}
|
||||
// thinking deltas are internal reasoning — must NOT surface
|
||||
d, f = parseStreamLine("cc", `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"hmm"}}}`)
|
||||
if d != "" || f != "" {
|
||||
t.Fatalf("thinking_delta must be ignored: d=%q f=%q", d, f)
|
||||
}
|
||||
d, f = parseStreamLine("cc", `{"type":"result","result":"最终答案"}`)
|
||||
if d != "" || f != "最终答案" {
|
||||
t.Fatalf("result: d=%q f=%q", d, f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLineQoder(t *testing.T) {
|
||||
d, f := parseStreamLine("qoder", `{"type":"assistant","subtype":"message","message":{"content":[{"type":"text","text":"第一段"}]}}`)
|
||||
if d != "第一段\n\n" || f != "" {
|
||||
t.Fatalf("assistant: d=%q f=%q", d, f)
|
||||
}
|
||||
d, f = parseStreamLine("qoder", `{"type":"result","subtype":"success","message":{"content":[{"type":"text","text":"1、2、3"}]}}`)
|
||||
if d != "" || f != "1、2、3" {
|
||||
t.Fatalf("result: d=%q f=%q", d, f)
|
||||
}
|
||||
d, f = parseStreamLine("qoder", `{"type":"result","subtype":"success","result":"OK"}`)
|
||||
if d != "" || f != "OK" {
|
||||
t.Fatalf("result field: d=%q f=%q", d, f)
|
||||
}
|
||||
// garbage tolerated
|
||||
if d, f := parseStreamLine("qoder", `{not json`); d != "" || f != "" {
|
||||
t.Fatalf("garbage: d=%q f=%q", d, f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSpecsCoverage(t *testing.T) {
|
||||
for ch, want := range map[string]string{
|
||||
"claudecode": "cc", "codebuddy": "cc", "workbuddy": "cc",
|
||||
"qoder": "qoder", "qoderwork": "qoder",
|
||||
"codex": "", "gemini": "", "opencode": "",
|
||||
} {
|
||||
spec := agentSpecs[ch]
|
||||
if spec.streamParser != want {
|
||||
t.Errorf("%s streamParser = %q, want %q", ch, spec.streamParser, want)
|
||||
}
|
||||
if (want != "") != (len(spec.streamArgvTail) > 0) {
|
||||
t.Errorf("%s streamArgvTail presence mismatch", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return devHandler{}
|
||||
})
|
||||
}
|
||||
|
||||
// devHandler mounts the developer umbrella command `dws dev`, grouping the
|
||||
// three developer-facing pillars under one entry:
|
||||
//
|
||||
// dev app application lifecycle (CRUD / credentials / permission /
|
||||
// member / security / webapp / robot / version)
|
||||
// dev connect link an existing robot to the local agent for debugging
|
||||
// (promoted out of the robot subtree: it is a local-dev entry,
|
||||
// not a robot configuration)
|
||||
// dev doc open-platform developer doc search (bridges the devdoc
|
||||
// product; `dws devdoc` keeps working independently)
|
||||
//
|
||||
// Future developer capabilities (event subscription, MCP tooling, ...) join
|
||||
// as new subtrees here instead of new top-level commands.
|
||||
type devHandler struct{}
|
||||
|
||||
func (devHandler) Name() string {
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "dev",
|
||||
Short: "开放平台开发者能力",
|
||||
Long: "钉钉开放平台开发者命令组:应用生命周期管理(app)、机器人本地调试建联(connect)、开发文档搜索(doc)。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
doc := &cobra.Command{
|
||||
Use: "doc",
|
||||
Short: "开放平台文档搜索",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
doc.AddCommand(newDevdocArticleSearchCommand(runner))
|
||||
|
||||
root.AddCommand(
|
||||
newDevAppCommand(runner),
|
||||
newDevAppRobotConnectCommand(runner),
|
||||
doc,
|
||||
)
|
||||
return root
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,785 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
// devapp_connect wires the channel-aware "建联" (linking) capability into the
|
||||
// dev command tree as `dws dev connect`. Provisioning (建号) is NOT
|
||||
// duplicated here — it reuses the existing `dev app robot create/submit/result`.
|
||||
// This file holds only the linking half: detect the current agent channel, then
|
||||
// start a Go-native in-process Stream that forwards @-bot messages to the local
|
||||
// agent CLI. The Stream forwarder internals live in connect_stream.go.
|
||||
|
||||
// connectChannels is the set of supported channels: the external ones plus every
|
||||
// exec-type agent in agentSpecs (kept in sync automatically).
|
||||
var connectChannels = func() map[string]struct{} {
|
||||
m := map[string]struct{}{"openclaw": {}, "hermes": {}}
|
||||
for ch := range agentSpecs {
|
||||
m[ch] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// resolveConnectChannel resolves the current agent channel using "explicit wins,
|
||||
// then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var >
|
||||
// each agent's known runtime signal. Returns the channel name and the basis for
|
||||
// the decision (detectedBy, for troubleshooting).
|
||||
//
|
||||
// Signals (verified on real runtimes):
|
||||
// - openclaw connector injects DINGTALK_AGENT=DING_DWS_CLAW.
|
||||
// - WorkBuddy injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME into spawned children.
|
||||
// - QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (and neither QODER_CLI nor CLAUDECODE).
|
||||
// - plain Qoder injects QODER_CLI=1 (it is a Claude Code fork, so also CLAUDECODE=1).
|
||||
// - pure Claude Code injects only CLAUDECODE=1.
|
||||
// - hermes uses the official channel, marked by HERMES_AGENT / HERMES.
|
||||
func resolveConnectChannel(explicit string) (channel string, detectedBy string) {
|
||||
if norm := strings.ToLower(strings.TrimSpace(explicit)); norm != "" && norm != "auto" {
|
||||
return norm, "flag:--channel"
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_AGENT_CHANNEL"))); v != "" {
|
||||
return v, "env:DWS_AGENT_CHANNEL"
|
||||
}
|
||||
// Signal fallback.
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("DINGTALK_AGENT")), "DING_DWS_CLAW") {
|
||||
return "openclaw", "signal:DINGTALK_AGENT"
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("OPENCLAW")) != "" || strings.TrimSpace(os.Getenv("OPENCLAW_GATEWAY")) != "" {
|
||||
return "openclaw", "signal:OPENCLAW"
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("HERMES_AGENT")) != "" || strings.TrimSpace(os.Getenv("HERMES")) != "" {
|
||||
return "hermes", "signal:HERMES"
|
||||
}
|
||||
// WorkBuddy(CodeBuddy) injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME
|
||||
// (verified, pointing at ~/.workbuddy) into the children it spawns. This is a
|
||||
// WorkBuddy-specific runtime marker that does not leak globally, so dws can
|
||||
// recognise the current host when WorkBuddy spawns it.
|
||||
if strings.TrimSpace(os.Getenv("WORKBUDDY_CONFIG_DIR")) != "" || strings.TrimSpace(os.Getenv("WORKBUDDY_APP_NAME")) != "" {
|
||||
return "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"
|
||||
}
|
||||
// QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (verified)
|
||||
// and carries neither QODER_CLI nor CLAUDECODE. Use it to split qoderwork out
|
||||
// of the qoder family, avoiding "linking inside QoderWork but reaching Qoder".
|
||||
// Must come before the QODER_CLI / CLAUDECODE checks below.
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("QODERCLI_INTEGRATION_MODE")), "qoder_work") {
|
||||
return "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("QODER_CLI")) != "" {
|
||||
// Plain Qoder (AI coding IDE): carries QODER_CLI=1. Qoder is a Claude Code
|
||||
// fork and also carries CLAUDECODE=1, so this check must precede CLAUDECODE
|
||||
// below to avoid misdetecting it as claudecode.
|
||||
return "qoder", "signal:QODER_CLI"
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("CLAUDECODE")) != "" {
|
||||
// Pure Claude Code (not the qoder fork): only CLAUDECODE=1, no QODER_CLI.
|
||||
return "claudecode", "signal:CLAUDECODE"
|
||||
}
|
||||
// Last resort: a self-built / unsupported AI tool that supplied its own
|
||||
// command (via --agent-cmd, which sets DWS_AGENT_CMD, or DWS_AGENT_CMD
|
||||
// directly) routes to the generic "custom" channel — so onboarding an
|
||||
// unrecognised host needs no per-tool detection signal (issue #37).
|
||||
if strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" {
|
||||
return "custom", "env:DWS_AGENT_CMD"
|
||||
}
|
||||
return "", "undetected"
|
||||
}
|
||||
|
||||
// buildConnectPlan returns the linking plan that wires the bot to a channel's
|
||||
// local agent. External channels (openclaw/hermes) have bespoke plans; every
|
||||
// exec-type agent (agentSpecs) shares a generic Stream + headless-CLI plan. Used
|
||||
// for the --dry-run preview.
|
||||
func buildConnectPlan(channel, clientID, robotCode string) map[string]any {
|
||||
switch channel {
|
||||
case "openclaw":
|
||||
return map[string]any{
|
||||
"method": "openclaw-connector",
|
||||
"summary": "走 dingtalk-openclaw-connector 官方建联,由连接器自己建号 + AI 卡片流式回复(dws 不代建机器人)",
|
||||
"steps": []string{
|
||||
"按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 设备码扫码注册机器人",
|
||||
"启动 openclaw gateway,由连接器处理消息收发与卡片渲染",
|
||||
},
|
||||
}
|
||||
case "hermes":
|
||||
return map[string]any{
|
||||
"method": "official-channel",
|
||||
"summary": "走 Hermes 官方 channel 建联,由 Hermes 自己建号 + 原生回复(dws 不代建机器人)",
|
||||
"steps": []string{
|
||||
"运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan 扫码授权",
|
||||
"`hermes gateway restart`,直接在钉钉里跟新机器人对话",
|
||||
"回复打了 Done 表情却不显示/卡在'数据加载中':钉钉按 AI 助理应答窗口渲染回复,超窗的纯文本会被丢弃;回复慢的 agent 需在 hermes 侧启用 AI 卡片(config.yaml 配 platforms.dingtalk.extra.card_template_id),由卡片先占位再流式出字",
|
||||
},
|
||||
}
|
||||
case "custom":
|
||||
return map[string]any{
|
||||
"method": "stream-bridge-custom",
|
||||
"summary": "Go 原生 Stream 建联,转发到 --agent-cmd/DWS_AGENT_CMD 指定的自定义 AI CLI(无头/一次性:问题作为末参,stdout 作回复);用来接入未内置支持的或自研的 AI 工具",
|
||||
"steps": []string{
|
||||
"用 --agent-cmd \"<你的命令>\"(或 DWS_AGENT_CMD)指定 AI 工具的无头/一次性命令",
|
||||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||||
"收到消息 → 运行 <你的命令> \"用户问题\" → stdout 作为回复",
|
||||
"经 sessionWebhook/AI 卡片把回复发回钉钉",
|
||||
},
|
||||
}
|
||||
}
|
||||
if spec, ok := agentSpecs[channel]; ok {
|
||||
if channel == "codex" {
|
||||
return map[string]any{
|
||||
"method": "stream-bridge-codex-app-server",
|
||||
"summary": "Go 原生 Stream 建联,转发到本地 Codex app-server 的 thread/turn 协议",
|
||||
"steps": []string{
|
||||
"自动定位 Codex CLI(PATH),启动 app-server",
|
||||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||||
"收到消息 → 按 conversationId 映射/恢复 Codex thread → turn/start 获取结构化增量与完成事件",
|
||||
"经 AI 卡片或 sessionWebhook 把回复发回钉钉,并记录发送成功/失败",
|
||||
},
|
||||
}
|
||||
}
|
||||
if channel == "opencode" {
|
||||
return map[string]any{
|
||||
"method": "stream-bridge-opencode-server",
|
||||
"summary": "Go 原生 Stream 建联,转发到本地 opencode serve 的 HTTP session/message 协议",
|
||||
"steps": []string{
|
||||
"自动定位 opencode CLI(PATH),启动 opencode serve --pure",
|
||||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||||
"收到消息 → 按 conversationId 映射/恢复 opencode session → /session/{id}/message 获取一次性回复",
|
||||
"经 AI 卡片或 sessionWebhook 把回复发回钉钉,并记录发送成功/失败",
|
||||
},
|
||||
}
|
||||
}
|
||||
if channel == "qoder" || channel == "qoderwork" {
|
||||
return map[string]any{
|
||||
"method": "stream-bridge-qoder-stream",
|
||||
"summary": fmt.Sprintf("Go 原生 Stream 建联,转发到本地 %s 的常驻 qodercli stream-json 子进程", spec.app),
|
||||
"steps": []string{
|
||||
"自动定位 qodercli(PATH 或 app 自带)",
|
||||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||||
"启动并复用 qodercli --print --output-format stream-json --input-format stream-json",
|
||||
"收到消息 → 按 conversationId 传入 Qoder session_id → 读取 assistant/result JSON 作为回复",
|
||||
"经 sessionWebhook/AI 卡片把回复发回钉钉,并记录发送成功/失败",
|
||||
},
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"method": "stream-bridge",
|
||||
"summary": fmt.Sprintf("Go 原生 Stream 建联,转发到本地 %s 的无头 CLI(每条消息起一个新实例,可 7×24 无人值守)", spec.app),
|
||||
"steps": []string{
|
||||
"自动定位 agent CLI(DWS_AGENT_CMD > PATH > app 自带),缺包管理器装的会自动安装、装不了的提示安装",
|
||||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||||
"收到消息 → 调该 agent 的无头 CLI(如 claude -p / codebuddy -p)→ stdout 作为回复",
|
||||
"经 sessionWebhook 把回复发回钉钉",
|
||||
},
|
||||
}
|
||||
}
|
||||
return map[string]any{"method": "unknown"}
|
||||
}
|
||||
|
||||
// connectExternalCommand returns the connector command (argv) for channels that
|
||||
// must be launched by an external process. Resolution priority: the
|
||||
// DWS_CONNECT_CMD env var (space-separated, for customisation/testing, applies
|
||||
// to all channels) > openclaw's built-in gateway. The stream-bridge channels
|
||||
// (qoder/qoderwork/claudecode/workbuddy) use the Go-native in-process Stream
|
||||
// (see connect_stream.go) and return no external command (nil). hermes uses the
|
||||
// official channel and also has no built-in external command. Pure function,
|
||||
// side-effect free, for easy unit testing.
|
||||
func connectExternalCommand(channel string) []string {
|
||||
if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_CMD")); v != "" {
|
||||
return strings.Fields(v)
|
||||
}
|
||||
switch channel {
|
||||
case "openclaw":
|
||||
// openclaw is taken over by the external connector: write credentials into
|
||||
// openclaw.json, then restart the gateway.
|
||||
return []string{"openclaw", "gateway", "restart"}
|
||||
default:
|
||||
// stream-bridge channels go Go-native; hermes etc. have no built-in command
|
||||
// and need DWS_CONNECT_CMD.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// launchConnector wires the bot to the local agent per channel, running in the
|
||||
// foreground until interrupted. Dispatch priority:
|
||||
// 1. external connector (DWS_CONNECT_CMD override or openclaw gateway) → os/exec
|
||||
// child, credentials injected via CID/SEC/DWS_AGENT_CHANNEL;
|
||||
// 2. stream-bridge channels (qoder/qoderwork/claudecode/workbuddy) → Go-native
|
||||
// in-process Stream + forwarder, no node/external-script dependency;
|
||||
// 3. others (hermes etc.) → no built-in linking, advise DWS_CONNECT_CMD.
|
||||
func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, clientID, clientSecret string, opts connectAgentOptions) error {
|
||||
if argv := connectExternalCommand(channel); len(argv) > 0 {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s 启动外部连接器: %s\n", channel, strings.Join(argv, " "))
|
||||
proc := exec.CommandContext(cmd.Context(), argv[0], argv[1:]...)
|
||||
proc.Env = append(os.Environ(),
|
||||
"CID="+clientID,
|
||||
"SEC="+clientSecret,
|
||||
"DWS_AGENT_CHANNEL="+channel,
|
||||
)
|
||||
proc.Stdout = cmd.OutOrStdout()
|
||||
proc.Stderr = cmd.ErrOrStderr()
|
||||
return proc.Run()
|
||||
}
|
||||
|
||||
if isStreamBridgeChannel(channel) {
|
||||
// Role config ("digital employee"): when supplied, validate it targets THIS
|
||||
// bot and fold its owner / persona / knowledge into the options that were
|
||||
// not set explicitly (an explicit flag always wins). Done first so the
|
||||
// merged options feed the forwarder, knowledge loading and the gate below.
|
||||
if opts.RoleConfigPath != "" {
|
||||
role, rerr := LoadRoleConfig(opts.RoleConfigPath)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if role.ClientID != clientID {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"角色配置 %q 绑定 client_id=%s,与本次连接的机器人 %s 不一致;一个角色对应一个机器人,请核对凭证或换用对应的角色配置",
|
||||
opts.RoleConfigPath, role.ClientID, clientID))
|
||||
}
|
||||
opts = applyRoleConfig(opts, role)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 角色已加载:%s(主人=%s|知识源 +%d|权限scope=[%s]|确认策略=%s)\n",
|
||||
role.Name, opts.OwnerUserID, len(role.KnowledgeSources), strings.Join(role.AllowedScopes, ","), role.ConfirmPolicy)
|
||||
}
|
||||
fwd, err := forwarderForChannel(channel, clientID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cardCli *aiCardClient
|
||||
replyStyle := "text/markdown"
|
||||
if opts.ReplyCard {
|
||||
cardCli = newAICardClient(clientID, clientSecret, opts.CardTemplate)
|
||||
if cardCli.hasTemplate() {
|
||||
replyStyle = "ai-card(thinking→done, 失败回退普通消息)"
|
||||
} else {
|
||||
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
|
||||
}
|
||||
}
|
||||
extras := &connectExtras{persona: opts.Persona}
|
||||
if opts.KnowledgeDir != "" {
|
||||
kb, kerr := loadKnowledgeBase(opts.KnowledgeDir)
|
||||
if kerr != nil {
|
||||
return kerr
|
||||
}
|
||||
extras.kb = kb
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir)
|
||||
}
|
||||
if opts.KnowledgeSource != "" {
|
||||
if kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil {
|
||||
return kerr
|
||||
} else if kb != nil {
|
||||
extras.kb = kb
|
||||
}
|
||||
}
|
||||
// Role-supplied knowledge sources are additive: load each and merge its
|
||||
// chunks into the same retriever (knowledgeBase is just a chunk slice), so
|
||||
// a role can carry several wiki/doc/dir sources without a flag per source.
|
||||
for _, src := range opts.KnowledgeSources {
|
||||
kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, src)
|
||||
if kerr != nil {
|
||||
return kerr
|
||||
}
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
if extras.kb == nil {
|
||||
extras.kb = kb
|
||||
} else {
|
||||
extras.kb.chunks = append(extras.kb.chunks, kb.chunks...)
|
||||
}
|
||||
}
|
||||
if len(opts.AllowedUsers) > 0 || len(opts.AllowedGroups) > 0 || opts.UserRateLimit > 0 {
|
||||
extras.gate = newConnectGate(opts.AllowedUsers, opts.AllowedGroups, opts.UserRateLimit)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 访问控制:用户白名单 %d、群白名单 %d、限流 %d 条/分钟/人\n",
|
||||
len(opts.AllowedUsers), len(opts.AllowedGroups), opts.UserRateLimit)
|
||||
}
|
||||
// Confirmation gate ("digital twin"): when an owner + an interactive-card
|
||||
// template are configured, action requests route to the owner for approval
|
||||
// before executing. Needs a runner to run the approved command directly.
|
||||
if opts.OwnerUserID != "" {
|
||||
gate := newApprovalGate(clientID)
|
||||
// Optional online-sheet audit: one row per action, reviewable in
|
||||
// DingTalk. Runs under the connector (bot) identity via the runner.
|
||||
var audit auditSink
|
||||
if opts.AuditSheetNode != "" {
|
||||
audit = &sheetAuditSink{runner: runner, nodeID: opts.AuditSheetNode, sheetID: opts.AuditSheetTab}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 操作审计已开启:在线表格 node=%s tab=%s(每个操作追加一行)\n", opts.AuditSheetNode, opts.AuditSheetTab)
|
||||
}
|
||||
if len(opts.RoleScopes) > 0 {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 角色能力边界:仅允许 [%s](其它产品的执行动作会被拒)\n", strings.Join(opts.RoleScopes, ", "))
|
||||
}
|
||||
if opts.ConfirmPolicy != "" && opts.ConfirmPolicy != "manual" {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认策略:%s(manual=每次问 / auto=不问直接做仍留痕 / remember=同类记住上次)\n", opts.ConfirmPolicy)
|
||||
}
|
||||
if sender := newDingtalkApprovalCardSender(clientID, clientSecret, opts.ApprovalCardTemplate); sender != nil {
|
||||
orch := newApprovalOrchestrator(gate, sender, runner, opts.OwnerUserID)
|
||||
orch.audit = audit
|
||||
orch.allowedScopes = opts.RoleScopes
|
||||
orch.confirmPolicy = opts.ConfirmPolicy
|
||||
extras.approval = orch
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认闸已开启:主人=%s(执行类请求先卡片审批,[同意]/[拒绝]按钮)\n", opts.OwnerUserID)
|
||||
} else {
|
||||
// No card template → fall back to text approval instead of disabling
|
||||
// the gate: the bot privately DMs the owner, who confirms by replying
|
||||
// 「同意」/「拒绝」 in their 1:1 chat — the requester never sees the
|
||||
// approval. Works with zero card-platform setup. The notifier reuses
|
||||
// the bot's own credentials (robot 1:1 send), so it reaches the owner
|
||||
// in the bot's org.
|
||||
notifier := newAICardClient(clientID, clientSecret, "")
|
||||
orch := newTextApprovalOrchestrator(gate, runner, opts.OwnerUserID, notifier)
|
||||
orch.audit = audit
|
||||
orch.allowedScopes = opts.RoleScopes
|
||||
orch.confirmPolicy = opts.ConfirmPolicy
|
||||
extras.approval = orch
|
||||
// Background auto-retry: a deferred backlog (e.g. queued while the
|
||||
// dws login was not yet the owner) drains by itself once the identity
|
||||
// is back — no manual "重试" needed. Silent unless something completes.
|
||||
orch.startAutoRetry(cmd.Context(), 2*time.Minute)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认闸已开启:主人=%s(文本审批:执行类请求私聊主人,主人回复「同意」/「拒绝」确认,请求人无感;积压请求每2分钟自动重试,也可回「重试」立即补做;配 --approval-card-template 可升级为卡片按钮)\n", opts.OwnerUserID)
|
||||
}
|
||||
}
|
||||
// Quality hint (issue #39): the bot runs in a clean temp dir with no
|
||||
// project knowledge by default, so it answers with less context than the
|
||||
// same agent in your terminal. Surface the levers once, only when neither
|
||||
// is set, so users discover why "终端答得对、机器人答不对".
|
||||
if opts.WorkDir == "" && opts.KnowledgeDir == "" && opts.KnowledgeSource == "" && len(opts.KnowledgeSources) == 0 {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 提示:机器人默认在空白临时目录里跑、不带你本地项目的上下文,回答可能不如终端准。要对齐终端:加 --agent-workdir <你的项目目录> 让它读到同样的文件,或 --knowledge-dir/--knowledge-source 挂资料。\n")
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle)
|
||||
return runStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras)
|
||||
}
|
||||
|
||||
// hermes / openclaw run their own official bot provisioning + reply logic
|
||||
// (device-flow QR registration, AI-card streaming). dws must not provision
|
||||
// a robot for them or wrap them in the Stream bridge — their bots are a
|
||||
// different type and only render through their native pipeline. Point the
|
||||
// user at the official tool instead.
|
||||
if guidance := officialChannelGuidance(channel); guidance != "" {
|
||||
fmt.Fprint(cmd.ErrOrStderr(), guidance)
|
||||
return nil
|
||||
}
|
||||
|
||||
return apperrors.NewValidation(fmt.Sprintf("渠道 %q 暂无内置建联;用环境变量 DWS_CONNECT_CMD 指定要运行的连接器", channel))
|
||||
}
|
||||
|
||||
// officialChannelGuidance returns onboarding instructions for channels that own
|
||||
// their bot lifecycle (hermes, openclaw). dws guides the user to the official
|
||||
// tool rather than provisioning a robot itself, because a dws-provisioned
|
||||
// "智能体" robot does not render through these channels' native reply path.
|
||||
func officialChannelGuidance(channel string) string {
|
||||
switch channel {
|
||||
case "hermes":
|
||||
return "[connect] hermes 渠道走 Hermes 官方建联,dws 不代建机器人:\n" +
|
||||
" 1. 运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan,用钉钉扫码授权\n" +
|
||||
" (设备码注册,自动把 client_id/secret 写入 ~/.hermes/.env)\n" +
|
||||
" 2. `hermes gateway restart`,直接在钉钉里跟新机器人对话\n"
|
||||
case "openclaw":
|
||||
return "[connect] openclaw 渠道走 dingtalk-openclaw-connector 官方建联,dws 不代建机器人:\n" +
|
||||
" 1. 按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 的设备码扫码注册机器人\n" +
|
||||
" 2. 启动 openclaw gateway,由连接器处理 AI 卡片流式回复\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const connectLocalDebugCompletionState = "LOCAL_DEBUG_ONLY"
|
||||
|
||||
func connectLocalDebugNotice() string {
|
||||
return "[connect] 提示:本地调试,不代表线上发布完成;dev connect 只建立本地 Stream,不会提交版本发布。若机器人来自 APPROVAL_REQUIRED,仍需继续执行 version create → check-approval → publish → status。\n"
|
||||
}
|
||||
|
||||
// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that
|
||||
// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top
|
||||
// level), so an agent can parse "is this a dry-run preview" the same way across
|
||||
// all dev commands. The connect-specific fields (channel/cli/connect/...) sit
|
||||
// inside, since connect is a linking pre-check, not an MCP tool call.
|
||||
func connectPreviewEnvelope(fields map[string]any) map[string]any {
|
||||
fields["kind"] = "connect_preview"
|
||||
fields["dry_run"] = true
|
||||
fields["scope"] = "local_debug_only"
|
||||
fields["doesNotPublish"] = true
|
||||
fields["completionState"] = connectLocalDebugCompletionState
|
||||
fields["terminal"] = false
|
||||
fields["message"] = "本地建联预检只说明 Stream 调试可用,不代表线上发布完成"
|
||||
return map[string]any{"invocation": fields}
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectCommand implements `dws dev connect`: the linking
|
||||
// half of provisioning. It takes an existing robot's credentials — either
|
||||
// directly via --client-id/--client-secret, or by --unified-app-id (reusing
|
||||
// devapp 的 `get_dev_app_credentials` to fetch them) — and starts the
|
||||
// channel-aware Stream connector in the foreground. It never provisions a robot;
|
||||
// for 建号 run `dws dev app robot create` (or submit/result) first.
|
||||
func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "connect",
|
||||
Short: "建联:把现成机器人接到当前本地 agent(起 Stream,不建号)",
|
||||
Long: "用已建好的机器人凭证把它接到当前本地 agent,不做建号。\n" +
|
||||
"凭证两种来源:① 直接传 --robot-client-id/--robot-client-secret;② 传 --unified-app-id(复用 dev app credentials get 自动取凭证)。\n" +
|
||||
"渠道由 --channel 显式指定,或运行时信号自动探测。\n" +
|
||||
"缺凭证请先用 `dws dev app robot submit` 建号(随后 `robot result` 轮询)拿 clientId/clientSecret。",
|
||||
Example: " dws dev connect --channel workbuddy --robot-client-id <id> --robot-client-secret <secret>\n" +
|
||||
" dws dev connect --unified-app-id <ID> --channel qoderwork\n" +
|
||||
" dws dev connect --channel claudecode --robot-client-id <id> --robot-client-secret <secret>\n" +
|
||||
" dws dev connect --agent-cmd \"lobster -p\" --robot-client-id <id> --robot-client-secret <secret> # 自研/未支持的 AI 工具",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Daemon dispatch (see connect_daemon.go). --daemon-supervise runs the
|
||||
// restart supervisor; --daemon (parent) re-execs into a detached
|
||||
// supervisor; --daemon-worker falls through to the normal foreground
|
||||
// connector below (it IS the connector, just spawned by the supervisor).
|
||||
supervise, _ := cmd.Flags().GetBool(daemonSuperviseFlag)
|
||||
if supervise {
|
||||
return runSupervisor(cmd)
|
||||
}
|
||||
channelFlag := devAppStringFlag(cmd, "channel")
|
||||
// --agent-cmd is sugar for the generic "custom" channel: it supplies the
|
||||
// agent argv (via DWS_AGENT_CMD) so an unsupported / self-built AI tool can
|
||||
// be onboarded without a detection signal (issue #37). The intent is
|
||||
// unambiguous, so it forces channel=custom unless the user explicitly chose
|
||||
// a different --channel (then we honour their choice but still pass the cmd).
|
||||
if ac := strings.TrimSpace(devAppStringFlag(cmd, "agent-cmd")); ac != "" {
|
||||
_ = os.Setenv("DWS_AGENT_CMD", ac)
|
||||
if channelFlag == "" || strings.EqualFold(channelFlag, "auto") {
|
||||
channelFlag = "custom"
|
||||
}
|
||||
}
|
||||
channel, detectedBy := resolveConnectChannel(channelFlag)
|
||||
if channel == "" {
|
||||
return apperrors.NewValidation("无法探测 agent 渠道;请用 --channel 指定 (openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode),或用 --agent-cmd \"<命令>\" 接入自研/未支持的 AI(custom 渠道),或设置 DWS_AGENT_CHANNEL")
|
||||
}
|
||||
if _, ok := connectChannels[channel]; !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("未知渠道 %q(支持 openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom)", channel))
|
||||
}
|
||||
|
||||
clientID := devAppStringFlag(cmd, "robot-client-id")
|
||||
clientSecret := devAppStringFlag(cmd, "robot-client-secret")
|
||||
unifiedAppID := devAppStringFlag(cmd, "unified-app-id")
|
||||
opts := connectAgentOptionsFromCommand(cmd)
|
||||
|
||||
// Credential resolution: explicit pair wins; otherwise reuse dev app's
|
||||
// credentials get against --unified-app-id.
|
||||
resolvedBy := "flag:--robot-client-id/--robot-client-secret"
|
||||
if clientID == "" || clientSecret == "" {
|
||||
switch {
|
||||
case unifiedAppID != "":
|
||||
if commandDryRun(cmd) {
|
||||
// Dry-run must not call the credentials tool; just preview routing.
|
||||
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
|
||||
"channel": channel,
|
||||
"detectedBy": detectedBy,
|
||||
"credentialSource": "unified-app-id (credentials get, skipped in dry-run)",
|
||||
"unifiedAppId": unifiedAppID,
|
||||
"agent": connectAgentOptionsPayload(channel, opts),
|
||||
"cli": connectCliStatus(channel),
|
||||
"connect": buildConnectPlan(channel, "", ""),
|
||||
}))
|
||||
}
|
||||
id, secret, err := devAppFetchCredentials(runner, cmd, unifiedAppID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" || secret == "" {
|
||||
return apperrors.NewInternal("credentials get 未返回 clientId/clientSecret;clientSecret 可能仅建号时返回一次,请改用 --robot-client-id/--robot-client-secret 直接传入")
|
||||
}
|
||||
clientID, clientSecret = id, secret
|
||||
resolvedBy = "unified-app-id:credentials get"
|
||||
case !commandDryRun(cmd) && connectStdinInteractive():
|
||||
// No credentials and a real terminal: guide the user through
|
||||
// provisioning a new robot app or picking an existing one,
|
||||
// instead of failing. Scripts/daemons/pipes and dry-run fall
|
||||
// through to the explicit-flag requirement below.
|
||||
creds, oerr := runConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout())
|
||||
if oerr != nil {
|
||||
return oerr
|
||||
}
|
||||
clientID, clientSecret = creds.clientID, creds.clientSecret
|
||||
resolvedBy = creds.source
|
||||
default:
|
||||
return apperrors.NewValidation("需要 --robot-client-id/--robot-client-secret(用现成机器人凭证),或 --unified-app-id(复用 dev app credentials get 自动取凭证);或在交互终端直接运行 connect 进入建联引导")
|
||||
}
|
||||
}
|
||||
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
|
||||
"channel": channel,
|
||||
"detectedBy": detectedBy,
|
||||
"credentialSource": resolvedBy,
|
||||
"clientId": clientID,
|
||||
"agent": connectAgentOptionsPayload(channel, opts),
|
||||
"cli": connectCliStatus(channel),
|
||||
"connect": buildConnectPlan(channel, clientID, ""),
|
||||
}))
|
||||
}
|
||||
|
||||
// --daemon: detach into a background supervisor that keeps the
|
||||
// connector alive 7x24. We resolve credentials/channel first (above) so
|
||||
// the parent fails fast on bad input before forking, then re-exec.
|
||||
if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode {
|
||||
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID)
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
|
||||
fmt.Fprint(cmd.ErrOrStderr(), connectLocalDebugNotice())
|
||||
return launchConnector(cmd, runner, channel, clientID, clientSecret, opts)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
// Daemon mode (see connect_daemon.go). --daemon detaches the connector into a
|
||||
// self-restarting background supervisor; status/stop are sibling subcommands.
|
||||
cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台常驻(脱离终端、崩溃自拉起),父进程打印 pid/日志路径后退出(Windows 暂不支持)")
|
||||
// Internal re-exec mode flags, hidden from help.
|
||||
cmd.Flags().Bool(daemonSuperviseFlag, false, "internal: run the daemon supervisor (set automatically by --daemon)")
|
||||
cmd.Flags().Bool(daemonWorkerFlag, false, "internal: run a single supervised connector worker (set automatically by the supervisor)")
|
||||
_ = cmd.Flags().MarkHidden(daemonSuperviseFlag)
|
||||
_ = cmd.Flags().MarkHidden(daemonWorkerFlag)
|
||||
cmd.AddCommand(
|
||||
newDevAppRobotConnectStatusCommand(),
|
||||
newDevAppRobotConnectStopCommand(),
|
||||
)
|
||||
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
|
||||
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
|
||||
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
|
||||
// 持久 flag(见 internal/app/flags.go),同名会 shadow 全局 flag。这里是要建联的
|
||||
// 目标机器人凭证,与 OAuth 客户端是两码事,故独立命名避免撞名。
|
||||
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
|
||||
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)")
|
||||
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
|
||||
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
|
||||
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
|
||||
cmd.Flags().Bool("reply-card", true, "用 AI 卡片回复(思考中→完成状态,同官方渠道体验);卡片失败自动回退普通消息;--reply-card=false 关闭")
|
||||
cmd.Flags().String("card-template", "", "AI 卡片模板 ID(开发者后台·本应用·AI 卡片设置里获取;模板按应用授权,强烈建议注册自己应用的模板);env: DWS_CARD_TEMPLATE")
|
||||
cmd.Flags().String("knowledge-dir", "", "答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复;env: DWS_KNOWLEDGE_DIR")
|
||||
cmd.Flags().String("knowledge-source", "", "答疑知识源:wiki:<spaceId> / doc:<docId> 从钉钉知识库拉取并缓存为本地知识(复用 dws doc 能力);裸值当作本地目录;与 --knowledge-dir 并存;env: DWS_KNOWLEDGE_SOURCE")
|
||||
cmd.Flags().String("allowed-users", "", "用户白名单 staffId(逗号分隔),配置后仅名单内用户可触发;env: DWS_ALLOWED_USERS")
|
||||
cmd.Flags().String("allowed-groups", "", "群白名单 openConversationId(逗号分隔),配置后仅名单内群可触发;env: DWS_ALLOWED_GROUPS")
|
||||
cmd.Flags().Int("user-rate-limit", 20, "单用户每分钟消息上限(防刷;每条消息都是一次 LLM 调用),0 关闭;env: DWS_USER_RATE_LIMIT")
|
||||
cmd.Flags().String("owner-user-id", "", "数字分身主人 staffId:开启确认闸后,执行类请求先发卡片给主人审批,同意才执行;env: DWS_OWNER_USER_ID")
|
||||
cmd.Flags().String("approval-card-template", "", "确认闸交互卡片模板 ID(带[同意][拒绝]按钮);与 --owner-user-id 同时配置才开启确认闸;env: DWS_APPROVAL_CARD_TEMPLATE")
|
||||
cmd.Flags().String("role-config", "", "数字员工角色配置 YAML:用角色的主人/人设/知识源填充未显式给出的选项(显式 flag 优先);role 的 client_id 必须与本机器人一致;env: DWS_ROLE_CONFIG")
|
||||
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
|
||||
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// connectAgentOptionsFromCommand reads the agent tuning flags, falling back to
|
||||
// the mirrored env vars so connectors run from scripts/services can be
|
||||
// configured without flags.
|
||||
func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
|
||||
model := devAppStringFlag(cmd, "agent-model")
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL"))
|
||||
}
|
||||
workDir := devAppStringFlag(cmd, "agent-workdir")
|
||||
if workDir == "" {
|
||||
workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR"))
|
||||
}
|
||||
memory, _ := cmd.Flags().GetBool("agent-memory")
|
||||
replyCard, _ := cmd.Flags().GetBool("reply-card")
|
||||
// Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables
|
||||
// cards regardless of the flag default.
|
||||
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_REPLY_CARD"))); v == "0" || v == "false" {
|
||||
replyCard = false
|
||||
}
|
||||
cardTemplate := devAppStringFlag(cmd, "card-template")
|
||||
if cardTemplate == "" {
|
||||
cardTemplate = strings.TrimSpace(os.Getenv("DWS_CARD_TEMPLATE"))
|
||||
}
|
||||
// "public" opts into the openclaw shared template explicitly — best-effort
|
||||
// only, since shared templates may not render for every app.
|
||||
if strings.EqualFold(cardTemplate, "public") {
|
||||
cardTemplate = defaultAICardTemplateID
|
||||
}
|
||||
knowledgeDir := devAppStringFlag(cmd, "knowledge-dir")
|
||||
if knowledgeDir == "" {
|
||||
knowledgeDir = strings.TrimSpace(os.Getenv("DWS_KNOWLEDGE_DIR"))
|
||||
}
|
||||
knowledgeSource := devAppStringFlag(cmd, "knowledge-source")
|
||||
if knowledgeSource == "" {
|
||||
knowledgeSource = strings.TrimSpace(os.Getenv("DWS_KNOWLEDGE_SOURCE"))
|
||||
}
|
||||
users := devAppStringFlag(cmd, "allowed-users")
|
||||
if users == "" {
|
||||
users = os.Getenv("DWS_ALLOWED_USERS")
|
||||
}
|
||||
groups := devAppStringFlag(cmd, "allowed-groups")
|
||||
if groups == "" {
|
||||
groups = os.Getenv("DWS_ALLOWED_GROUPS")
|
||||
}
|
||||
rateLimit, _ := cmd.Flags().GetInt("user-rate-limit")
|
||||
if !cmd.Flags().Changed("user-rate-limit") {
|
||||
if v := strings.TrimSpace(os.Getenv("DWS_USER_RATE_LIMIT")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
rateLimit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
owner := devAppStringFlag(cmd, "owner-user-id")
|
||||
if owner == "" {
|
||||
owner = strings.TrimSpace(os.Getenv("DWS_OWNER_USER_ID"))
|
||||
}
|
||||
approvalTemplate := devAppStringFlag(cmd, "approval-card-template")
|
||||
if approvalTemplate == "" {
|
||||
approvalTemplate = strings.TrimSpace(os.Getenv("DWS_APPROVAL_CARD_TEMPLATE"))
|
||||
}
|
||||
roleConfig := devAppStringFlag(cmd, "role-config")
|
||||
if roleConfig == "" {
|
||||
roleConfig = strings.TrimSpace(os.Getenv("DWS_ROLE_CONFIG"))
|
||||
}
|
||||
auditSheet := devAppStringFlag(cmd, "audit-sheet")
|
||||
if auditSheet == "" {
|
||||
auditSheet = strings.TrimSpace(os.Getenv("DWS_AUDIT_SHEET"))
|
||||
}
|
||||
auditSheetTab := devAppStringFlag(cmd, "audit-sheet-tab")
|
||||
if auditSheetTab == "" {
|
||||
auditSheetTab = strings.TrimSpace(os.Getenv("DWS_AUDIT_SHEET_TAB"))
|
||||
}
|
||||
if auditSheetTab == "" {
|
||||
auditSheetTab = "Sheet1"
|
||||
}
|
||||
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory,
|
||||
ReplyCard: replyCard, CardTemplate: cardTemplate,
|
||||
KnowledgeDir: knowledgeDir,
|
||||
KnowledgeSource: knowledgeSource,
|
||||
AllowedUsers: splitCommaList(users), AllowedGroups: splitCommaList(groups),
|
||||
UserRateLimit: rateLimit,
|
||||
OwnerUserID: owner,
|
||||
ApprovalCardTemplate: approvalTemplate,
|
||||
RoleConfigPath: roleConfig,
|
||||
AuditSheetNode: auditSheet,
|
||||
AuditSheetTab: auditSheetTab}
|
||||
}
|
||||
|
||||
// connectAgentOptionsPayload renders the effective agent tuning for the
|
||||
// dry-run preview, including whether session memory actually applies to the
|
||||
// chosen channel (Codex uses app-server threads, opencode uses opencode serve
|
||||
// sessions, CLI session channels use --session-id/--resume, and gemini stays
|
||||
// stateless today).
|
||||
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
|
||||
spec, ok := agentSpecs[channel]
|
||||
memory := "unsupported"
|
||||
if channel == "codex" {
|
||||
if opts.Memory {
|
||||
memory = "per-conversation-app-server"
|
||||
} else {
|
||||
memory = "disabled"
|
||||
}
|
||||
} else if channel == "opencode" {
|
||||
if opts.Memory {
|
||||
memory = "per-conversation-opencode-server"
|
||||
} else {
|
||||
memory = "disabled"
|
||||
}
|
||||
} else if channel == "qoder" || channel == "qoderwork" {
|
||||
if opts.Memory {
|
||||
memory = "per-conversation-qoder-stream"
|
||||
} else {
|
||||
memory = "disabled"
|
||||
}
|
||||
} else if ok && spec.ccSessions {
|
||||
if opts.Memory {
|
||||
memory = "per-conversation"
|
||||
} else {
|
||||
memory = "disabled"
|
||||
}
|
||||
}
|
||||
model := opts.Model
|
||||
if model == "" {
|
||||
model = "(channel default)"
|
||||
}
|
||||
workDir := opts.WorkDir
|
||||
if workDir == "" {
|
||||
workDir = "(clean temp dir)"
|
||||
}
|
||||
replyStyle := "text/markdown"
|
||||
if opts.ReplyCard {
|
||||
if opts.CardTemplate != "" {
|
||||
replyStyle = "ai-card"
|
||||
} else {
|
||||
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
|
||||
}
|
||||
}
|
||||
return map[string]any{"model": model, "workdir": workDir, "memory": memory, "replyStyle": replyStyle}
|
||||
}
|
||||
|
||||
// devAppFetchCredentials reuses dev app 的 get_dev_app_credentials tool to
|
||||
// resolve a unified app's clientId/clientSecret, so `robot connect
|
||||
// --unified-app-id` need not have the caller paste raw credentials. Note the
|
||||
// open platform only returns clientSecret once (at provisioning); if the tool
|
||||
// omits it the caller is told to fall back to explicit flags.
|
||||
//
|
||||
// TODO(verify): the clientId/clientSecret (and appKey/appSecret fallback) field
|
||||
// names below are NOT yet confirmed against the real get_dev_app_credentials
|
||||
// response — no fixture exists in-repo. Verify against the pre-prod gateway and
|
||||
// pin the exact field names before relying on --unified-app-id auto-fetch. The
|
||||
// path degrades safely today: an unrecognised shape yields empty strings and the
|
||||
// caller is told to use --robot-client-id/--robot-client-secret instead.
|
||||
func devAppFetchCredentials(runner executor.Runner, cmd *cobra.Command, unifiedAppID string) (clientID, clientSecret string, err error) {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
devAppProduct,
|
||||
devAppCredentialsGetTool,
|
||||
map[string]any{"unifiedAppId": unifiedAppID},
|
||||
)
|
||||
res, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
payload := devAppConnectUnwrap(res.Response)
|
||||
clientID = devAppConnectFirst(payload, "clientId", "appKey", "clientID")
|
||||
clientSecret = devAppConnectFirst(payload, "clientSecret", "appSecret")
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// devAppConnectUnwrap descends the executor/MCP envelope
|
||||
// (Response{"content":{...,"result":{...}}}) to the innermost object, tolerating
|
||||
// either wrapper being absent.
|
||||
func devAppConnectUnwrap(resp map[string]any) map[string]any {
|
||||
cur := resp
|
||||
if cur == nil {
|
||||
return nil
|
||||
}
|
||||
if inner, ok := cur["content"].(map[string]any); ok {
|
||||
cur = inner
|
||||
}
|
||||
if inner, ok := cur["result"].(map[string]any); ok {
|
||||
cur = inner
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// devAppConnectFirst returns the first non-empty string value among the given
|
||||
// keys, tolerating nil maps and non-string scalars.
|
||||
func devAppConnectFirst(resp map[string]any, keys ...string) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if v, ok := resp[key].(string); ok {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clearChannelEnv clears every env var that participates in channel detection
|
||||
// (empty == treated as unset), so the test host's own QODER_CLI etc. cannot
|
||||
// leak into a case. t.Setenv restores them when the case ends.
|
||||
func clearChannelEnv(t *testing.T) {
|
||||
for _, k := range []string{
|
||||
"DWS_AGENT_CHANNEL", "DINGTALK_AGENT", "OPENCLAW", "OPENCLAW_GATEWAY",
|
||||
"HERMES_AGENT", "HERMES", "QODER_CLI", "QODERCLI_INTEGRATION_MODE",
|
||||
"DWS_CONNECT_CMD", "DWS_AGENT_CMD",
|
||||
"WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnectChannel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag string
|
||||
env map[string]string
|
||||
wantChannel string
|
||||
wantDetectedBy string
|
||||
}{
|
||||
{"explicit flag wins", "openclaw", map[string]string{"DWS_AGENT_CHANNEL": "qoder", "QODER_CLI": "1"}, "openclaw", "flag:--channel"},
|
||||
{"env overrides signal", "auto", map[string]string{"DWS_AGENT_CHANNEL": "qoderwork", "QODER_CLI": "1"}, "qoderwork", "env:DWS_AGENT_CHANNEL"},
|
||||
{"signal openclaw(DINGTALK_AGENT)", "auto", map[string]string{"DINGTALK_AGENT": "DING_DWS_CLAW"}, "openclaw", "signal:DINGTALK_AGENT"},
|
||||
{"signal openclaw(OPENCLAW)", "", map[string]string{"OPENCLAW": "1"}, "openclaw", "signal:OPENCLAW"},
|
||||
{"signal qoder family", "", map[string]string{"QODER_CLI": "1"}, "qoder", "signal:QODER_CLI"},
|
||||
{"signal qoderwork(INTEGRATION_MODE)", "auto", map[string]string{"QODERCLI_INTEGRATION_MODE": "qoder_work"}, "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"},
|
||||
{"qoderwork precedes qoder/claudecode", "auto", map[string]string{"QODERCLI_INTEGRATION_MODE": "qoder_work", "QODER_CLI": "1", "CLAUDECODE": "1"}, "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"},
|
||||
{"signal hermes", "auto", map[string]string{"HERMES_AGENT": "1"}, "hermes", "signal:HERMES"},
|
||||
{"signal workbuddy(WORKBUDDY_CONFIG_DIR)", "auto", map[string]string{"WORKBUDDY_CONFIG_DIR": "/Users/x/.workbuddy"}, "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"},
|
||||
{"signal workbuddy(WORKBUDDY_APP_NAME)", "auto", map[string]string{"WORKBUDDY_APP_NAME": "WorkBuddy"}, "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"},
|
||||
{"signal claudecode", "auto", map[string]string{"CLAUDECODE": "1"}, "claudecode", "signal:CLAUDECODE"},
|
||||
{"qoder fork precedes claudecode", "auto", map[string]string{"QODER_CLI": "1", "CLAUDECODE": "1"}, "qoder", "signal:QODER_CLI"},
|
||||
{"custom via DWS_AGENT_CMD", "auto", map[string]string{"DWS_AGENT_CMD": "lobster -p"}, "custom", "env:DWS_AGENT_CMD"},
|
||||
{"explicit channel beats DWS_AGENT_CMD", "claudecode", map[string]string{"DWS_AGENT_CMD": "lobster -p"}, "claudecode", "flag:--channel"},
|
||||
{"undetected", "auto", nil, "", "undetected"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
ch, by := resolveConnectChannel(tc.flag)
|
||||
if ch != tc.wantChannel || by != tc.wantDetectedBy {
|
||||
t.Fatalf("resolveConnectChannel(%q) = (%q,%q), want (%q,%q)", tc.flag, ch, by, tc.wantChannel, tc.wantDetectedBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectChannelsKnown(t *testing.T) {
|
||||
for _, ch := range []string{"openclaw", "qoder", "qoderwork", "hermes", "workbuddy", "claudecode", "codebuddy", "custom"} {
|
||||
if _, ok := connectChannels[ch]; !ok {
|
||||
t.Errorf("channel %q should be in connectChannels", ch)
|
||||
}
|
||||
}
|
||||
if _, ok := connectChannels["weird"]; ok {
|
||||
t.Error("unknown channel should not be in connectChannels")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomChannel covers the self-built / unsupported-agent escape hatch
|
||||
// (issue #37): custom is a stream-bridge channel whose argv comes entirely from
|
||||
// DWS_AGENT_CMD (set by --agent-cmd); without a command it errors clearly.
|
||||
func TestCustomChannel(t *testing.T) {
|
||||
if !isStreamBridgeChannel("custom") {
|
||||
t.Fatal("custom should be a stream-bridge channel")
|
||||
}
|
||||
if got, _ := buildConnectPlan("custom", "cid", "rc")["method"].(string); got != "stream-bridge-custom" {
|
||||
t.Errorf("buildConnectPlan(custom).method = %q, want stream-bridge-custom", got)
|
||||
}
|
||||
t.Run("missing command errors", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
if _, _, err := resolveExecAgent("custom"); err == nil {
|
||||
t.Error("custom without DWS_AGENT_CMD should error")
|
||||
}
|
||||
})
|
||||
t.Run("command wins", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_AGENT_CMD", "lobster -p")
|
||||
argv, _, err := resolveExecAgent("custom")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !equalStringSlice(argv, []string{"lobster", "-p"}) {
|
||||
t.Errorf("argv = %v, want [lobster -p]", argv)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildConnectPlanMethod(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"openclaw": "openclaw-connector",
|
||||
"qoder": "stream-bridge-qoder-stream",
|
||||
"qoderwork": "stream-bridge-qoder-stream",
|
||||
"hermes": "official-channel",
|
||||
"workbuddy": "stream-bridge",
|
||||
"claudecode": "stream-bridge",
|
||||
"codebuddy": "stream-bridge",
|
||||
"codex": "stream-bridge-codex-app-server",
|
||||
"weird": "unknown",
|
||||
}
|
||||
for ch, m := range want {
|
||||
got, _ := buildConnectPlan(ch, "cid", "rc")["method"].(string)
|
||||
if got != m {
|
||||
t.Errorf("buildConnectPlan(%q).method = %q, want %q", ch, got, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectExternalCommand(t *testing.T) {
|
||||
t.Run("DWS_CONNECT_CMD override (applies to all channels)", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_CMD", "my-bridge --flag x")
|
||||
want := []string{"my-bridge", "--flag", "x"}
|
||||
for _, ch := range []string{"qoder", "workbuddy", "openclaw", "hermes"} {
|
||||
if got := connectExternalCommand(ch); !equalStringSlice(got, want) {
|
||||
t.Fatalf("channel %q: got %v, want %v", ch, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("stream-bridge channels go Go-native, no external command", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
for _, ch := range []string{"qoder", "qoderwork", "claudecode", "codebuddy", "workbuddy"} {
|
||||
if got := connectExternalCommand(ch); got != nil {
|
||||
t.Errorf("stream-bridge channel %q should return nil (Go-native), got %v", ch, got)
|
||||
}
|
||||
if !isStreamBridgeChannel(ch) {
|
||||
t.Errorf("channel %q should be recognised as stream-bridge", ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("openclaw uses external gateway", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
if got := connectExternalCommand("openclaw"); len(got) == 0 || got[0] != "openclaw" {
|
||||
t.Errorf("openclaw default should be openclaw ..., got %v", got)
|
||||
}
|
||||
if isStreamBridgeChannel("openclaw") {
|
||||
t.Error("openclaw should not be a stream-bridge channel")
|
||||
}
|
||||
})
|
||||
t.Run("hermes has no built-in command", func(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
if got := connectExternalCommand("hermes"); got != nil {
|
||||
t.Errorf("hermes with no built-in command should return nil, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwarderForChannel(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
stub := t.TempDir()
|
||||
writeShellExecutable(t, stub, "codex", "exit 0\n")
|
||||
t.Setenv("PATH", stub)
|
||||
// DWS_AGENT_CMD covers ordinary stream-bridge channels so the test does not
|
||||
// depend on those binaries being installed. Codex ignores it and stays on
|
||||
// app-server.
|
||||
t.Setenv("DWS_AGENT_CMD", "fake-cli --flag")
|
||||
for ch := range agentSpecs {
|
||||
fwd, err := forwarderForChannel(ch, "", connectAgentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(%q) err = %v", ch, err)
|
||||
}
|
||||
if ch == "codex" {
|
||||
if _, ok := fwd.(*codexAppServerForwarder); !ok {
|
||||
t.Errorf("codex should ignore DWS_AGENT_CMD and yield app-server, got %T", fwd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ef, ok := fwd.(*execForwarder)
|
||||
if !ok {
|
||||
t.Errorf("channel %q should yield *execForwarder, got %T", ch, fwd)
|
||||
continue
|
||||
}
|
||||
if !equalStringSlice(ef.argv, []string{"fake-cli", "--flag"}) {
|
||||
t.Errorf("channel %q DWS_AGENT_CMD not applied: argv = %v", ch, ef.argv)
|
||||
}
|
||||
}
|
||||
// Non-agent channel has no forwarder.
|
||||
if _, err := forwarderForChannel("openclaw", "", connectAgentOptions{}); err == nil {
|
||||
t.Error("openclaw should not yield a forwarder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSpecsCoverMainstreamAgents(t *testing.T) {
|
||||
// The mainstream agents the bot must support, all present as channels.
|
||||
for _, ch := range []string{
|
||||
"claudecode", "codex", "gemini", "opencode",
|
||||
"qoder", "qoderwork", "codebuddy", "workbuddy",
|
||||
} {
|
||||
spec, ok := agentSpecs[ch]
|
||||
if !ok {
|
||||
t.Errorf("agentSpecs missing channel %q", ch)
|
||||
continue
|
||||
}
|
||||
if len(spec.bins) == 0 {
|
||||
t.Errorf("channel %q has no bins", ch)
|
||||
}
|
||||
if spec.hint == "" {
|
||||
t.Errorf("channel %q has no install hint", ch)
|
||||
}
|
||||
if _, ok := connectChannels[ch]; !ok {
|
||||
t.Errorf("channel %q not in connectChannels", ch)
|
||||
}
|
||||
if !isStreamBridgeChannel(ch) {
|
||||
t.Errorf("channel %q should be stream-bridge", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgDedup(t *testing.T) {
|
||||
d := newMsgDedup(3)
|
||||
if !d.first("a") {
|
||||
t.Fatal("first a should be new")
|
||||
}
|
||||
if d.first("a") {
|
||||
t.Fatal("second a should be a duplicate")
|
||||
}
|
||||
if !d.first("b") || !d.first("c") {
|
||||
t.Fatal("b and c should be new")
|
||||
}
|
||||
// seen now holds {a,b,c} == limit; the next new id triggers a reset.
|
||||
if !d.first("x") {
|
||||
t.Fatal("x should be new (and trigger reset at limit)")
|
||||
}
|
||||
// After the reset, a was evicted, so it is treated as new again.
|
||||
if !d.first("a") {
|
||||
t.Fatal("a should be new again after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateBinary(t *testing.T) {
|
||||
// On PATH: sh exists on every unix test host.
|
||||
if _, ok := locateBinary([]string{"sh"}, nil); !ok {
|
||||
t.Error("sh should be found on PATH")
|
||||
}
|
||||
// Miss: nonexistent name + non-matching glob.
|
||||
if _, ok := locateBinary([]string{"definitely-not-a-real-binary-xyz"}, []string{"/no/such/path/*/nope"}); ok {
|
||||
t.Error("should not find a nonexistent binary")
|
||||
}
|
||||
// Glob hit: a real file matched by a wildcard dir.
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "archX")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(sub, "mycli")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := locateBinary([]string{"no-such"}, []string{filepath.Join(dir, "*", "mycli")})
|
||||
if !ok || got != bin {
|
||||
t.Errorf("glob locate = (%q,%v), want (%q,true)", got, ok, bin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecAgentOverride(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_AGENT_CMD", "my-agent --foo bar")
|
||||
argv, env, err := resolveExecAgent("qoder")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !equalStringSlice(argv, []string{"my-agent", "--foo", "bar"}) {
|
||||
t.Errorf("override argv = %v", argv)
|
||||
}
|
||||
if env != nil {
|
||||
t.Errorf("override env should be nil, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user