Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96986dfbff | ||
|
|
27c3449036 | ||
|
|
d0787ce8ee | ||
|
|
32c1d772de | ||
|
|
39b3003e2f | ||
|
|
43798de088 | ||
|
|
c4d8987f6c | ||
|
|
657d2c25e3 | ||
|
|
9f7107b6bb | ||
|
|
bfd48b6a71 | ||
|
|
390b6115bf | ||
|
|
fc9acb9007 | ||
|
|
aa6abc5ed6 | ||
|
|
ea6fd16d11 | ||
|
|
5ac5fcbf16 | ||
|
|
4a717bd92f |
@@ -61,6 +61,46 @@ jobs:
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,10 +18,7 @@ jobs:
|
||||
release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
runs-on: ubuntu-latest
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -40,17 +37,50 @@ jobs:
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
set -euo pipefail
|
||||
RCS_VERSION="0.29.0"
|
||||
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
|
||||
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"
|
||||
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
|
||||
| sha256sum --check --strict -
|
||||
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: Prepare Apple Developer ID certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
|
||||
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
|
||||
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 077
|
||||
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
|
||||
password_path="$RUNNER_TEMP/dws-developer-id-password"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
|
||||
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
|
||||
|
||||
# Fail before packaging if the secret is corrupt or the password is wrong.
|
||||
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
|
||||
# -legacy to validate those containers even though rcodesign can read them.
|
||||
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
|
||||
|
||||
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
|
||||
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -63,12 +93,88 @@ jobs:
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
- name: Remove Apple Developer ID certificate
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
# GoReleaser uploads the original archives to a Draft before
|
||||
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
|
||||
# file, verify the Draft digests, and keep it private for Apple validation.
|
||||
- name: Upload finalized signed assets to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DWS_PUBLISH_RELEASE: "false"
|
||||
run: ./scripts/release/finalize-github-release.sh
|
||||
|
||||
- name: Preserve finalized distribution files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
verify-darwin-signatures:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Download finalized Darwin assets from Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-darwin-amd64.tar.gz' \
|
||||
--pattern 'dws-darwin-arm64.tar.gz' \
|
||||
--clobber
|
||||
|
||||
- name: Verify finalized Darwin signatures with Apple codesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/dws-darwin-${arch}.tar.gz"
|
||||
stage="$RUNNER_TEMP/verify-darwin-${arch}"
|
||||
mkdir -p "$stage"
|
||||
tar -xzf "$archive" -C "$stage"
|
||||
test -f "$stage/dws"
|
||||
codesign --verify --strict --verbose=4 "$stage/dws"
|
||||
codesign -dvvv "$stage/dws"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# Mirroring every release asset to Gitee can be slow; 30 minutes previously
|
||||
# cut the fallback upload off mid-run.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish verified Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
|
||||
@@ -42,5 +42,8 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ release:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -8,7 +8,40 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** — aligns personal event streaming with the contract an orchestrator can drive without guessing: a fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>` (block on it, don't `sleep`); a final `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)` line with exit code 0 on controlled exit and non-zero (no `exited` line) on failure; stdin-EOF as a graceful shutdown signal, armed only for a parent-controlled pipe stdin on an unbounded run (an interactive TTY and `< /dev/null` never trigger it), with a self-explaining diagnostic when it fires; and ownership-based subscription cleanup — a subscription this run created is unsubscribed on any clean exit while a `--subscribe-id`-reused one is left intact (`--ephemeral` still forces cleanup), so `kill -9` is the only way to leak a server-side subscription. Skill docs (mono + `dingtalk-event`) document the contract; design notes in `docs/event-subprocess-contract.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws schema` for registered local commands** — `dws schema "event consume"` (or `event.consume`) now returns a machine-readable input schema synthesized from the command's cobra flags, in the same flat shape helper subtrees emit: `{description, path, source, parameters{<flag>:{type, required, description, default?}}}` plus an `arguments` array for positional inputs, with `source: "cobra"` distinguishing flag-synthesized schema from MCP-fetched (`mcp:<server>`). Intermediate nodes (`dws schema event`) list their subcommands. The mechanism is a reusable registry (`cobraSchemaRoots`); `event` is the first consumer and more command trees can opt in without further wiring. Inherited global flags and hidden internal flags are excluded so the schema describes just that command.
|
||||
- **Safe macOS Keychain → file-DEK migration** — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
|
||||
@@ -258,6 +258,16 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -255,6 +255,16 @@ dws --profile <名称|corpId> contact user search --query "..." # 单次对指
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -1,193 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -1,8 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -1,54 +0,0 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -1,143 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -0,0 +1,115 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -88,8 +88,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/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/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=
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
Long: "查看、导出和校验本地操作审计日志。",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuditTailCommand(),
|
||||
newAuditExportCommand(),
|
||||
newAuditVerifyCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditTailCommand() *cobra.Command {
|
||||
var n int
|
||||
cmd := &cobra.Command{
|
||||
Use: "tail",
|
||||
Short: "查看最近的审计记录",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if n < 1 {
|
||||
return fmt.Errorf("--lines 必须为正整数,收到 %d", n)
|
||||
}
|
||||
dir := auditDir()
|
||||
file, err := audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计记录: %w", err)
|
||||
}
|
||||
lines, err := tailFile(file, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditExportCommand() *cobra.Command {
|
||||
var since, until, format string
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出审计日志",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := auditDir()
|
||||
|
||||
sinceDate := strings.ReplaceAll(since, "-", "")
|
||||
untilDate := strings.ReplaceAll(until, "-", "")
|
||||
|
||||
files, err := audit.AuditFilesInRange(dir, sinceDate, untilDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找审计文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("指定范围内无审计文件")
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "jsonl":
|
||||
return exportJSONL(files)
|
||||
case "csv":
|
||||
return exportCSV(files)
|
||||
default:
|
||||
return fmt.Errorf("不支持的格式: %s(可选 jsonl, csv)", format)
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditVerifyCommand() *cobra.Command {
|
||||
var file string
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "校验审计日志哈希链完整性",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := file
|
||||
if target == "" {
|
||||
dir := auditDir()
|
||||
var err error
|
||||
target, err = audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计文件: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
valid, brokenAt, err := audit.VerifyFile(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验失败: %w", err)
|
||||
}
|
||||
if valid {
|
||||
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
|
||||
} else {
|
||||
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func auditDir() string {
|
||||
if dir := os.Getenv(audit.EnvAuditDir); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(defaultConfigDir(), "audit")
|
||||
}
|
||||
|
||||
func tailFile(path string, n int) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func exportJSONL(files []string) error {
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportCSV(files []string) error {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
|
||||
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
|
||||
if err := w.Write(header); err != nil {
|
||||
return fmt.Errorf("写入 CSV 表头失败: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var evt audit.Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("解析审计记录失败 %s:%d: %w", file, lineNum, err)
|
||||
}
|
||||
row := []string{
|
||||
evt.Timestamp.Format(time.RFC3339),
|
||||
evt.ExecutionID,
|
||||
evt.Actor.UserID,
|
||||
evt.Actor.CorpID,
|
||||
evt.Product,
|
||||
evt.Command,
|
||||
evt.Result,
|
||||
strconv.FormatInt(evt.DurationMs, 10),
|
||||
evt.ErrCategory,
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("写入 CSV 记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuditTailRejectsNonPositiveLines(t *testing.T) {
|
||||
for _, n := range []string{"0", "-1"} {
|
||||
cmd := newAuditTailCommand()
|
||||
cmd.SetArgs([]string{"--lines", n})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("--lines %s: expected error, got nil", n)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "正整数") {
|
||||
t.Fatalf("--lines %s: unexpected error: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailFileReturnsLastN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := tailFile(path, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 || lines[0] != "d" || lines[1] != "e" {
|
||||
t.Fatalf("got %v, want [d e]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSVWritesHeaderAndRows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
rec := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1","corp_id":"c1"},"product":"calendar","command":"event_list","result":"success","duration_ms":12,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(rec+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatalf("exportCSV error: %v", exportErr)
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Read(buf)
|
||||
out := string(buf[:n])
|
||||
if !strings.Contains(out, "timestamp,execution_id") {
|
||||
t.Fatalf("missing CSV header, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "e1") || !strings.Contains(out, "event_list") {
|
||||
t.Fatalf("missing CSV row data, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportCSVFailsOnMalformedJSON guards the reviewer's V9 finding: a corrupt
|
||||
// JSONL line must surface an error with file/line evidence instead of being
|
||||
// silently skipped while the command exits 0.
|
||||
func TestExportCSVFailsOnMalformedJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
good := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1"},"product":"calendar","command":"event_list","result":"success","duration_ms":1,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(good+"\nnot-json\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
// Drain the pipe so the writer never blocks.
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = r.Read(buf)
|
||||
|
||||
if exportErr == nil {
|
||||
t.Fatal("expected error on malformed JSONL, got nil")
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), "解析审计记录失败") {
|
||||
t.Fatalf("error missing parse context: %v", exportErr)
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), ":2") {
|
||||
t.Fatalf("error missing line evidence: %v", exportErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"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/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
auditSinkOnce sync.Once
|
||||
auditCloseOnce sync.Once
|
||||
sharedAuditSink audit.Sink
|
||||
|
||||
auditIDMu sync.Mutex
|
||||
cachedActor audit.Actor
|
||||
cachedAgentID string
|
||||
cachedProfile string
|
||||
identityLoaded bool
|
||||
|
||||
// loadTokenForProfile is the profile-scoped token loader. It is a package
|
||||
// variable so profile-switch Actor attribution can be tested deterministically
|
||||
// without touching the OS keychain.
|
||||
loadTokenForProfile = auth.LoadTokenDataForProfile
|
||||
)
|
||||
|
||||
// setupAuditSink builds the process-wide audit sink once and caches it so the
|
||||
// runner and the shutdown hook share a single writer/forwarder instance.
|
||||
func setupAuditSink() audit.Sink {
|
||||
auditSinkOnce.Do(func() {
|
||||
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
|
||||
if err != nil {
|
||||
auditReport("initialization failed, audit disabled for this session: %v", err)
|
||||
sharedAuditSink = audit.NopSink{}
|
||||
return
|
||||
}
|
||||
sharedAuditSink = sink
|
||||
})
|
||||
return sharedAuditSink
|
||||
}
|
||||
|
||||
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
|
||||
// It is invoked from an unconditional defer in Execute so the drain happens for
|
||||
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
|
||||
// returns an error). The sync.Once makes repeated calls safe.
|
||||
func CloseAuditSink() {
|
||||
auditCloseOnce.Do(func() {
|
||||
if sharedAuditSink == nil {
|
||||
return
|
||||
}
|
||||
if err := sharedAuditSink.Close(); err != nil {
|
||||
auditReport("close failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
|
||||
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
|
||||
// so init/write/forward failures are observable instead of silently swallowed.
|
||||
func auditReport(format string, args ...any) {
|
||||
msg := "audit: " + fmt.Sprintf(format, args...)
|
||||
if l := FileLoggerInstance(); l != nil {
|
||||
l.Warn(msg)
|
||||
}
|
||||
if audit.DebugEnabled() {
|
||||
fmt.Fprintln(os.Stderr, "[dws] "+msg)
|
||||
}
|
||||
}
|
||||
|
||||
// auditIdentity resolves the Actor for the active runtime profile. The result
|
||||
// is cached per-profile so a profile switch within a long-running process (e.g.
|
||||
// serve mode) re-resolves rather than reusing a stale identity.
|
||||
func auditIdentity() (audit.Actor, string) {
|
||||
profile := auth.RuntimeProfile()
|
||||
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
if identityLoaded && profile == cachedProfile {
|
||||
return cachedActor, cachedAgentID
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
var actor audit.Actor
|
||||
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
|
||||
actor = audit.Actor{
|
||||
UserID: td.UserID,
|
||||
Name: td.UserName,
|
||||
CorpID: td.CorpID,
|
||||
CorpName: td.CorpName,
|
||||
}
|
||||
} else if err != nil {
|
||||
auditReport("resolve actor for profile %q failed: %v", profile, err)
|
||||
}
|
||||
|
||||
agentID := ""
|
||||
if id := auth.Load(configDir); id != nil {
|
||||
agentID = id.AgentID
|
||||
}
|
||||
|
||||
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
|
||||
return actor, agentID
|
||||
}
|
||||
|
||||
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := sink.(audit.NopSink); ok {
|
||||
return
|
||||
}
|
||||
|
||||
actor, agentID := auditIdentity()
|
||||
|
||||
result := "success"
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
result = "error"
|
||||
errCat, errReason = classifyAuditError(retErr)
|
||||
}
|
||||
|
||||
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
|
||||
|
||||
evt := &audit.Event{
|
||||
Timestamp: invokeStart,
|
||||
ExecutionID: execID,
|
||||
AgentID: agentID,
|
||||
Actor: actor,
|
||||
Product: invocation.CanonicalProduct,
|
||||
Command: invocation.Tool,
|
||||
Endpoint: transport.RedactURL(endpoint),
|
||||
ParamsSummary: paramsSummary,
|
||||
Result: result,
|
||||
ErrCategory: errCat,
|
||||
ErrReason: errReason,
|
||||
DurationMs: time.Since(invokeStart).Milliseconds(),
|
||||
CLIVersion: cliVersion,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
auditReport("emit event failed (exec %s): %v", execID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAuditError(err error) (category, reason string) {
|
||||
if err == nil {
|
||||
return "", ""
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
return string(typed.Category), typed.Reason
|
||||
}
|
||||
return "unknown", err.Error()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TestAuditIdentityReresolvesOnProfileSwitch guards the reviewer's finding that a
|
||||
// long-running process (e.g. serve mode) must attribute events to the ACTIVE
|
||||
// runtime profile rather than reusing a process-global first Actor. It also
|
||||
// asserts the per-profile cache avoids redundant token loads within one profile.
|
||||
func TestAuditIdentityReresolvesOnProfileSwitch(t *testing.T) {
|
||||
prevLoader := loadTokenForProfile
|
||||
prevProfile := auth.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
loadTokenForProfile = prevLoader
|
||||
auth.SetRuntimeProfile(prevProfile)
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
resetAuditIdentityCache()
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := map[string]int{}
|
||||
loadTokenForProfile = func(_ /*configDir*/, profile string) (*auth.TokenData, error) {
|
||||
mu.Lock()
|
||||
calls[profile]++
|
||||
mu.Unlock()
|
||||
switch profile {
|
||||
case "orgA":
|
||||
return &auth.TokenData{UserID: "ua", UserName: "Alice", CorpID: "ca", CorpName: "CorpA"}, nil
|
||||
case "orgB":
|
||||
return &auth.TokenData{UserID: "ub", UserName: "Bob", CorpID: "cb", CorpName: "CorpB"}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgA")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" || actor.CorpName != "CorpA" {
|
||||
t.Fatalf("orgA: got %+v, want Alice/CorpA", actor)
|
||||
}
|
||||
// Second call under the same profile must hit the cache (no extra load).
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" {
|
||||
t.Fatalf("orgA cached: got %+v", actor)
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgB")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ub" || actor.CorpName != "CorpB" {
|
||||
t.Fatalf("orgB: got %+v, want Bob/CorpB (stale Actor reused?)", actor)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls["orgA"] != 1 {
|
||||
t.Fatalf("orgA loaded %d times, want 1 (cache miss?)", calls["orgA"])
|
||||
}
|
||||
if calls["orgB"] != 1 {
|
||||
t.Fatalf("orgB loaded %d times, want 1", calls["orgB"])
|
||||
}
|
||||
}
|
||||
|
||||
func resetAuditIdentityCache() {
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
cachedActor = audit.Actor{}
|
||||
cachedAgentID = ""
|
||||
cachedProfile = ""
|
||||
identityLoaded = false
|
||||
}
|
||||
|
||||
// TestCloseAuditSinkDrainsOnErrorPath guards the reviewer's V5 finding: when a
|
||||
// command's RunE returns an error, Cobra skips PersistentPostRunE, so the audit
|
||||
// drain must instead happen through the unconditional defer in Execute that calls
|
||||
// CloseAuditSink. This test wires a real forwarder-backed sink into the shared
|
||||
// slot and asserts CloseAuditSink flushes the queued forward exactly as the
|
||||
// error-path defer would, and that a second call is a harmless no-op.
|
||||
func TestCloseAuditSinkDrainsOnErrorPath(t *testing.T) {
|
||||
var delivered int64
|
||||
var releaseOnce sync.Once
|
||||
release := make(chan struct{})
|
||||
releaseFn := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release // hold the request until the drain awaits it
|
||||
atomic.AddInt64(&delivered, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer releaseFn() // LIFO: unblock any in-flight handler before srv.Close()
|
||||
|
||||
writer, err := audit.NewDateRotatingWriter(t.TempDir(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fwd := audit.NewHTTPForwarder(srv.URL, "", audit.RedactNone, nil)
|
||||
sink := audit.NewFileSink(writer, audit.NewChain(""), fwd)
|
||||
|
||||
prevSink := sharedAuditSink
|
||||
t.Cleanup(func() {
|
||||
sharedAuditSink = prevSink
|
||||
auditCloseOnce = sync.Once{}
|
||||
})
|
||||
sharedAuditSink = sink
|
||||
auditCloseOnce = sync.Once{}
|
||||
|
||||
if err := sink.Emit(&audit.Event{Timestamp: time.Unix(0, 0), Product: "calendar", Command: "event_list", Result: "error"}); err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&delivered); got != 0 {
|
||||
t.Fatalf("forward delivered before drain: %d", got)
|
||||
}
|
||||
|
||||
// Let the held request complete, then drain via the same entry point the
|
||||
// error-path defer uses. CloseAuditSink blocks until the forward goroutine
|
||||
// observes the HTTP response, so the counter is settled when it returns.
|
||||
releaseFn()
|
||||
CloseAuditSink()
|
||||
|
||||
if got := atomic.LoadInt64(&delivered); got != 1 {
|
||||
t.Fatalf("forward not drained on error path: delivered=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Idempotent: the success-path PersistentPostRunE and the defer both call it.
|
||||
CloseAuditSink()
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -283,6 +284,7 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -521,6 +523,65 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate-keychain",
|
||||
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
|
||||
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
|
||||
|
||||
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
|
||||
先用 --dry-run 预检,确认后加 --yes 执行。`,
|
||||
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
if strings.TrimSpace(target) != "file-dek" {
|
||||
return apperrors.NewValidation("--to 当前仅支持 file-dek")
|
||||
}
|
||||
if os.Getenv(keychain.DisableKeychainEnv) != "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
|
||||
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
|
||||
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
|
||||
if !dryRun && !yes {
|
||||
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
|
||||
}
|
||||
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Target string `json:"target"`
|
||||
Entries int `json:"entries"`
|
||||
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
@@ -596,7 +657,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
@@ -1229,11 +1290,18 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsCiphertextKeyMismatch(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "ciphertext_key_mismatch",
|
||||
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
|
||||
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
|
||||
}
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -232,6 +233,22 @@ func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "dws auth reset") {
|
||||
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
|
||||
if diagnostic == nil {
|
||||
t.Fatal("authStatusDiagnosticFromError() = nil")
|
||||
}
|
||||
if diagnostic.Reason != "ciphertext_key_mismatch" {
|
||||
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
|
||||
}
|
||||
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
@@ -335,6 +352,91 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
|
||||
calls := 0
|
||||
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
|
||||
calls++
|
||||
if calls == 1 && !dryRun {
|
||||
t.Fatal("first migration call should be dry-run")
|
||||
}
|
||||
if calls == 2 && dryRun {
|
||||
t.Fatal("second migration call should execute")
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
newRoot := func() (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
return root, &out
|
||||
}
|
||||
|
||||
root, out := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("dry-run output = %q", out.String())
|
||||
}
|
||||
|
||||
root, out = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("migration output = %q", out.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("migration calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
|
||||
t.Fatal("migration backend should not be called")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
root := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
|
||||
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
|
||||
@@ -101,7 +101,9 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
raw 仅 SDK 原始 payload,无外层封装
|
||||
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
|
||||
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接。
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
|
||||
通常不需要设置。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -214,6 +216,11 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
DryRun: dryRun,
|
||||
SpawnExtraArgs: streamOpts.spawnArgs(),
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style,
|
||||
// unbounded run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(maxEvents, duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
|
||||
// Step 5: validation (flag-only rules).
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
@@ -260,7 +267,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"不 fork daemon,当前进程跑 bus (systemd/k8s/launchd 友好)")
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
"个人事件订阅 ID;传入后复用已有订阅")
|
||||
f.StringVar(&personalOpts.Rule, "rule", "",
|
||||
@@ -274,7 +281,10 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
f.DurationVar(&personalOpts.TTL, "ttl", 0,
|
||||
"个人订阅 TTL (Go duration,如 24h;0 表示不过期)")
|
||||
f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false,
|
||||
"consume 退出时自动取消个人订阅")
|
||||
"强制退出时取消个人订阅。默认已按归属清理:本次新建的订阅退出即取消,"+
|
||||
"用 --subscribe-id 复用的订阅保留。优雅停可用 SIGTERM、关闭 stdin,"+
|
||||
"或从外部 dws event stop <subscribe_id>(会一并退订);"+
|
||||
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
|
||||
f.StringVar(&personalOpts.UserID, "user", "",
|
||||
"个人单聊对端 userId")
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
@@ -457,7 +467,13 @@ func newEventBusCommand() *cobra.Command {
|
||||
readyPipe := busctl.ReadyFDFromEnv()
|
||||
failEarly := func(err error) error {
|
||||
if readyPipe != nil {
|
||||
// 'E' signals failure; the trailing text lets the parent
|
||||
// (busctl.waitReady) surface the real startup error to the
|
||||
// user instead of an opaque "startup failure on ready pipe".
|
||||
_, _ = readyPipe.Write([]byte{'E'})
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(readyPipe, err.Error())
|
||||
}
|
||||
_ = readyPipe.Close()
|
||||
}
|
||||
return err
|
||||
@@ -1093,6 +1109,26 @@ func firstArg(args []string) string {
|
||||
return args[0]
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher (AI-subprocess
|
||||
// contract). It arms only for a parent-controlled, pipe-style stdin on an
|
||||
// unbounded run:
|
||||
// - bounded runs (--max-events / --duration) already have their own
|
||||
// lifecycle, so stdin is irrelevant;
|
||||
// - char devices (an interactive TTY, or /dev/null) are excluded, so a
|
||||
// terminal Ctrl-D and the common `< /dev/null` launch do NOT trigger a
|
||||
// surprise shutdown. Only a pipe / regular file — an stdin a parent
|
||||
// holds and can close to stop us — arms the watcher.
|
||||
func shouldWatchStdinEOF(maxEvents int, duration time.Duration) bool {
|
||||
if maxEvents > 0 || duration > 0 {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice == 0
|
||||
}
|
||||
|
||||
// eventTypesWithDefault picks the catch-all list from registry when the
|
||||
// user did not pass --event-types.
|
||||
func eventTypesWithDefault(types []string) []string {
|
||||
|
||||
@@ -204,6 +204,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -239,7 +240,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
if opts.Ephemeral {
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
@@ -251,6 +259,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -260,6 +269,11 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
@@ -756,6 +770,16 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ func Execute() (exitCode int) {
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||
// including command errors where Cobra skips PersistentPostRunE.
|
||||
timing.PrintIfEnabled()
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
@@ -335,6 +337,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
},
|
||||
@@ -357,6 +360,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
@@ -560,6 +564,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"audit": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"dev": true,
|
||||
@@ -594,7 +599,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -142,6 +143,7 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
auditSink: setupAuditSink(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ type runtimeRunner struct {
|
||||
scanner safety.Scanner
|
||||
enforceContentScan bool
|
||||
includeScanReport bool
|
||||
auditSink audit.Sink
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
@@ -468,6 +471,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
emitAudit(r.auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileSinkEmit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
defer sink.Close()
|
||||
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "abc123",
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "calendar",
|
||||
Command: "list_events",
|
||||
Endpoint: "https://api.example.com/mcp",
|
||||
Result: "success",
|
||||
DurationMs: 150,
|
||||
CLIVersion: "1.0.47",
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if evt.Hash == "" {
|
||||
t.Error("expected hash to be set")
|
||||
}
|
||||
if evt.PrevHash != "" {
|
||||
t.Error("first event should have empty prev_hash")
|
||||
}
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Event
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.ExecutionID != "abc123" {
|
||||
t.Errorf("got execution_id=%s, want abc123", decoded.ExecutionID)
|
||||
}
|
||||
if decoded.Hash == "" {
|
||||
t.Error("decoded hash should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainIntegrity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('a'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: int64(i * 10),
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid, brokenAt, err := VerifyFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Errorf("expected valid chain, broken at line %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainDetectsTampering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('0'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: 100,
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tamper with the file: modify a character in the second line
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Find second newline and change a char after it
|
||||
lines := splitLines(data)
|
||||
if len(lines) < 2 {
|
||||
t.Fatal("expected at least 2 lines")
|
||||
}
|
||||
// Corrupt the second line by changing first char of product
|
||||
var evt2 map[string]any
|
||||
json.Unmarshal([]byte(lines[1]), &evt2)
|
||||
evt2["product"] = "tampered"
|
||||
tampered, _ := json.Marshal(evt2)
|
||||
lines[1] = string(tampered)
|
||||
|
||||
corrupted := []byte(lines[0] + "\n" + lines[1] + "\n" + lines[2] + "\n")
|
||||
os.WriteFile(file, corrupted, 0o600)
|
||||
|
||||
valid, brokenAt, _ := VerifyFile(file)
|
||||
if valid {
|
||||
t.Error("expected invalid chain after tampering")
|
||||
}
|
||||
if brokenAt != 2 {
|
||||
t.Errorf("expected break at line 2, got %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetention(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create old files
|
||||
oldDate := time.Now().AddDate(0, 0, -100).Format("20060102")
|
||||
recentDate := time.Now().AddDate(0, 0, -10).Format("20060102")
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
|
||||
|
||||
_, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Give async pruning a moment
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+oldDate+".jsonl")); !os.IsNotExist(err) {
|
||||
t.Error("expected old file to be pruned")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+recentDate+".jsonl")); err != nil {
|
||||
t.Error("recent file should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactEvent(t *testing.T) {
|
||||
evt := Event{
|
||||
Actor: Actor{UserID: "uid123", Name: "张三", CorpID: "corp1", CorpName: "公司A"},
|
||||
Product: "calendar",
|
||||
Command: "list",
|
||||
ParamsSummary: `{"date":"2026-01-01"}`,
|
||||
Result: "success",
|
||||
}
|
||||
|
||||
hashed := RedactEvent(evt, RedactHashed)
|
||||
if hashed.Actor.Name == "张三" {
|
||||
t.Error("name should be hashed")
|
||||
}
|
||||
if hashed.ParamsSummary != "" {
|
||||
t.Error("params should be cleared in hashed mode")
|
||||
}
|
||||
if hashed.Actor.UserID != "uid123" {
|
||||
t.Error("user_id should remain in hashed mode")
|
||||
}
|
||||
|
||||
minimal := RedactEvent(evt, RedactMinimal)
|
||||
if minimal.Actor.UserID == "uid123" {
|
||||
t.Error("user_id should be hashed in minimal mode")
|
||||
}
|
||||
if minimal.Endpoint != "" {
|
||||
t.Error("endpoint should be cleared in minimal mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNopSink(t *testing.T) {
|
||||
var s NopSink
|
||||
if err := s.Emit(&Event{}); err != nil {
|
||||
t.Error("NopSink.Emit should not error")
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Error("NopSink.Close should not error")
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(data []byte) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
if i > start {
|
||||
lines = append(lines, string(data[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, string(data[start:]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Chain computes the L1 sha256 tamper-evidence chain. It is deliberately
|
||||
// stateless: every seal derives prev_hash from the last record already present
|
||||
// in the current day's file rather than from an in-memory or sidecar cursor.
|
||||
//
|
||||
// This makes the chain correct in two cases the previous global-sidecar design
|
||||
// broke:
|
||||
// - cross-date: each day rotates to a fresh file whose first record chains
|
||||
// from "", so every file is independently verifiable by VerifyFile.
|
||||
// - cross-process: because the caller holds an exclusive inter-process lock on
|
||||
// the file while sealing, the tail read reflects records written by any
|
||||
// other dws process, so concurrent writers cannot fork the chain.
|
||||
type Chain struct{}
|
||||
|
||||
// NewChain keeps the historical constructor signature. The directory argument
|
||||
// is no longer needed because prev_hash is derived from the target file.
|
||||
func NewChain(string) *Chain { return &Chain{} }
|
||||
|
||||
// SealFromFile reads the hash of the last record in f (the current day's audit
|
||||
// file) and returns the prev_hash / hash pair for the event whose hash-free
|
||||
// body is provided. The caller must hold the file lock.
|
||||
func (c *Chain) SealFromFile(f *os.File, body []byte) (prevHash, hash string, err error) {
|
||||
prevHash, err = lastRecordHash(f)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return prevHash, ComputeHash(prevHash, body), nil
|
||||
}
|
||||
|
||||
// lastRecordHash returns the "hash" field of the final non-empty JSONL record in
|
||||
// f, or "" when the file is empty. It reads only the tail of the file so cost
|
||||
// does not grow with file size.
|
||||
func lastRecordHash(f *os.File) (string, error) {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
size := fi.Size()
|
||||
if size == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
const tailWindow = 64 * 1024
|
||||
start := size - tailWindow
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, size-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Trim trailing newlines, then isolate the last line within the window.
|
||||
end := len(buf)
|
||||
for end > 0 && (buf[end-1] == '\n' || buf[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
if end == 0 {
|
||||
return "", nil
|
||||
}
|
||||
lineStart := end
|
||||
for lineStart > 0 && buf[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
last := buf[lineStart:end]
|
||||
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
// The last record spilled past our tail window (pathologically large
|
||||
// line). Fall back to a full scan for correctness.
|
||||
if lineStart == 0 && start > 0 {
|
||||
return lastRecordHashFullScan(f)
|
||||
}
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func lastRecordHashFullScan(f *os.File) (string, error) {
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
var last []byte
|
||||
for scanner.Scan() {
|
||||
if b := scanner.Bytes(); len(b) > 0 {
|
||||
last = append(last[:0], b...)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(last) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func VerifyFile(path string) (valid bool, brokenAt int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
|
||||
prevHash := ""
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
|
||||
var evt struct {
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return false, lineNum, fmt.Errorf("line %d: invalid JSON: %w", lineNum, err)
|
||||
}
|
||||
|
||||
if evt.PrevHash != prevHash {
|
||||
return false, lineNum, fmt.Errorf("line %d: prev_hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
body := stripHashFields(line)
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(body)
|
||||
expected := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
if evt.Hash != expected {
|
||||
return false, lineNum, fmt.Errorf("line %d: hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
prevHash = evt.Hash
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, lineNum, err
|
||||
}
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func stripHashFields(line []byte) []byte {
|
||||
var evt Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return line
|
||||
}
|
||||
evt.PrevHash = ""
|
||||
evt.Hash = ""
|
||||
out, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ComputeHash(prevHash string, eventJSON []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(eventJSON)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvAudit = "DWS_AUDIT"
|
||||
EnvAuditDir = "DWS_AUDIT_DIR"
|
||||
EnvRetentionDays = "DWS_AUDIT_RETENTION_DAYS"
|
||||
EnvForwardURL = "DWS_AUDIT_FORWARD_URL"
|
||||
EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN"
|
||||
EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT"
|
||||
EnvAuditDebug = "DWS_AUDIT_DEBUG"
|
||||
|
||||
defaultRetentionDays = 90
|
||||
auditSubdir = "audit"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAudit,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "操作审计日志开关(默认启用,设 0/false/off 关闭)",
|
||||
DefaultValue: "启用",
|
||||
Example: "0",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDir,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志目录(默认 <configDir>/audit)",
|
||||
Example: "/var/log/dws-audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvRetentionDays,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志留存天数",
|
||||
DefaultValue: "90",
|
||||
Example: "180",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardURL,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计事件远端转发 URL(POST JSON)",
|
||||
Example: "https://siem.example.com/audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardToken,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发 Bearer Token",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardRedact,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发脱敏级别:none / hashed / minimal",
|
||||
DefaultValue: "none",
|
||||
Example: "hashed",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDebug,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "打印审计子系统初始化/写入/转发失败诊断到 stderr(设 1/true/on 开启)",
|
||||
Example: "1",
|
||||
})
|
||||
}
|
||||
|
||||
// DebugEnabled reports whether audit-subsystem diagnostics should be surfaced to
|
||||
// stderr. Failures are always eligible for the structured log; this gates the
|
||||
// noisier stderr channel.
|
||||
func DebugEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditDebug))) {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsEnabled() bool {
|
||||
v := os.Getenv(EnvAudit)
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BuildSink constructs the audit sink for configDir. It returns an error when
|
||||
// the audit subsystem is enabled but cannot initialize (e.g. the log directory
|
||||
// is not writable) so the caller can surface the failure instead of silently
|
||||
// degrading. report receives non-fatal forwarder diagnostics; it may be nil.
|
||||
func BuildSink(configDir string, report func(format string, args ...any)) (Sink, error) {
|
||||
if !IsEnabled() {
|
||||
return NopSink{}, nil
|
||||
}
|
||||
|
||||
dir := os.Getenv(EnvAuditDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(configDir, auditSubdir)
|
||||
}
|
||||
|
||||
retention := defaultRetentionDays
|
||||
if v := os.Getenv(EnvRetentionDays); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
retention = n
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := NewDateRotatingWriter(dir, retention)
|
||||
if err != nil {
|
||||
return NopSink{}, err
|
||||
}
|
||||
|
||||
chain := NewChain(dir)
|
||||
|
||||
var forwarder *HTTPForwarder
|
||||
if fwdURL := os.Getenv(EnvForwardURL); fwdURL != "" {
|
||||
token := os.Getenv(EnvForwardToken)
|
||||
redact := RedactLevel(strings.ToLower(os.Getenv(EnvForwardRedact)))
|
||||
if redact != RedactHashed && redact != RedactMinimal {
|
||||
redact = RedactNone
|
||||
}
|
||||
forwarder = NewHTTPForwarder(fwdURL, token, redact, report)
|
||||
}
|
||||
|
||||
return NewFileSink(writer, chain, forwarder), nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Actor Actor `json:"actor"`
|
||||
Product string `json:"product"`
|
||||
Command string `json:"command"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ParamsSummary string `json:"params_summary,omitempty"`
|
||||
Result string `json:"result"`
|
||||
ErrCategory string `json:"error_category,omitempty"`
|
||||
ErrReason string `json:"error_reason,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CorpID string `json:"corp_id"`
|
||||
CorpName string `json:"corp_name,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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 audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lockFile takes a non-blocking exclusive advisory lock; returns an error when
|
||||
// another process holds it so the caller can retry with a timeout.
|
||||
func lockFile(f *os.File) error {
|
||||
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const lockfileExclusiveLock = 0x00000002
|
||||
|
||||
// lockFile takes a non-blocking exclusive lock on the first byte range; returns
|
||||
// an error when another process holds it so the caller can retry with a timeout.
|
||||
func lockFile(f *os.File) error {
|
||||
ol := new(windows.Overlapped)
|
||||
return windows.LockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
lockfileExclusiveLock|windows.LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
(*windows.Overlapped)(unsafe.Pointer(ol)),
|
||||
)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
ol := new(windows.Overlapped)
|
||||
_ = windows.UnlockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
(*windows.Overlapped)(unsafe.Pointer(ol)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPForwarder struct {
|
||||
url string
|
||||
token string
|
||||
redact RedactLevel
|
||||
client *http.Client
|
||||
wg sync.WaitGroup
|
||||
report func(format string, args ...any)
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewHTTPForwarder(url, token string, redact RedactLevel, report func(string, ...any)) *HTTPForwarder {
|
||||
if report == nil {
|
||||
report = func(string, ...any) {}
|
||||
}
|
||||
return &HTTPForwarder{
|
||||
url: url,
|
||||
token: token,
|
||||
redact: redact,
|
||||
client: &http.Client{Timeout: 3 * time.Second},
|
||||
report: report,
|
||||
timeout: 3 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Forward dispatches the event asynchronously while tracking the goroutine so
|
||||
// Close can wait for delivery instead of the CLI dropping it on exit.
|
||||
func (f *HTTPForwarder) Forward(evt Event) {
|
||||
f.wg.Add(1)
|
||||
go func() {
|
||||
defer f.wg.Done()
|
||||
f.send(evt)
|
||||
}()
|
||||
}
|
||||
|
||||
// Close waits for in-flight forwards to finish, bounded by ctx (and, if ctx has
|
||||
// no deadline, by a small internal timeout) so shutdown never blocks forever.
|
||||
func (f *HTTPForwarder) Close(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, f.timeout+2*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
f.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
f.report("forward flush timed out: %v", ctx.Err())
|
||||
return fmt.Errorf("audit: forward flush timed out: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HTTPForwarder) send(evt Event) {
|
||||
var body []byte
|
||||
var err error
|
||||
if f.redact != RedactNone {
|
||||
body, err = RedactEventJSON(evt, f.redact)
|
||||
} else {
|
||||
body, err = json.Marshal(evt)
|
||||
}
|
||||
if err != nil {
|
||||
f.report("forward marshal failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
f.report("forward build request failed: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+f.token)
|
||||
}
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
f.report("forward request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
f.report("forward rejected: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type RedactLevel string
|
||||
|
||||
const (
|
||||
RedactNone RedactLevel = "none"
|
||||
RedactHashed RedactLevel = "hashed"
|
||||
RedactMinimal RedactLevel = "minimal"
|
||||
)
|
||||
|
||||
func RedactEvent(evt Event, level RedactLevel) Event {
|
||||
switch level {
|
||||
case RedactHashed:
|
||||
if evt.Actor.Name != "" {
|
||||
evt.Actor.Name = hashString(evt.Actor.Name)
|
||||
}
|
||||
if evt.Actor.CorpName != "" {
|
||||
evt.Actor.CorpName = hashString(evt.Actor.CorpName)
|
||||
}
|
||||
evt.ParamsSummary = ""
|
||||
case RedactMinimal:
|
||||
evt.Actor = Actor{UserID: hashString(evt.Actor.UserID), CorpID: hashString(evt.Actor.CorpID)}
|
||||
evt.ParamsSummary = ""
|
||||
evt.Endpoint = ""
|
||||
evt.ErrReason = ""
|
||||
evt.AgentID = ""
|
||||
evt.PrevHash = ""
|
||||
evt.Hash = ""
|
||||
}
|
||||
return evt
|
||||
}
|
||||
|
||||
func RedactEventJSON(evt Event, level RedactLevel) ([]byte, error) {
|
||||
redacted := RedactEvent(evt, level)
|
||||
return json.Marshal(redacted)
|
||||
}
|
||||
|
||||
func hashString(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeEvent seals and appends one event through the writer+chain, mirroring
|
||||
// FileSink.Emit's locking discipline, so tests exercise the real tail-derived
|
||||
// hash chain path.
|
||||
func writeEvent(t *testing.T, w *DateRotatingWriter, chain *Chain, evt *Event) {
|
||||
t.Helper()
|
||||
body, err := marshalWithoutHash(evt)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
f, release, err := w.beginAppend()
|
||||
if err != nil {
|
||||
t.Fatalf("beginAppend: %v", err)
|
||||
}
|
||||
prev, hash, err := chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
release()
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
evt.PrevHash, evt.Hash = prev, hash
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
release()
|
||||
t.Fatalf("marshal final: %v", err)
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
release()
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func sampleEvent(id string) *Event {
|
||||
return &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: id,
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "calendar",
|
||||
Command: "event_list",
|
||||
Result: "success",
|
||||
DurationMs: 10,
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossDateChainIndependentPerFile verifies each day's file starts a fresh
|
||||
// chain (prev_hash="") and verifies independently — the bug the removed global
|
||||
// .chain sidecar introduced.
|
||||
func TestCrossDateChainIndependentPerFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chain := NewChain(dir)
|
||||
|
||||
// Simulate two calendar days by writing files directly with the same chain
|
||||
// semantics: each file's first record must chain from "".
|
||||
for _, day := range []string{"20260101", "20260102"} {
|
||||
path := filepath.Join(dir, "audit-"+day+".jsonl")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
evt := sampleEvent(fmt.Sprintf("%s-%d", day, i))
|
||||
body, _ := marshalWithoutHash(evt)
|
||||
prev, hash, err := chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt.PrevHash, evt.Hash = prev, hash
|
||||
line, _ := json.Marshal(evt)
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
valid, brokenAt, err := VerifyFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("verify %s: %v", day, err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatalf("file %s chain broken at line %d", day, brokenAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossProcessChainSharedFile simulates two independent writers (as two
|
||||
// processes would) appending to the same day's file. Because each seal derives
|
||||
// prev_hash from the file tail under the inter-process lock, the resulting chain
|
||||
// must remain valid with no fork.
|
||||
func TestCrossProcessChainSharedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
w1, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w1.Close()
|
||||
w2, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w2.Close()
|
||||
chain := NewChain(dir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
w := w1
|
||||
if i%2 == 1 {
|
||||
w = w2
|
||||
}
|
||||
go func(w *DateRotatingWriter, i int) {
|
||||
defer wg.Done()
|
||||
writeEvent(t, w, chain, sampleEvent(fmt.Sprintf("exec-%d", i)))
|
||||
}(w, i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid, brokenAt, err := VerifyFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatalf("cross-process chain broken at line %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderCloseWaitsForDelivery ensures Close blocks until every async
|
||||
// forward has been delivered to the remote endpoint.
|
||||
func TestForwarderCloseWaitsForDelivery(t *testing.T) {
|
||||
var received int64
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release // hold the handler so delivery is still in flight at Close time
|
||||
atomic.AddInt64(&received, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, nil)
|
||||
const n = 5
|
||||
for i := 0; i < n; i++ {
|
||||
fwd.Forward(*sampleEvent(fmt.Sprintf("e-%d", i)))
|
||||
}
|
||||
|
||||
// Nothing delivered yet because handlers are blocked.
|
||||
if got := atomic.LoadInt64(&received); got != 0 {
|
||||
t.Fatalf("expected 0 delivered before release, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
if err := fwd.Close(context.Background()); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&received); got != n {
|
||||
t.Fatalf("expected %d delivered after Close, got %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderCloseTimeoutReports verifies Close honors the ctx deadline and
|
||||
// reports instead of blocking forever when the endpoint never responds.
|
||||
func TestForwarderCloseTimeoutReports(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
// Defers run LIFO: close(block) first unblocks the handler so srv.Close can
|
||||
// join its connection goroutine without deadlocking.
|
||||
defer srv.Close()
|
||||
defer close(block)
|
||||
|
||||
var reported int64
|
||||
report := func(string, ...any) { atomic.AddInt64(&reported, 1) }
|
||||
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, report)
|
||||
fwd.timeout = 10 * time.Second // keep the in-flight send alive past ctx
|
||||
fwd.Forward(*sampleEvent("stuck"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := fwd.Close(ctx); err == nil {
|
||||
t.Fatal("expected timeout error from Close")
|
||||
}
|
||||
if atomic.LoadInt64(&reported) == 0 {
|
||||
t.Fatal("expected Close timeout to be reported")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSinkInitFailureObservable verifies BuildSink surfaces an error (and
|
||||
// the caller can fall back) when the audit directory cannot be created.
|
||||
func TestBuildSinkInitFailureObservable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Make a file where the audit subdir is expected so MkdirAll fails.
|
||||
clash := filepath.Join(dir, "audit")
|
||||
if err := os.WriteFile(clash, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(EnvAuditDir, filepath.Join(clash, "sub"))
|
||||
|
||||
var reported int64
|
||||
_, err := BuildSink(dir, func(string, ...any) { atomic.AddInt64(&reported, 1) })
|
||||
if err == nil {
|
||||
t.Fatal("expected BuildSink to fail when audit dir is unusable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
auditLockFile = ".audit.lock"
|
||||
auditLockTimeout = 3 * time.Second
|
||||
auditLockRetry = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
type DateRotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
curDate string
|
||||
file *os.File
|
||||
lock *os.File
|
||||
retention int
|
||||
}
|
||||
|
||||
func NewDateRotatingWriter(dir string, retentionDays int) (*DateRotatingWriter, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("audit: create dir: %w", err)
|
||||
}
|
||||
lock, err := os.OpenFile(filepath.Join(dir, auditLockFile), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: open lock file: %w", err)
|
||||
}
|
||||
w := &DateRotatingWriter{
|
||||
dir: dir,
|
||||
retention: retentionDays,
|
||||
lock: lock,
|
||||
}
|
||||
go w.pruneOldFiles()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// beginAppend serializes writers within this process (mu) and across processes
|
||||
// (flock), rotates to today's file, and returns the open handle plus a release
|
||||
// func that unlocks in reverse order. The file is opened O_RDWR|O_APPEND so the
|
||||
// chain can read the tail while every write still lands atomically at EOF even
|
||||
// when another dws process appends concurrently.
|
||||
func (w *DateRotatingWriter) beginAppend() (*os.File, func(), error) {
|
||||
w.mu.Lock()
|
||||
if err := w.acquireLock(); err != nil {
|
||||
w.mu.Unlock()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
today := time.Now().Format("20060102")
|
||||
if today != w.curDate || w.file == nil {
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
w.file = nil
|
||||
}
|
||||
path := filepath.Join(w.dir, fmt.Sprintf("audit-%s.jsonl", today))
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
unlockFile(w.lock)
|
||||
w.mu.Unlock()
|
||||
return nil, nil, fmt.Errorf("audit: open file: %w", err)
|
||||
}
|
||||
w.file = f
|
||||
w.curDate = today
|
||||
}
|
||||
|
||||
release := func() {
|
||||
unlockFile(w.lock)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
return w.file, release, nil
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) acquireLock() error {
|
||||
deadline := time.Now().Add(auditLockTimeout)
|
||||
for {
|
||||
if err := lockFile(w.lock); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("audit: timeout acquiring file lock after %v (another dws process may be writing)", auditLockTimeout)
|
||||
}
|
||||
time.Sleep(auditLockRetry)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var firstErr error
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
w.file = nil
|
||||
}
|
||||
if w.lock != nil {
|
||||
if err := w.lock.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
w.lock = nil
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) pruneOldFiles() {
|
||||
if w.retention <= 0 {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(w.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -w.retention).Format("20060102")
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
dateStr := strings.TrimPrefix(name, "audit-")
|
||||
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
|
||||
if len(dateStr) != 8 {
|
||||
continue
|
||||
}
|
||||
if dateStr < cutoff {
|
||||
_ = os.Remove(filepath.Join(w.dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) Dir() string {
|
||||
return w.dir
|
||||
}
|
||||
|
||||
func LatestAuditFile(dir string) (string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "audit-") && strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no audit files found in %s", dir)
|
||||
}
|
||||
sort.Strings(files)
|
||||
return filepath.Join(dir, files[len(files)-1]), nil
|
||||
}
|
||||
|
||||
func AuditFilesInRange(dir, since, until string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
dateStr := strings.TrimPrefix(name, "audit-")
|
||||
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
|
||||
if len(dateStr) != 8 {
|
||||
continue
|
||||
}
|
||||
if (since == "" || dateStr >= since) && (until == "" || dateStr <= until) {
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
Emit(event *Event) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type NopSink struct{}
|
||||
|
||||
func (NopSink) Emit(*Event) error { return nil }
|
||||
func (NopSink) Close() error { return nil }
|
||||
|
||||
type FileSink struct {
|
||||
writer *DateRotatingWriter
|
||||
chain *Chain
|
||||
forwarder *HTTPForwarder
|
||||
}
|
||||
|
||||
func NewFileSink(writer *DateRotatingWriter, chain *Chain, forwarder *HTTPForwarder) *FileSink {
|
||||
return &FileSink{
|
||||
writer: writer,
|
||||
chain: chain,
|
||||
forwarder: forwarder,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileSink) Emit(evt *Event) error {
|
||||
body, err := marshalWithoutHash(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit: marshal event: %w", err)
|
||||
}
|
||||
|
||||
f, release, err := s.writer.beginAppend()
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit: acquire writer: %w", err)
|
||||
}
|
||||
|
||||
// Derive prev_hash from the file tail, seal, and append — all under the
|
||||
// writer's process + inter-process lock so the chain cannot fork.
|
||||
prevHash, hash, err := s.chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: seal event: %w", err)
|
||||
}
|
||||
evt.PrevHash = prevHash
|
||||
evt.Hash = hash
|
||||
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: marshal final event: %w", err)
|
||||
}
|
||||
line = append(line, '\n')
|
||||
if _, err := f.Write(line); err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: write event: %w", err)
|
||||
}
|
||||
release()
|
||||
|
||||
if s.forwarder != nil {
|
||||
s.forwarder.Forward(*evt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close flushes in-flight remote forwards (bounded) before closing the writer,
|
||||
// so events are not silently dropped when the CLI process exits right after
|
||||
// emitting.
|
||||
func (s *FileSink) Close() error {
|
||||
var forwardErr error
|
||||
if s.forwarder != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
forwardErr = s.forwarder.Close(ctx)
|
||||
cancel()
|
||||
}
|
||||
if err := s.writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return forwardErr
|
||||
}
|
||||
|
||||
func marshalWithoutHash(evt *Event) ([]byte, error) {
|
||||
saved := Event{
|
||||
Timestamp: evt.Timestamp,
|
||||
ExecutionID: evt.ExecutionID,
|
||||
AgentID: evt.AgentID,
|
||||
Actor: evt.Actor,
|
||||
Product: evt.Product,
|
||||
Command: evt.Command,
|
||||
Endpoint: evt.Endpoint,
|
||||
ParamsSummary: evt.ParamsSummary,
|
||||
Result: evt.Result,
|
||||
ErrCategory: evt.ErrCategory,
|
||||
ErrReason: evt.ErrReason,
|
||||
DurationMs: evt.DurationMs,
|
||||
CLIVersion: evt.CLIVersion,
|
||||
OS: evt.OS,
|
||||
Arch: evt.Arch,
|
||||
}
|
||||
return json.Marshal(saved)
|
||||
}
|
||||
@@ -163,6 +163,10 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
|
||||
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
|
||||
// refresh and login cannot rewrite an entry while its DEK backend is changing.
|
||||
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
|
||||
var migrated int
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
var err error
|
||||
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
|
||||
return err
|
||||
})
|
||||
return migrated, err
|
||||
}
|
||||
@@ -15,12 +15,14 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,6 +30,9 @@ var (
|
||||
migrationDone bool
|
||||
)
|
||||
|
||||
// ErrTokenDataNotFound means the requested keychain slot does not exist.
|
||||
var ErrTokenDataNotFound = errors.New("token data not found")
|
||||
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
@@ -86,7 +91,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -96,6 +101,72 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies that every registered token slot can be
|
||||
// read before an OAuth login or exchange can target any profile.
|
||||
// A missing slot is safe (first login or a legacy fallback); any other error
|
||||
// stops the remote operation when existing ciphertext is already known to be
|
||||
// unreadable and therefore unsafe to update.
|
||||
func preflightTokenPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cfg.Profiles))
|
||||
for _, profile := range cfg.Profiles {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
if corpID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[corpID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[corpID] = struct{}{}
|
||||
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
|
||||
TokenAccountForCorpID(corpID), corpID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
|
||||
return fmt.Errorf(
|
||||
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
|
||||
func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
|
||||
@@ -27,10 +27,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Use MCP mode if clientID is from MCP server
|
||||
if IsClientIDFromMCP() {
|
||||
return p.exchangeCodeViaMCP(ctx, code)
|
||||
|
||||
@@ -110,6 +110,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
@@ -623,6 +626,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("refresh_token 已过期")
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(data); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
|
||||
@@ -67,7 +67,11 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
|
||||
return false
|
||||
}
|
||||
_, err := LoadTokenDataKeychain()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
@@ -93,7 +97,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
@@ -102,6 +106,9 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
if _, err := LoadTokenDataKeychain(); err != nil {
|
||||
return fmt.Errorf("auth token cannot be decrypted with the portable file DEK: %w", err)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -187,7 +188,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
@@ -197,6 +198,8 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
|
||||
return nil, lerr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// 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 darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setPreflightTestCredentials(t *testing.T) {
|
||||
t.Helper()
|
||||
SetClientID("preflight-client-id")
|
||||
SetClientSecret("preflight-client-secret")
|
||||
resetClientIDFromMCP()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
}
|
||||
|
||||
func profileCiphertextPathForTest(corpID string) string {
|
||||
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
|
||||
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
|
||||
}
|
||||
|
||||
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
|
||||
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != data.AccessToken {
|
||||
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
err := preflightTokenPersistence(configDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
|
||||
|
||||
// Simulate interruption after the profile ciphertext rename but before
|
||||
// profiles.json is updated by saveTokenDataLocked.
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_portable", "", "")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
|
||||
}
|
||||
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
if PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
|
||||
}
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
if err := preflightTokenRefreshPersistence(dataB); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
|
||||
}
|
||||
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != dataB.AccessToken {
|
||||
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
setPreflightTestCredentials(t)
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.NoBrowser = true
|
||||
_, err := provider.Login(ctx, force)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
_, err := provider.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("device code requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected refresh request")
|
||||
})}
|
||||
_, err := provider.lockedRefresh(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("refresh HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.UserID != "user-new" {
|
||||
t.Fatalf("ExchangeAuthCode() data = %#v", data)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,10 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
服务发现和动态 schema 已下线。静态端点模式下,schema 覆盖两类命令:
|
||||
1. helper-only 子树(如 dev):CONTENT 从其绑定的 MCP 服务实时取,source 为 mcp:<server>;
|
||||
2. 登记的本地命令(如 event):从二进制注册的 cobra flag 合成,source 为 cobra。
|
||||
其余普通产品命令和 flag 仍以当前二进制的 --help 为准。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -88,7 +91,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees support.
|
||||
// Helper-only subtrees: schema CONTENT fetched live from the MCP server.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
@@ -101,6 +104,19 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
}
|
||||
}
|
||||
|
||||
// Registered local subtrees (event, …): schema synthesized from cobra flags.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderCobraSchema(cmd.Root(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// cobraSchemaRoots are top-level command names whose subtrees answer `dws schema`
|
||||
// by SYNTHESIZING the machine-readable input schema from their cobra flags. This
|
||||
// is the local-command counterpart to helperSchemaRoots: helper subtrees (dev)
|
||||
// fetch CONTENT live from an MCP server, whereas these commands have no MCP
|
||||
// backing so the schema is built from the flags the binary actually registered.
|
||||
//
|
||||
// The output shape is the same flat object helper leaves emit — {description,
|
||||
// path, source, parameters{<flag>:{type,description,required,default?}}} — so an
|
||||
// agent gets one consistent schema format no matter the source; `source` is
|
||||
// "cobra" to mark it synthesized from flags (vs "mcp:<server>"). event is the
|
||||
// first consumer; register more command trees here as they adopt the contract.
|
||||
var cobraSchemaRoots = map[string]bool{"event": true}
|
||||
|
||||
// renderCobraSchema builds the `dws schema` payload for command subtrees listed
|
||||
// in cobraSchemaRoots. Mirrors renderHelperSchema's routing: returns
|
||||
// (payload, true) when the path targets a registered subtree so the caller
|
||||
// skips the static-mode fallback; (nil, false) otherwise.
|
||||
//
|
||||
// A runnable leaf renders the flat parameter object synthesized from its flags
|
||||
// (plus positional arguments parsed from its Use line). A group/root renders the
|
||||
// same browse listing helper groups use.
|
||||
func renderCobraSchema(root *cobra.Command, rawPath string) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !cobraSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Any non-flag leftover token means an unknown subcommand — surface it with
|
||||
// the closest group's children, same as renderHelperSchema.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
return cobraLeafSchema(target), true, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// cobraLeafSchema renders one runnable command as the flat schema object,
|
||||
// synthesizing parameters from its flags and (when present) positional
|
||||
// arguments from its Use line.
|
||||
func cobraLeafSchema(cmd *cobra.Command) map[string]any {
|
||||
out := map[string]any{
|
||||
"description": strings.TrimSpace(cmd.Short),
|
||||
"path": helperCommandPath(cmd),
|
||||
"source": "cobra",
|
||||
"parameters": cobraFlatParameters(cmd),
|
||||
}
|
||||
if args := cobraPositionalArgs(cmd); len(args) > 0 {
|
||||
out["arguments"] = args
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cobraFlatParameters projects a command's LOCAL flags into the flat
|
||||
// per-parameter object. Local (non-inherited) flags are the command-specific
|
||||
// inputs; global persistent flags inherited from the root (--profile, --verbose,
|
||||
// --jq, …) are intentionally excluded so the schema describes THIS command, not
|
||||
// the whole CLI. Hidden internal flags are skipped. Each entry is
|
||||
// {type, description, required, default?} with type mapped to a JSON-type
|
||||
// string, required read from cobra's required-flag annotation, and default only
|
||||
// when the flag has a meaningful (non-zero) default.
|
||||
func cobraFlatParameters(cmd *cobra.Command) map[string]any {
|
||||
params := map[string]any{}
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
entry := map[string]any{
|
||||
"type": pflagJSONType(f),
|
||||
"description": strings.TrimSpace(f.Usage),
|
||||
"required": flagIsRequired(f),
|
||||
}
|
||||
if def, ok := meaningfulDefault(f); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[f.Name] = entry
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
// cobraPositionalArgs parses a command's Use line into structured positional
|
||||
// arguments. Cobra has no typed metadata for positionals, so the Use string
|
||||
// ("consume [event_key]", "stop [subscribe_id]") is the source of truth:
|
||||
// tokens after the command name are positional slots. <name> is required,
|
||||
// [name] is optional, a trailing "..." marks it variadic. Returns nil when the
|
||||
// command declares no positionals (e.g. flag-only commands), so the leaf object
|
||||
// simply omits the "arguments" field.
|
||||
func cobraPositionalArgs(cmd *cobra.Command) []map[string]any {
|
||||
fields := strings.Fields(cmd.Use)
|
||||
if len(fields) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, tok := range fields[1:] {
|
||||
variadic := strings.Contains(tok, "...")
|
||||
required := strings.HasPrefix(tok, "<")
|
||||
name := strings.Trim(tok, "[]<>.")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
arg := map[string]any{
|
||||
"name": name,
|
||||
"required": required,
|
||||
}
|
||||
if variadic {
|
||||
arg["variadic"] = true
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pflagJSONType maps a pflag value type to a JSON-type string. Duration is
|
||||
// expressed as "string" because it is entered as a CLI string ("10m"); slice
|
||||
// and array types collapse to "array"; the numeric families collapse to
|
||||
// "integer"/"number". Unknown types default to "string" so the contract is
|
||||
// always populated.
|
||||
func pflagJSONType(f *pflag.Flag) string {
|
||||
switch f.Value.Type() {
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64", "count":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "stringSlice", "stringArray", "intSlice", "int32Slice", "int64Slice",
|
||||
"uintSlice", "float32Slice", "float64Slice", "boolSlice", "durationSlice":
|
||||
return "array"
|
||||
default:
|
||||
// string, duration, ip, and any custom Value type read as a string.
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// flagIsRequired reports whether cobra.MarkFlagRequired was applied to the flag
|
||||
// (it records the requirement in the flag's annotations under
|
||||
// cobra.BashCompOneRequiredFlag). Event's conditionally-required inputs
|
||||
// (--user / --group depend on the event key) are enforced at runtime, not via
|
||||
// this annotation, so they read as required:false here — the dependency is
|
||||
// documented in the command help, not the flag metadata.
|
||||
func flagIsRequired(f *pflag.Flag) bool {
|
||||
if f.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
vals, ok := f.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
return ok && len(vals) == 1 && vals[0] == "true"
|
||||
}
|
||||
|
||||
// meaningfulDefault returns a flag's default only when it is a real value, not
|
||||
// the zero/unset sentinel ("", "0", "0s", "false", "[]"). A zero default means
|
||||
// "no default — the value comes from you", so omitting it matches how the helper
|
||||
// renderer omits absent MCP defaults and keeps the schema free of noise.
|
||||
func meaningfulDefault(f *pflag.Flag) (string, bool) {
|
||||
def := strings.TrimSpace(f.DefValue)
|
||||
switch def {
|
||||
case "", "0", "0s", "false", "[]", "{}":
|
||||
return "", false
|
||||
default:
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildEventTestTree mirrors the shape of the real `dws event` subtree closely
|
||||
// enough to exercise the cobra-flag schema renderer: a group with a runnable
|
||||
// leaf that carries a positional arg, typed flags (string/int/duration/bool),
|
||||
// a required flag, a hidden internal flag, and a defaulted flag.
|
||||
func buildEventTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// A global persistent flag inherited by every command — must NOT appear in a
|
||||
// command's synthesized parameters (it describes the CLI, not the command).
|
||||
root.PersistentFlags().String("profile", "", "组织 profile")
|
||||
|
||||
consume := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
f := consume.Flags()
|
||||
f.StringP("format", "f", "ndjson", "输出格式")
|
||||
f.String("user", "", "单聊对端 userId")
|
||||
f.String("group", "", "群 openConversationId")
|
||||
f.Int("max-events", 0, "收到 N 条后退出")
|
||||
f.Duration("duration", 0, "运行时长上限")
|
||||
f.Bool("ephemeral", false, "退出时强制退订")
|
||||
f.String("subscribe-id", "", "复用已有订阅")
|
||||
f.String("client-id", "", "内部:覆盖凭证解析")
|
||||
_ = f.MarkHidden("client-id")
|
||||
// A flag marked required via cobra — must read required:true.
|
||||
f.String("token", "", "必填令牌")
|
||||
_ = consume.MarkFlagRequired("token")
|
||||
|
||||
stop := &cobra.Command{
|
||||
Use: "stop [subscribe_id]",
|
||||
Short: "取消订阅",
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
stop.Flags().Bool("all", false, "取消全部")
|
||||
|
||||
event := &cobra.Command{Use: "event", Short: "个人消息事件"}
|
||||
// A hidden internal subcommand — must not appear in the browse listing.
|
||||
bus := &cobra.Command{Use: "_bus", Short: "内部 bus", Hidden: true, Run: func(*cobra.Command, []string) {}}
|
||||
event.AddCommand(consume, stop, bus)
|
||||
|
||||
root.AddCommand(event)
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_LeafFlatShape(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected cobra renderer to claim the event path")
|
||||
}
|
||||
|
||||
if payload["description"] != "订阅事件流并输出到 stdout" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "event consume" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "cobra" {
|
||||
t.Fatalf("source = %v, want cobra", payload["source"])
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Inherited global flag must be excluded.
|
||||
if _, present := params["profile"]; present {
|
||||
t.Fatal("inherited --profile must not appear in a command's parameters")
|
||||
}
|
||||
// Hidden internal flag must be excluded.
|
||||
if _, present := params["client-id"]; present {
|
||||
t.Fatal("hidden --client-id must not appear")
|
||||
}
|
||||
|
||||
// Type mapping.
|
||||
if got := paramField(t, params, "user", "type"); got != "string" {
|
||||
t.Errorf("user type = %v, want string", got)
|
||||
}
|
||||
if got := paramField(t, params, "max-events", "type"); got != "integer" {
|
||||
t.Errorf("max-events type = %v, want integer", got)
|
||||
}
|
||||
if got := paramField(t, params, "duration", "type"); got != "string" {
|
||||
t.Errorf("duration type = %v, want string (CLI string like 10m)", got)
|
||||
}
|
||||
if got := paramField(t, params, "ephemeral", "type"); got != "boolean" {
|
||||
t.Errorf("ephemeral type = %v, want boolean", got)
|
||||
}
|
||||
|
||||
// Meaningful default is surfaced; zero defaults are omitted.
|
||||
if got := paramField(t, params, "format", "default"); got != "ndjson" {
|
||||
t.Errorf("format default = %v, want ndjson", got)
|
||||
}
|
||||
if _, hasDefault := params["max-events"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("max-events has a zero default (0) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["ephemeral"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("ephemeral has a zero default (false) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["duration"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("duration has a zero default (0s) — must be omitted")
|
||||
}
|
||||
|
||||
// Required annotation is honored; unmarked flags read required:false.
|
||||
if got := paramField(t, params, "token", "required"); got != true {
|
||||
t.Errorf("token required = %v, want true", got)
|
||||
}
|
||||
if got := paramField(t, params, "user", "required"); got != false {
|
||||
t.Errorf("user required = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_PositionalArguments(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, _, err := renderCobraSchema(root, "event.consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
args, _ := payload["arguments"].([]map[string]any)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("arguments = %#v, want 1 positional", payload["arguments"])
|
||||
}
|
||||
if args[0]["name"] != "event_key" {
|
||||
t.Errorf("arg name = %v, want event_key", args[0]["name"])
|
||||
}
|
||||
// [event_key] is optional syntax → required:false.
|
||||
if args[0]["required"] != false {
|
||||
t.Errorf("arg required = %v, want false", args[0]["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_DotAndSpacePathEquivalent(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
dotted, _, _ := renderCobraSchema(root, "event.consume")
|
||||
spaced, _, _ := renderCobraSchema(root, "event consume")
|
||||
if dotted["path"] != spaced["path"] || dotted["path"] != "event consume" {
|
||||
t.Fatalf("dot/space forms diverged: %v vs %v", dotted["path"], spaced["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_GroupBrowse(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
// consume + stop; hidden _bus excluded.
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("commands = %#v, want 2 (hidden _bus excluded)", cmds)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c["cli_path"] == "event _bus" {
|
||||
t.Fatal("hidden _bus must not be listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
payload, ok, err := renderCobraSchema(root, "event nope")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected unknown-subcommand error, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_NonRegisteredPathDeclined(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
if _, ok, _ := renderCobraSchema(root, "dev app create"); ok {
|
||||
t.Fatal("non-registered path must not be claimed by the cobra renderer")
|
||||
}
|
||||
if _, ok, _ := renderCobraSchema(root, "ding.message.send"); ok {
|
||||
t.Fatal("non-registered path must not be claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// paramField fetches params[<name>][<field>], failing the test if the param is
|
||||
// absent.
|
||||
func paramField(t *testing.T, params map[string]any, name, field string) any {
|
||||
t.Helper()
|
||||
p, _ := params[name].(map[string]any)
|
||||
if p == nil {
|
||||
t.Fatalf("missing param %q in %#v", name, params)
|
||||
}
|
||||
return p[field]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -136,11 +137,15 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// waitReady reads exactly one byte from pr ('R' or 'E') within ReadyTimeout.
|
||||
// pr is closed by the caller on return.
|
||||
// waitReady reads the child's ready signal within ReadyTimeout. The first
|
||||
// byte is 'R' (ready) or 'E' (error); on 'E' the child may write its real
|
||||
// error text after the byte and close the pipe, which we surface so the
|
||||
// caller sees WHY the bus failed to start (instead of an opaque
|
||||
// ErrSpawnFailed). pr is closed by the caller on return.
|
||||
func waitReady(pr *os.File) error {
|
||||
type result struct {
|
||||
b byte
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
@@ -155,6 +160,13 @@ func waitReady(pr *os.File) error {
|
||||
done <- result{err: io.ErrUnexpectedEOF}
|
||||
return
|
||||
}
|
||||
if buf[0] == 'E' {
|
||||
// Failure: read the trailing error text (bounded), which the
|
||||
// child writes right after 'E' before closing.
|
||||
rest, _ := io.ReadAll(io.LimitReader(pr, 4096))
|
||||
done <- result{b: 'E', msg: strings.TrimSpace(string(rest))}
|
||||
return
|
||||
}
|
||||
done <- result{b: buf[0]}
|
||||
}()
|
||||
select {
|
||||
@@ -169,6 +181,9 @@ func waitReady(pr *os.File) error {
|
||||
case 'R':
|
||||
return nil
|
||||
case 'E':
|
||||
if res.msg != "" {
|
||||
return fmt.Errorf("%w: %s", ErrSpawnFailed, res.msg)
|
||||
}
|
||||
return ErrSpawnFailed
|
||||
default:
|
||||
return fmt.Errorf("busctl: unexpected ready byte %q", res.b)
|
||||
|
||||
@@ -15,9 +15,11 @@ package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -246,3 +248,45 @@ func TestReadyFDFromEnv_LowFDRejected(t *testing.T) {
|
||||
t.Errorf("ReadyFDFromEnv should reject stdio fds, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady must surface the child's real startup error (written after the
|
||||
// 'E' byte) so consume shows WHY the bus failed, not an opaque message.
|
||||
func TestWaitReady_FailureSurfacesChildError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
const reason = "newPersonalStreamSource: token expired for corp dinga626"
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte{'E'})
|
||||
_, _ = io.WriteString(pw, reason)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
err = waitReady(pr)
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("error must surface the child's real reason; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A bare 'E' (no trailing text) still reports ErrSpawnFailed.
|
||||
func TestWaitReady_BareFailureByte(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
go func() { _, _ = pw.Write([]byte{'E'}); _ = pw.Close() }()
|
||||
if err := waitReady(pr); !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ type Config struct {
|
||||
// MaxEvents: stop after receiving this many events. 0 = no limit.
|
||||
MaxEvents int
|
||||
|
||||
// EventKey is the single event key being consumed. Used only for the
|
||||
// AI-subprocess contract stderr lines (`[event] ready event_key=...`).
|
||||
// Empty → the key is omitted from the marker.
|
||||
EventKey string
|
||||
|
||||
// Duration: wall-clock budget for the consume run. After this elapses,
|
||||
// Run returns nil (clean exit, exit code 0). Zero = no limit.
|
||||
//
|
||||
@@ -91,6 +96,18 @@ type Config struct {
|
||||
// Routes are pre-parsed --route specs. Empty = no routing.
|
||||
Routes []Route
|
||||
|
||||
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
|
||||
// graceful shutdown (reason: signal). This wires the AI-subprocess
|
||||
// contract — a parent closes stdin to stop the consumer. The cobra
|
||||
// layer passes os.Stdin; tests inject a controllable reader. nil →
|
||||
// stdin is not watched (backward-compatible default for callers that
|
||||
// do not opt in).
|
||||
//
|
||||
// Note: `< /dev/null` EOFs immediately and exits at once. To stay
|
||||
// resident feed a never-EOF stdin (`< <(tail -f /dev/null)`) or run
|
||||
// bounded (--max-events / --duration).
|
||||
Stdin io.Reader
|
||||
|
||||
// Stdout sink; nil → os.Stdout. Injected for tests.
|
||||
Stdout io.Writer
|
||||
// Stderr sink for status lines (HelloAck info, bye reason); nil → os.Stderr.
|
||||
@@ -132,15 +149,30 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Distinguish the exit cause for the contract's `exited` line:
|
||||
// duration deadline → timeout
|
||||
// parentCtx cancelled → signal (SIGTERM/SIGINT)
|
||||
// runCtx-only cancelled → signal (stdin EOF)
|
||||
parentCtx := ctx
|
||||
|
||||
// --duration: layer a deadline on top of caller-provided ctx. Run
|
||||
// returns nil on deadline (clean exit) rather than surfacing the
|
||||
// context.DeadlineExceeded as an error to the user.
|
||||
var timeoutCtx context.Context
|
||||
if cfg.Duration > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
defer cancel()
|
||||
ctx = timeoutCtx
|
||||
}
|
||||
|
||||
// runCtx lets the stdin watcher and the read loop share one cancel
|
||||
// without disturbing the timeout/parent contexts (so we can still tell
|
||||
// stdin-EOF from a real signal from a duration deadline).
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
ctx = runCtx
|
||||
|
||||
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: build pipeline: %w", err)
|
||||
@@ -184,19 +216,57 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr,
|
||||
"connected bus pid=%d source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.BusPID, ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
if cfg.EventKey != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
|
||||
} else {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
|
||||
}
|
||||
// Secondary diagnostic line (source/state/idle); not part of the
|
||||
// ready contract.
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
}
|
||||
|
||||
// Watch stdin for EOF → graceful shutdown (AI-subprocess contract).
|
||||
// The cobra layer only sets Stdin when the watcher should arm (a
|
||||
// pipe-style, unbounded run); tests inject it directly.
|
||||
if cfg.Stdin != nil {
|
||||
go watchStdinEOF(runCtx, cfg.Stdin, cfg.Stderr, cancelRun)
|
||||
}
|
||||
|
||||
// Exit-reason contract: on a graceful exit emit a final stderr line
|
||||
// [event] exited — received N event(s) in Xs (reason: <r>)
|
||||
// Error returns leave reason empty → no `exited` line (an `Error:` line
|
||||
// is printed by the cobra layer instead).
|
||||
received := 0
|
||||
start := time.Now()
|
||||
reason := ""
|
||||
defer func() {
|
||||
if !cfg.Quiet && reason != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
received, time.Since(start).Round(time.Millisecond), reason)
|
||||
}
|
||||
}()
|
||||
|
||||
// classifyCancel maps a context-cancelled exit to a contract reason.
|
||||
classifyCancel := func() string {
|
||||
if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
for {
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil // peer closed cleanly
|
||||
reason = "bus_shutdown" // peer closed cleanly
|
||||
return nil
|
||||
}
|
||||
if isCtxCancelled(ctx) {
|
||||
reason = classifyCancel()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: read frame: %w", err)
|
||||
@@ -219,6 +289,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "signal"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: deliver event: %w", err)
|
||||
@@ -229,14 +300,16 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "limit"
|
||||
return nil
|
||||
}
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(raw, &bye)
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr, "bus closing: %s\n", bye.Reason)
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
|
||||
}
|
||||
reason = "bus_shutdown"
|
||||
return nil
|
||||
case transport.FrameTypeSourceState:
|
||||
if !cfg.Quiet {
|
||||
@@ -269,3 +342,35 @@ func isCtxCancelled(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// watchStdinEOF reads and discards stdin until EOF (or any read error),
|
||||
// then prints a self-explaining diagnostic and calls onEOF to trigger a
|
||||
// graceful shutdown. This implements the AI-subprocess contract: a parent
|
||||
// closes the child's stdin to stop it. It returns early if ctx is
|
||||
// cancelled first (the run ended for another reason), so it does not fire
|
||||
// a spurious shutdown. errOut is io.Discard under --quiet.
|
||||
func watchStdinEOF(ctx context.Context, r io.Reader, errOut io.Writer, onEOF func()) {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
if _, err := r.Read(buf); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Run already ending; do not attribute this to stdin.
|
||||
default:
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as an exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--duration for a bounded run, "+
|
||||
"keep stdin open (`< <(tail -f /dev/null)` in a script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
onEOF()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Discard any data and keep reading until EOF.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stderr should have the connected-to-bus banner.
|
||||
if !strings.Contains(stderr.String(), "connected bus pid=") {
|
||||
t.Errorf("stderr missing connected banner:\n%s", stderr.String())
|
||||
// Stderr should carry the standardized ready marker.
|
||||
if !strings.Contains(stderr.String(), "[event] ready ") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,3 +309,156 @@ func TestRun_MultipleConsumersOneBus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI subprocess contract tests (event-subprocess-contract.md) ---
|
||||
|
||||
// T1a/T1b: the ready marker carries event_key and precedes the first
|
||||
// stdout event.
|
||||
func TestRun_ReadyMarkerContract(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
EventKey: "im.message.receive_v1", MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// T1a: ready line with event_key.
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1") {
|
||||
t.Errorf("missing ready marker with event_key:\n%s", stderr.String())
|
||||
}
|
||||
// T1b: ready is on stderr, the event is on stdout — the ready line is
|
||||
// emitted before Deliver writes the first stdout line. Assert stdout
|
||||
// got exactly the event and stderr got ready before exited.
|
||||
rIdx := strings.Index(stderr.String(), "[event] ready")
|
||||
xIdx := strings.Index(stderr.String(), "[event] exited")
|
||||
if rIdx < 0 || xIdx < 0 || rIdx > xIdx {
|
||||
t.Errorf("ready must precede exited:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "im.message.receive_v1") {
|
||||
t.Errorf("stdout missing the event:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3a: --max-events exit → reason=limit.
|
||||
func TestRun_ExitReasonLimit(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: limit") {
|
||||
t.Errorf("expected reason: limit:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3b: --duration deadline with no events → reason=timeout, exit clean.
|
||||
func TestRun_ExitReasonTimeout(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
// Bring up bus but never trigger emission → consume blocks until the
|
||||
// duration deadline fires.
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Duration: 300 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on duration: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: timeout") {
|
||||
t.Errorf("expected reason: timeout:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3c: parent ctx cancel (signal) → reason=signal.
|
||||
func TestRun_ExitReasonSignal(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(250 * time.Millisecond); cc() }()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on signal: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T2c: closing stdin triggers a graceful shutdown (reason=signal), with no
|
||||
// --max-events / --duration set.
|
||||
func TestRun_StdinEOFShutsDown(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() { time.Sleep(250 * time.Millisecond); _ = pw.Close() }() // EOF
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Stdin: pr,
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run should exit clean on stdin EOF: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not exit after stdin EOF")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal on stdin EOF:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3d: a startup failure returns non-nil and emits NO `exited` line (the
|
||||
// cobra layer prints an Error: line instead).
|
||||
func TestRun_FailureHasNoExitedLine(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
var stderr bytes.Buffer
|
||||
// Missing WorkDir/IPCEndpoint/ClientID → immediate config error, before
|
||||
// the exit-reason machinery is armed.
|
||||
err := Run(context.Background(), Config{Stderr: &stderr})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required config")
|
||||
}
|
||||
if strings.Contains(stderr.String(), "[event] exited") {
|
||||
t.Errorf("failure must not print an exited line:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
+99
-10
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1039,7 +1040,7 @@ func newChatCommand() *cobra.Command {
|
||||
Use: "chat",
|
||||
Aliases: []string{"im"},
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。`,
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -1325,7 +1326,12 @@ func newChatCommand() *cobra.Command {
|
||||
|
||||
// ── message 子命令 ────────────────────────────────────────
|
||||
|
||||
chatMessageCmd := &cobra.Command{Use: "message", Short: "会话消息管理", RunE: groupRunE}
|
||||
chatMessageCmd := &cobra.Command{
|
||||
Use: "message",
|
||||
Short: "会话消息管理",
|
||||
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -4243,6 +4249,80 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageListPinCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListPinCmd.Flags().Int("size", 0, "一次拉取的消息数量(默认 20,最大 100)")
|
||||
|
||||
// ── message favorites: 收藏/取消收藏/查询收藏消息 ──────────
|
||||
|
||||
chatMessageAddFavoriteCmd := &cobra.Command{
|
||||
Use: "add-favorite",
|
||||
Short: "收藏指定消息",
|
||||
Long: `收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作会修改当前用户的消息收藏状态。`,
|
||||
Example: ` dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "add_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageRemoveFavoriteCmd := &cobra.Command{
|
||||
Use: "remove-favorite",
|
||||
Short: "取消收藏指定消息",
|
||||
Long: `取消收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作只移除当前用户的收藏标记,不会删除原消息。`,
|
||||
Example: ` dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "remove_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageListFavoritesCmd := &cobra.Command{
|
||||
Use: "list-favorites",
|
||||
Short: "查询收藏的消息列表",
|
||||
Long: `查询当前用户收藏的消息列表,支持数字游标分页。
|
||||
|
||||
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
|
||||
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
|
||||
Example: ` dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--size must be between 1 and 100")
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
|
||||
"cursor": cursor,
|
||||
"size": strconv.Itoa(size),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
|
||||
|
||||
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
chatGroupListMyGroupsCmd := &cobra.Command{
|
||||
@@ -4927,17 +5007,26 @@ status 可选值:
|
||||
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
|
||||
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||||
return err
|
||||
name := strings.TrimSpace(mustGetFlag(cmd, "name"))
|
||||
if name == "" {
|
||||
return apperrors.NewValidation("--name must not be blank")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"title": mustGetFlag(cmd, "name"),
|
||||
"categoryName": name,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
|
||||
toolArgs["keywords"] = parseCSVValues(v)
|
||||
if cmd.Flags().Changed("keywords") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "keywords"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--keywords must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["groupNameKeywords"] = values
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("members"); v != "" {
|
||||
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
|
||||
if cmd.Flags().Changed("members") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "members"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--members must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["memberOpenDingTalkIds"] = values
|
||||
}
|
||||
return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs)
|
||||
},
|
||||
@@ -5009,7 +5098,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
|
||||
chatBotCmd.AddCommand(chatBotFindCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// 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"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatFavoritesCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatFavoritesCaller struct {
|
||||
calls []chatFavoritesCall
|
||||
}
|
||||
|
||||
func (c *chatFavoritesCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatFavoritesCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatFavoritesCaller) Format() string { return "json" }
|
||||
func (*chatFavoritesCaller) DryRun() bool { return false }
|
||||
func (*chatFavoritesCaller) Fields() string { return "" }
|
||||
func (*chatFavoritesCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatFavoritesCommand(t *testing.T, caller *chatFavoritesCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestChatFavoritesCommandsRegistered(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []string
|
||||
}{
|
||||
{name: "add-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "remove-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "list-favorites", flags: []string{"cursor", "size"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
cmd, remaining, err := root.Find([]string{"message", tt.name})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws chat message %s not registered: cmd=%v remaining=%v err=%v", tt.name, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tt.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("chat message %s: missing flag --%s", tt.name, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
toolName string
|
||||
}{
|
||||
{name: "add", command: "add-favorite", toolName: "add_message_favorite"},
|
||||
{name: "remove", command: "remove-favorite", toolName: "remove_message_favorite"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", tt.command,
|
||||
"--open-message-id", "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message %s returned error: %v", tt.command, err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: tt.toolName,
|
||||
args: map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationsRequireBothIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "add missing message", args: []string{"message", "add-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "add missing conversation", args: []string{"message", "add-favorite", "--open-message-id", "msg-1"}},
|
||||
{name: "remove missing message", args: []string{"message", "remove-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "remove missing conversation", args: []string{"message", "remove-favorite", "--open-message-id", "msg-1"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command without all required IDs returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
if err := executeChatFavoritesCommand(t, caller, "message", "list-favorites"); err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: "list_message_favorites",
|
||||
args: map[string]any{
|
||||
"cursor": int64(0),
|
||||
"size": "20",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsNegativeCursor(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--cursor", "-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--cursor must be greater than or equal to 0") {
|
||||
t.Fatalf("error = %v, want cursor validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatMessageSearchCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatMessageSearchCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
}
|
||||
|
||||
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatMessageSearchCaller) Format() string { return "json" }
|
||||
func (*chatMessageSearchCaller) DryRun() bool { return false }
|
||||
func (*chatMessageSearchCaller) Fields() string { return "" }
|
||||
func (*chatMessageSearchCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
start := "2026-07-09T00:00:00+08:00"
|
||||
end := "2026-07-11T00:00:00+08:00"
|
||||
startTime, err := time.Parse(time.RFC3339, start)
|
||||
if err != nil {
|
||||
t.Fatalf("parse start time: %v", err)
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, end)
|
||||
if err != nil {
|
||||
t.Fatalf("parse end time: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
productID string
|
||||
toolName string
|
||||
wantToolArg map[string]any
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "chat",
|
||||
toolName: "search_messages_by_keyword",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationId": "cid-1",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "im",
|
||||
toolName: "search_messages",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(tt.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != tt.productID || call.toolName != tt.toolName {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tt.wantToolArg) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatSmartCategoryCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatSmartCategoryCaller struct {
|
||||
calls []chatSmartCategoryCall
|
||||
}
|
||||
|
||||
func (c *chatSmartCategoryCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatSmartCategoryCall{
|
||||
productID: productID,
|
||||
toolName: toolName,
|
||||
args: args,
|
||||
})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatSmartCategoryCaller) Format() string { return "json" }
|
||||
func (*chatSmartCategoryCaller) DryRun() bool { return false }
|
||||
func (*chatSmartCategoryCaller) Fields() string { return "" }
|
||||
func (*chatSmartCategoryCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatCategoryCreateSmartUsesMCPParameterNames(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"category", "create-smart",
|
||||
"--name", "priority",
|
||||
"--keywords", "alpha, ,beta,",
|
||||
"--members", "open-id-1, ,open-id-2,",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "create_smart_conv_category" {
|
||||
t.Fatalf("tool call = %s/%s, want im/create_smart_conv_category", call.productID, call.toolName)
|
||||
}
|
||||
want := map[string]any{
|
||||
"categoryName": "priority",
|
||||
"groupNameKeywords": []string{"alpha", "beta"},
|
||||
"memberOpenDingTalkIds": []string{"open-id-1", "open-id-2"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartRejectsBlankInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
args: []string{"--name", ""},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "blank name",
|
||||
args: []string{"--name", " \t "},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "empty keywords",
|
||||
args: []string{"--name", "priority", "--keywords", ""},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank keywords CSV",
|
||||
args: []string{"--name", "priority", "--keywords", " , , "},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty keywords JSON array",
|
||||
args: []string{"--name", "priority", "--keywords", "[]"},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty members",
|
||||
args: []string{"--name", "priority", "--members", ""},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members CSV",
|
||||
args: []string{"--name", "priority", "--members", " , , "},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members JSON array",
|
||||
args: []string{"--name", "priority", "--members", `["", " "]`},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"category", "create-smart"}, tt.args...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool call count = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartAllowsOmittedRulesAndTrimsName(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"category", "create-smart", "--name", " priority "})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"categoryName": "priority"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,50 @@ func connectDaemonDir(dirKey string) (string, error) {
|
||||
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
|
||||
func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
|
||||
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
|
||||
func daemonExecutablePath(dir string) string {
|
||||
return filepath.Join(dir, "dws-daemon")
|
||||
}
|
||||
|
||||
// stageDaemonExecutable snapshots the current dws binary into the connector's
|
||||
// persistent directory. One-click launchers may execute a temporary download
|
||||
// (for example dws-latest) and remove it after connect --daemon returns; the
|
||||
// supervisor must not depend on that transient path for future worker restarts.
|
||||
func stageDaemonExecutable(src, dir string) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".dws-daemon-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := tmp.Chmod(daemonExecutablePerm); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(tmp, in); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dst := daemonExecutablePath(dir)
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// writeDaemonState atomically persists the daemon state to daemon-state.json
|
||||
// (persistent, survives supervisor exit) so restart/list can recover config.
|
||||
@@ -169,8 +213,9 @@ func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Du
|
||||
}
|
||||
|
||||
const (
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
daemonExecutablePerm os.FileMode = 0o700
|
||||
// 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).
|
||||
@@ -226,6 +271,10 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
exe, err = stageDaemonExecutable(exe, dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("stage daemon executable: " + err.Error())
|
||||
}
|
||||
superviseArgs := buildSuperviseArgs(os.Args[1:])
|
||||
|
||||
logPath := daemonLogPath(dir)
|
||||
@@ -386,6 +435,7 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
worker.Stdout = out
|
||||
worker.Stderr = out
|
||||
worker.Env = os.Environ()
|
||||
configureWorkerProcessGroup(worker)
|
||||
if err := worker.Start(); err != nil {
|
||||
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
|
||||
failures++
|
||||
@@ -398,6 +448,7 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
|
||||
|
||||
waitErr := superviseWait(ctx, worker)
|
||||
cleanupWorkerProcessGroup(worker.Process.Pid)
|
||||
ran := time.Since(started)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -68,6 +68,36 @@ func TestDaemonDirKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageDaemonExecutable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + "/source"
|
||||
if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := stageDaemonExecutable(src, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stageDaemonExecutable: %v", err)
|
||||
}
|
||||
if dst != daemonExecutablePath(dir) {
|
||||
t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir))
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "test-binary" {
|
||||
t.Fatalf("content = %q, want test-binary", got)
|
||||
}
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != daemonExecutablePerm {
|
||||
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
||||
got := buildWorkerArgs(in)
|
||||
|
||||
@@ -36,3 +36,17 @@ func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
}
|
||||
|
||||
// configureWorkerProcessGroup isolates the connector worker and every local
|
||||
// agent process it starts (opencode serve, codex app-server, etc.) in one group.
|
||||
func configureWorkerProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// cleanupWorkerProcessGroup removes descendants left behind when the worker
|
||||
// exits abruptly and cannot run its normal deferred cleanup.
|
||||
func cleanupWorkerProcessGroup(pid int) {
|
||||
if pid > 0 {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigureWorkerProcessGroup(t *testing.T) {
|
||||
cmd := exec.Command("true")
|
||||
configureWorkerProcessGroup(cmd)
|
||||
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
|
||||
t.Fatal("worker must start in its own process group")
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,7 @@ const daemonDetachSupported = false
|
||||
|
||||
// applyDetach is a no-op on Windows; daemon mode is rejected earlier.
|
||||
func applyDetach(_ *exec.Cmd) {}
|
||||
|
||||
func configureWorkerProcessGroup(_ *exec.Cmd) {}
|
||||
|
||||
func cleanupWorkerProcessGroup(_ int) {}
|
||||
|
||||
@@ -48,6 +48,44 @@ func pictureDownloadCode(content interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// richTextPictureDownloadCodes returns the media download codes embedded in a
|
||||
// msgtype="richText" callback, preserving the node order. DingTalk represents
|
||||
// an inline picture as a richText node instead of a top-level picture message:
|
||||
//
|
||||
// {"type":"picture","downloadCode":"...","pictureDownloadCode":"..."}
|
||||
//
|
||||
// The two code fields identify the same picture; pictureDownloadCode handles
|
||||
// their precedence and returns only one code per node.
|
||||
func richTextPictureDownloadCodes(content interface{}) []string {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items, ok := m["richText"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
codes := make([]string, 0)
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code := pictureDownloadCode(node)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
// A richText node with a picture download code is actionable even if an
|
||||
// older callback omitted type. When type is present, reject unrelated
|
||||
// node kinds so future payload fields are not mistaken for pictures.
|
||||
if typ, exists := node["type"].(string); exists && strings.TrimSpace(typ) != "" && !strings.EqualFold(strings.TrimSpace(typ), "picture") {
|
||||
continue
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// fileInboundInfo carries everything a msgtype="file" callback might expose.
|
||||
// Client-sent files (a user attaching a file in the DingTalk client) surface
|
||||
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
|
||||
|
||||
@@ -42,6 +42,44 @@ func TestPictureDownloadCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodes(t *testing.T) {
|
||||
content := map[string]interface{}{"richText": []interface{}{
|
||||
map[string]interface{}{"text": "这个问题示例图如下:"},
|
||||
map[string]interface{}{
|
||||
"pictureDownloadCode": "picture-fallback",
|
||||
"downloadCode": "image-1",
|
||||
"type": "picture",
|
||||
},
|
||||
map[string]interface{}{"text": "中间文字"},
|
||||
map[string]interface{}{"pictureDownloadCode": "image-2", "type": "picture"},
|
||||
map[string]interface{}{"downloadCode": "not-a-picture", "type": "file"},
|
||||
}}
|
||||
|
||||
got := richTextPictureDownloadCodes(content)
|
||||
want := []string{"image-1", "image-2"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodesUnknownShape(t *testing.T) {
|
||||
for _, content := range []interface{}{
|
||||
nil,
|
||||
"plain text",
|
||||
map[string]interface{}{"richText": "not-an-array"},
|
||||
map[string]interface{}{"richText": []interface{}{map[string]interface{}{"text": "only text"}}},
|
||||
} {
|
||||
if got := richTextPictureDownloadCodes(content); len(got) != 0 {
|
||||
t.Fatalf("content %v: codes = %v, want none", content, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCallbackText covers the markdown / richText fallback path used
|
||||
// when SDK data.Text.Content is empty. This is the recovery path for
|
||||
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
|
||||
|
||||
@@ -1008,7 +1008,7 @@ type connectExtras struct {
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
picCodes []string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
@@ -1038,12 +1038,12 @@ func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||||
}
|
||||
merged.text = strings.Join(lines, "\n")
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
merged.picCodes = nil
|
||||
for i := range turns {
|
||||
merged.picCodes = append(merged.picCodes, turns[i].picCodes...)
|
||||
}
|
||||
if !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
@@ -1065,9 +1065,12 @@ func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||||
|
||||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||||
if text := strings.TrimSpace(turn.text); text != "" {
|
||||
if len(turn.picCodes) > 0 {
|
||||
return text + " [同时附有图片]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
if turn.picCode != "" {
|
||||
if len(turn.picCodes) > 0 {
|
||||
return "[图片]"
|
||||
}
|
||||
if turn.fileInfo.hasActionable() {
|
||||
@@ -1117,7 +1120,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
mediaCli = newAICardClient(clientID, clientSecret, "")
|
||||
}
|
||||
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
|
||||
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
|
||||
cli := client.NewStreamClient(
|
||||
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
|
||||
@@ -1129,9 +1132,17 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
msgtype := strings.TrimSpace(data.Msgtype)
|
||||
// Picture messages carry no text — their payload is a downloadCode
|
||||
// resolved to a local file in the forward goroutine below.
|
||||
picCode := ""
|
||||
var picCodes []string
|
||||
if strings.EqualFold(msgtype, "picture") {
|
||||
picCode = pictureDownloadCode(data.Content)
|
||||
if code := pictureDownloadCode(data.Content); code != "" {
|
||||
picCodes = append(picCodes, code)
|
||||
}
|
||||
}
|
||||
// A richText callback can mix text and inline picture nodes. Scan its
|
||||
// content even when data.Text.Content is already populated; otherwise
|
||||
// the text survives while every embedded picture silently disappears.
|
||||
if strings.EqualFold(msgtype, "richText") {
|
||||
picCodes = append(picCodes, richTextPictureDownloadCodes(data.Content)...)
|
||||
}
|
||||
// File callbacks come in two shapes: client-sent files carry a
|
||||
// downloadCode; API-sent files (`dws chat message send --msg-type file
|
||||
@@ -1147,7 +1158,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
// this, `dws chat message send --group ... --text ...` — which defaults
|
||||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||||
// dead to the sender.
|
||||
if text == "" && picCode == "" {
|
||||
if text == "" && len(picCodes) == 0 {
|
||||
// interactiveCard (a bot @-mentioning this bot) nests the body in
|
||||
// content.cardContent and carries the mention as its own leading
|
||||
// leaf; the leaf-aware extractor drops it so the agent gets the
|
||||
@@ -1162,7 +1173,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
|
||||
if (text == "" && len(picCodes) == 0 && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
|
||||
// Observability: silent drops are the #1 reason a working connector
|
||||
// looks dead. Log msgtype + a payload summary so an unhandled shape
|
||||
// (e.g. new-style file callback without downloadCode) shows up in
|
||||
@@ -1198,7 +1209,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
sender = strings.TrimSpace(data.SenderStaffId)
|
||||
}
|
||||
shown := text
|
||||
if shown == "" && picCode != "" {
|
||||
if shown == "" && len(picCodes) > 0 {
|
||||
shown = "[图片]"
|
||||
} else if shown == "" && fileInfo.hasActionable() {
|
||||
shown = "[文件: " + fileInfo.FileName + "]"
|
||||
@@ -1220,7 +1231,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCode: picCode,
|
||||
picCodes: picCodes,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
@@ -1239,7 +1250,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
picCode := turn.picCode
|
||||
picCodes := turn.picCodes
|
||||
fileInfo := turn.fileInfo
|
||||
webhook := turn.webhook
|
||||
convID := turn.convID
|
||||
@@ -1293,16 +1304,21 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
// Assemble the forwarded prompt: resolve an attached picture (the
|
||||
// top Q&A inbound is an error screenshot), then knowledge-augment.
|
||||
prompt := text
|
||||
if picCode != "" {
|
||||
if localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode); derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 图片下载失败: %v\n", derr)
|
||||
for i, picCode := range picCodes {
|
||||
localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode)
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 张图片下载失败: %v\n", i+1, derr)
|
||||
if prompt == "" {
|
||||
prompt = "(用户发来一张图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)"
|
||||
}
|
||||
} else if prompt == "" {
|
||||
continue
|
||||
}
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
|
||||
} else {
|
||||
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
}
|
||||
}
|
||||
if fileInfo.hasActionable() {
|
||||
|
||||
@@ -179,6 +179,27 @@ func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsPreservesAllPictures(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "第一张", picCodes: []string{"pic-1"}, msgID: "m1"},
|
||||
{convID: "conv-1", text: "再补两张", picCodes: []string{"pic-2", "pic-3"}, msgID: "m2"},
|
||||
})
|
||||
want := []string{"pic-1", "pic-2", "pic-3"}
|
||||
if len(merged.picCodes) != len(want) {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if merged.picCodes[i] != want[i] {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"第一张 [同时附有图片]", "再补两张 [同时附有图片]"} {
|
||||
if !strings.Contains(merged.text, text) {
|
||||
t.Fatalf("merged prompt missing %q:\n%s", text, merged.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
|
||||
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
|
||||
+64
-3
@@ -795,7 +795,7 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc comment [list|create|delete] 文档评论管理
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
|
||||
@@ -1951,6 +1951,67 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentReplyCmd.Flags().Bool("emoji", false, "设为 true 时作为表情贴图回复 (默认 false)")
|
||||
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档评论",
|
||||
Long: `更新指定文档中的一条评论。
|
||||
|
||||
--comment-key 为待更新评论的唯一标识,可从 comment list、create 或 create-inline 的返回结果中获取。
|
||||
可通过 --mention 指定更新后评论中被 @ 的用户 uid 列表。`,
|
||||
Example: ` dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "已按最新数据修正"
|
||||
dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "请确认" --mention uid1,uid2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "update_comment", toolArgs)
|
||||
},
|
||||
}
|
||||
commentUpdateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
commentUpdateCmd.Flags().String("content", "", "更新后的评论文字内容,纯文本 (必填)")
|
||||
commentUpdateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除文档评论",
|
||||
Long: `删除指定文档中的一条评论。
|
||||
|
||||
这是不可恢复的危险操作。执行前需要交互确认,或在用户已明确同意后传入全局 --yes 跳过确认。`,
|
||||
Example: ` dws doc comment delete --node DOC_ID --comment-key COMMENT_KEY --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||||
return err
|
||||
}
|
||||
commentKey := mustGetFlag(cmd, "comment-key")
|
||||
if !confirmDelete("文档评论", commentKey) {
|
||||
return nil
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": commentKey,
|
||||
})
|
||||
},
|
||||
}
|
||||
commentDeleteCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
|
||||
commentCreateInlineCmd := &cobra.Command{
|
||||
Use: "create-inline",
|
||||
Short: "创建划词评论",
|
||||
@@ -2006,7 +2067,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentCreateInlineCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
// comment 子命令的 --node 隐藏别名
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd}
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd}
|
||||
for _, c := range commentNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2020,7 +2081,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd)
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type docCommentMutationCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type docCommentMutationCaller struct {
|
||||
calls []docCommentMutationCall
|
||||
}
|
||||
|
||||
func (c *docCommentMutationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, docCommentMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*docCommentMutationCaller) Format() string { return "json" }
|
||||
func (*docCommentMutationCaller) DryRun() bool { return false }
|
||||
func (*docCommentMutationCaller) Fields() string { return "" }
|
||||
func (*docCommentMutationCaller) JQ() string { return "" }
|
||||
|
||||
func executeDocCommentMutationCommand(t *testing.T, caller *docCommentMutationCaller, processArgs []string, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = processArgs
|
||||
cmd := newDocCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateDeleteCommandsRegistered(t *testing.T) {
|
||||
root := newDocCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "update", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key", "content", "mention"}},
|
||||
{path: "delete", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{"comment", tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws doc comment %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws doc comment %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--file-id", "doc-1", "--comment-key", "comment-1",
|
||||
"--content", "updated", "--mention", "uid-1, ,uid-2")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "update_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"commentKey": "comment-1",
|
||||
"content": "updated",
|
||||
"mentionedUserIds": []string{"uid-1", "uid-2"},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateOmitsMentionWhenUnset(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--node", "doc-1", "--comment-key", "comment-1", "--content", "updated")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "commentKey": "comment-1", "content": "updated"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentMutationsRejectMissingRequiredFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "update node", args: []string{"comment", "update", "--comment-key", "comment-1", "--content", "updated"}},
|
||||
{name: "update comment key", args: []string{"comment", "update", "--node", "doc-1", "--content", "updated"}},
|
||||
{name: "update content", args: []string{"comment", "update", "--node", "doc-1", "--comment-key", "comment-1"}},
|
||||
{name: "delete node", args: []string{"comment", "delete", "--comment-key", "comment-1"}},
|
||||
{name: "delete comment key", args: []string{"comment", "delete", "--node", "doc-1"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "--yes"}, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command with missing required flag returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteMapsArgumentsAfterYesConfirmation(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "comment", "delete", "--yes"},
|
||||
"comment", "delete", "--id", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment delete returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "delete_comment",
|
||||
args: map[string]any{"nodeId": "doc-1", "commentKey": "comment-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteCancellationSkipsRemoteCall(t *testing.T) {
|
||||
previousStdin := os.Stdin
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
if _, err := writer.WriteString("no\n"); err != nil {
|
||||
t.Fatalf("write confirmation: %v", err)
|
||||
}
|
||||
_ = writer.Close()
|
||||
os.Stdin = reader
|
||||
t.Cleanup(func() {
|
||||
os.Stdin = previousStdin
|
||||
_ = reader.Close()
|
||||
})
|
||||
|
||||
caller := &docCommentMutationCaller{}
|
||||
err = executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "delete", "--node", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("cancelled doc comment delete returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0 after cancellation", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCmd := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘文件管理",
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据、下载链接、创建文件夹、获取上传信息、提交上传。`,
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -872,6 +872,56 @@ func newDriveCommand() *cobra.Command {
|
||||
driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||||
driveRenameCmd.Flags().String("name", "", "新名称 (必填)")
|
||||
|
||||
driveStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "获取节点统计信息",
|
||||
Long: `获取指定节点的统计数据,包括阅读人数、阅读次数、编辑次数、评论数、点赞数、预览次数和下载次数等。
|
||||
|
||||
不同文件类型返回的统计维度可能不同。--node 支持节点 ID 或文档 URL。`,
|
||||
Example: ` dws drive stats --node <dentryUuid>
|
||||
dws drive stats --node https://alidocs.dingtalk.com/i/nodes/<dentryUuid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_node_stats", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveStatsCmd.Flags().String("node", "", "节点 ID 或文档 URL (必填)")
|
||||
|
||||
driveShortcutCmd := &cobra.Command{
|
||||
Use: "shortcut",
|
||||
Short: "为节点创建快捷方式",
|
||||
Long: `为指定源节点创建快捷方式,并放置到目标文件夹或知识库。
|
||||
|
||||
通过 --node 指定源节点。--folder 和 --workspace 均为可选;都不传时由服务端选择默认位置。
|
||||
若同时指定,--folder 是目标文件夹,--workspace 用于指定其所属知识库。`,
|
||||
Example: ` dws drive shortcut --node <dentryUuid>
|
||||
dws drive shortcut --node <dentryUuid> --folder <targetFolderId>
|
||||
dws drive shortcut --node <dentryUuid> --workspace <workspaceId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
if v := docFolderFlag(cmd); v != "" {
|
||||
if err := validateDocFolderID(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["targetFolderId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
}
|
||||
return callMCPToolOnServer("drive", "create_shortcut", toolArgs)
|
||||
},
|
||||
}
|
||||
driveShortcutCmd.Flags().String("node", "", "源节点 ID 或文档 URL (必填)")
|
||||
driveShortcutCmd.Flags().String("folder", "", "目标文件夹 nodeId (可选)")
|
||||
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
|
||||
|
||||
// ── drive permission (文档节点权限管理) ──
|
||||
drivePermissionCmd := &cobra.Command{
|
||||
Use: "permission",
|
||||
@@ -1041,7 +1091,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
}
|
||||
for _, c := range driveNodeAliasCmds {
|
||||
c.Flags().String("url", "", "")
|
||||
@@ -1240,7 +1290,7 @@ func newDriveCommand() *cobra.Command {
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
} {
|
||||
RegisterCrossProductAliases(cmd)
|
||||
@@ -1326,6 +1376,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCopyCmd,
|
||||
driveMoveCmd,
|
||||
driveRenameCmd,
|
||||
driveStatsCmd,
|
||||
driveShortcutCmd,
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type driveStatsShortcutCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type driveStatsShortcutCaller struct {
|
||||
calls []driveStatsShortcutCall
|
||||
}
|
||||
|
||||
func (c *driveStatsShortcutCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, driveStatsShortcutCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*driveStatsShortcutCaller) Format() string { return "json" }
|
||||
func (*driveStatsShortcutCaller) DryRun() bool { return false }
|
||||
func (*driveStatsShortcutCaller) Fields() string { return "" }
|
||||
func (*driveStatsShortcutCaller) JQ() string { return "" }
|
||||
|
||||
func executeDriveStatsShortcutCommand(t *testing.T, caller *driveStatsShortcutCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newDriveCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutCommandsRegistered(t *testing.T) {
|
||||
root := newDriveCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "stats", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id"}},
|
||||
{path: "shortcut", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "folder", "workspace"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws drive %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws drive %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "stats", "--url", "https://alidocs.dingtalk.com/i/nodes/node-1"); err != nil {
|
||||
t.Fatalf("drive stats returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "get_node_stats",
|
||||
args: map[string]any{"nodeId": "https://alidocs.dingtalk.com/i/nodes/node-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutMapsOptionalTargetArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller,
|
||||
"shortcut", "--file-id", "source-1", "--folder", "folder-1", "--workspace", "workspace-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "create_shortcut",
|
||||
args: map[string]any{
|
||||
"nodeId": "source-1",
|
||||
"targetFolderId": "folder-1",
|
||||
"workspaceId": "workspace-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutOmitsUnsetOptionalArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "source-1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutRejectMissingNode(t *testing.T) {
|
||||
for _, path := range []string{"stats", "shortcut"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, path); err == nil {
|
||||
t.Fatalf("drive %s without --node returned nil error", path)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("drive %s remote calls = %d, want 0", path, len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutRejectsNumericDocFolder(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1", "--folder", "123456")
|
||||
if err == nil {
|
||||
t.Fatal("drive shortcut accepted a pure numeric doc folder")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet find 在工作表中搜索单元格内容
|
||||
dws sheet append 在工作表末尾追加数据
|
||||
dws sheet csv-put 将 CSV 数据写入表格指定位置
|
||||
dws sheet table-get 读取结构化 table 数据
|
||||
dws sheet table-put 写入结构化 table 数据
|
||||
dws sheet pivot-table [list|create|update|delete] 透视表管理
|
||||
dws sheet show-gridline 显示工作表网格线
|
||||
dws sheet hide-gridline 隐藏工作表网格线
|
||||
dws sheet merge-cells 合并单元格
|
||||
dws sheet insert-dimension 在指定位置插入行或列
|
||||
dws sheet delete-dimension 删除指定位置的行或列
|
||||
@@ -106,6 +111,8 @@ func newSheetCommand() *cobra.Command {
|
||||
chartCmd := newChartCmd()
|
||||
exportCmd := newExportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
pivotTableCmd := newPivotTableCmd()
|
||||
|
||||
batchUpdateCmd := newBatchUpdateCmd()
|
||||
rangeBatchClearCmd := newRangeBatchClearCmd()
|
||||
@@ -126,6 +133,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, dimensionCmds...)
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, batchUpdateCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
@@ -140,7 +148,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
// Add all to root
|
||||
root.AddCommand(standaloneCmds...)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
|
||||
|
||||
// Guards for grouped parent commands
|
||||
attachUnknownSubcommandGuard(root)
|
||||
@@ -149,6 +157,7 @@ func newSheetCommand() *cobra.Command {
|
||||
attachUnknownSubcommandGuard(filterViewCmd)
|
||||
attachUnknownSubcommandGuard(condFormatCmd)
|
||||
attachUnknownSubcommandGuard(chartCmd)
|
||||
attachUnknownSubcommandGuard(pivotTableCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetPivotGridlineCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetPivotGridlineCaller struct {
|
||||
calls []sheetPivotGridlineCall
|
||||
}
|
||||
|
||||
func (c *sheetPivotGridlineCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetPivotGridlineCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetPivotGridlineCaller) Format() string { return "json" }
|
||||
func (*sheetPivotGridlineCaller) DryRun() bool { return false }
|
||||
func (*sheetPivotGridlineCaller) Fields() string { return "" }
|
||||
func (*sheetPivotGridlineCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetPivotGridlineCommand(t *testing.T, caller *sheetPivotGridlineCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"pivot-table", "list"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"pivot-table", "create"}, []string{"node", "source", "properties", "target-sheet-id", "target-position"}},
|
||||
{[]string{"pivot-table", "update"}, []string{"node", "sheet-id", "pivot-table-id", "properties"}},
|
||||
{[]string{"pivot-table", "delete"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"show-gridline"}, []string{"node", "sheet-id"}},
|
||||
{[]string{"hide-gridline"}, []string{"node", "sheet-id"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find(tc.path)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", strings.Join(tc.path, " "), cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws sheet %s: missing flag --%s", strings.Join(tc.path, " "), flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotListBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "list", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table list returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "list_pivot_tables",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create",
|
||||
"--node", "node1",
|
||||
"--source", "'Data'!A1:D20",
|
||||
"--properties", `{"rows":[{"field":"team"}],"values":[{"field":"amount","summarize_by":" SUM "}]}`,
|
||||
"--target-sheet-id", "summary",
|
||||
"--target-position", "B2")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table create returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "create_pivot_table",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"source": "'Data'!A1:D20",
|
||||
"targetSheetId": "summary",
|
||||
"targetPosition": "B2",
|
||||
"properties": map[string]any{
|
||||
"rows": []any{map[string]any{"field": "team"}},
|
||||
"values": []any{map[string]any{"field": "amount", "summarize_by": "sum"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateReadsPropertiesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pivot.json")
|
||||
if err := os.WriteFile(path, []byte(`{"values":[{"field":"amount","summarize_by":"average"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10", "--properties", "@"+path); err != nil {
|
||||
t.Fatalf("pivot-table create with @file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "create_pivot_table" {
|
||||
t.Fatalf("calls = %#v, want create_pivot_table", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotUpdateAllowsPartialProperties(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1",
|
||||
"--properties", `{"show_subtotals":false}`)
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table update returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
"properties": map[string]any{"show_subtotals": false},
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "update_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want update_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotDeleteBuildsToolArgsAfterConfirmation(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
os.Args = []string{"dws", "sheet", "pivot-table", "delete", "--yes"}
|
||||
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("pivot-table delete returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{"nodeId": "node1", "sheetId": "sheet1", "pivotTableId": "pivot1"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "delete_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want delete_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotRejectsInvalidPropertiesBeforeCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
props string
|
||||
wantErr string
|
||||
}{
|
||||
{"create invalid JSON", "create", `{`, "JSON 解析失败"},
|
||||
{"create missing values", "create", `{"rows":[{"field":"team"}]}`, "缺少必填字段 values"},
|
||||
{"create empty values", "create", `{"values":[]}`, "至少包含一项"},
|
||||
{"create invalid summarize", "create", `{"values":[{"field":"amount","summarize_by":"total"}]}`, "summarize_by 不支持"},
|
||||
{"create invalid row", "create", `{"rows":[{}],"values":[{"field":"amount"}]}`, "rows[0].field"},
|
||||
{"create invalid collapse", "create", `{"values":[{"field":"amount"}],"collapse":"team"}`, "collapse 必须"},
|
||||
{"update empty properties", "update", `{}`, "不能为空对象"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
args := []string{"pivot-table", tt.command, "--node", "node1", "--properties", tt.props}
|
||||
if tt.command == "create" {
|
||||
args = append(args, "--source", "'Data'!A1:B10")
|
||||
} else {
|
||||
args = append(args, "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
}
|
||||
err := executeSheetPivotGridlineCommand(t, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineRequireMandatoryFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"pivot-table", "list", "--node", "node1"},
|
||||
{"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10"},
|
||||
{"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"},
|
||||
{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1"},
|
||||
{"show-gridline", "--node", "node1"},
|
||||
{"hide-gridline", "--sheet-id", "sheet1"},
|
||||
} {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, args...); err == nil {
|
||||
t.Fatalf("dws sheet %s returned nil error", strings.Join(args, " "))
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dws sheet %s made %d remote calls, want 0", strings.Join(args, " "), len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetGridlineCommandsBuildToolArgs(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
visibility string
|
||||
}{
|
||||
{"show-gridline", "visible"},
|
||||
{"hide-gridline", "hidden"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, tt.command, "--node", "node1", "--sheet-id", "sheet1"); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.command, err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "set_gridline_visibility",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"visibility": tt.visibility,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var validPivotSummarizeBy = map[string]bool{
|
||||
"sum": true, "count": true, "average": true,
|
||||
"max": true, "min": true, "product": true,
|
||||
"count_numbers": true, "std_dev": true, "std_dev_p": true,
|
||||
"var": true, "var_p": true, "distinct": true, "median": true,
|
||||
}
|
||||
|
||||
func validatePivotTableProperties(props map[string]any, requireValues bool) error {
|
||||
valuesRaw, hasValues := props["values"]
|
||||
if requireValues && (!hasValues || valuesRaw == nil) {
|
||||
return fmt.Errorf("--properties 缺少必填字段 values")
|
||||
}
|
||||
if hasValues && valuesRaw != nil {
|
||||
values, ok := valuesRaw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("values 必须为数组")
|
||||
}
|
||||
if requireValues && len(values) == 0 {
|
||||
return fmt.Errorf("values 必须至少包含一项")
|
||||
}
|
||||
for i, item := range values {
|
||||
field, err := validatePivotField(item, fmt.Sprintf("values[%d]", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = field
|
||||
obj := item.(map[string]any)
|
||||
if raw, ok := obj["summarize_by"]; ok && raw != nil {
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("values[%d].summarize_by 必须为字符串", i)
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if !validPivotSummarizeBy[normalized] {
|
||||
return fmt.Errorf("values[%d].summarize_by 不支持: %q", i, value)
|
||||
}
|
||||
obj["summarize_by"] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"rows", "columns", "filters"} {
|
||||
if err := validatePivotFieldArray(props, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := props["collapse"]; ok && raw != nil {
|
||||
switch raw.(type) {
|
||||
case map[string]any, []any:
|
||||
default:
|
||||
return fmt.Errorf("collapse 必须为对象或数组")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePivotField(item any, path string) (string, error) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s 必须为对象", path)
|
||||
}
|
||||
raw, ok := obj["field"]
|
||||
if !ok || raw == nil {
|
||||
return "", fmt.Errorf("%s.field 为必填字段", path)
|
||||
}
|
||||
field, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(field) == "" {
|
||||
return "", fmt.Errorf("%s.field 必须为非空字符串", path)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
func validatePivotFieldArray(props map[string]any, name string) error {
|
||||
raw, ok := props[name]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须为数组", name)
|
||||
}
|
||||
for i, item := range items {
|
||||
if _, err := validatePivotField(item, fmt.Sprintf("%s[%d]", name, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPivotProperties(raw string, requireValues bool) (map[string]any, error) {
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
data, err := os.ReadFile(strings.TrimPrefix(raw, "@"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 properties 文件失败: %w", err)
|
||||
}
|
||||
raw = string(data)
|
||||
}
|
||||
var properties map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &properties); err != nil {
|
||||
return nil, fmt.Errorf("--properties JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(properties) == 0 {
|
||||
return nil, fmt.Errorf("--properties 不能为空对象")
|
||||
}
|
||||
if err := validatePivotTableProperties(properties, requireValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return properties, nil
|
||||
}
|
||||
|
||||
func newPivotTableCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "pivot-table",
|
||||
Short: "透视表管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取透视表列表或详情",
|
||||
Example: ` dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID
|
||||
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("pivot-table-id"); value != "" {
|
||||
args["pivotTableId"] = value
|
||||
}
|
||||
return callMCPTool("list_pivot_tables", args)
|
||||
},
|
||||
}
|
||||
listCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
listCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
listCmd.Flags().String("pivot-table-id", "", "透视表 ID (可选,不传则返回全部)")
|
||||
|
||||
createCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建透视表",
|
||||
Long: `在指定数据源区域上创建原生透视表。
|
||||
|
||||
--source 必须使用带工作表前缀的 A1 范围,例如 "'Sheet1'!A1:D100"。
|
||||
--properties 为 JSON 对象或 @file,values 至少一项;可包含 rows、columns、filters、collapse 和总计显示选项。`,
|
||||
Example: ` dws sheet pivot-table create --node NODE_ID \
|
||||
--source "'Sheet1'!A1:D100" \
|
||||
--properties '{"rows":[{"field":"部门"}],"values":[{"field":"销售额","summarize_by":"sum"}]}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "source", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"source": mustGetFlag(cmd, "source"),
|
||||
"properties": properties,
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-sheet-id"); value != "" {
|
||||
args["targetSheetId"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-position"); value != "" {
|
||||
args["targetPosition"] = value
|
||||
}
|
||||
return callMCPTool("create_pivot_table", args)
|
||||
},
|
||||
}
|
||||
createCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
createCmd.Flags().String("source", "", "数据源区域,A1 表示法且包含工作表前缀 (必填)")
|
||||
createCmd.Flags().String("properties", "", "透视表配置 JSON 或 @文件路径 (必填)")
|
||||
createCmd.Flags().String("target-sheet-id", "", "目标工作表 ID 或名称 (可选,不传则自动新建)")
|
||||
createCmd.Flags().String("target-position", "", "透视表放置位置,A1 单元格地址 (可选)")
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新透视表配置",
|
||||
Example: ` dws sheet pivot-table update --node NODE_ID --sheet-id SHEET_ID \
|
||||
--pivot-table-id PT_ID --properties '{"show_subtotals":false}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("update_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": mustGetFlag(cmd, "pivot-table-id"),
|
||||
"properties": properties,
|
||||
})
|
||||
},
|
||||
}
|
||||
updateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
updateCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
updateCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
updateCmd.Flags().String("properties", "", "需要更新的透视表配置 JSON 或 @文件路径 (必填)")
|
||||
|
||||
deleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "[危险] 删除透视表",
|
||||
Example: ` dws sheet pivot-table delete --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID --yes`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
pivotTableID := mustGetFlag(cmd, "pivot-table-id")
|
||||
if !confirmDelete("透视表", pivotTableID) {
|
||||
return nil
|
||||
}
|
||||
return callMCPTool("delete_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": pivotTableID,
|
||||
})
|
||||
},
|
||||
}
|
||||
deleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
|
||||
root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd)
|
||||
attachUnknownSubcommandGuard(root)
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTableCmds() []*cobra.Command {
|
||||
tableGetCmd := &cobra.Command{
|
||||
Use: "table-get",
|
||||
Aliases: []string{"table-read"},
|
||||
Short: "读取结构化 table 数据",
|
||||
Long: `读取结构化 table 数据。
|
||||
|
||||
返回列名、行数据、pandas-style dtypes 和表格 number formats。`,
|
||||
Example: ` dws sheet table-get --node NODE_ID
|
||||
dws sheet table-get --node NODE_ID --sheet-id SHEET_ID --range A1:D20
|
||||
dws sheet table-get --node NODE_ID --sheet-id Sheet1 --no-header`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
|
||||
toolArgs["sheetId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("range"); v != "" {
|
||||
toolArgs["range"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("no-header") {
|
||||
v, _ := cmd.Flags().GetBool("no-header")
|
||||
toolArgs["noHeader"] = v
|
||||
}
|
||||
return callMCPTool("table_get", toolArgs)
|
||||
},
|
||||
}
|
||||
tableGetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tableGetCmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
|
||||
tableGetCmd.Flags().String("range", "", "读取范围,A1 表示法;可带 sheet 前缀,如 Sheet1!A1:D10")
|
||||
tableGetCmd.Flags().Bool("no-header", false, "首行不作为表头,自动生成 col1/col2/...")
|
||||
|
||||
tablePutCmd := &cobra.Command{
|
||||
Use: "table-put",
|
||||
Aliases: []string{"table-write"},
|
||||
Short: "写入结构化 table 数据",
|
||||
Long: `写入结构化 table 数据。
|
||||
|
||||
支持一次写入一个或多个 sheet;目标 sheet 存在时写入,传 name 且不存在时会创建 sheet。
|
||||
|
||||
--sheets 支持三种输入:
|
||||
JSON 数组 [{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]
|
||||
JSON 对象 {"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}
|
||||
单个 sheet spec JSON 对象 {"name":"Sheet1","columns":["name"],"data":[["Alice"]]}
|
||||
|
||||
--sheets 可传 @filepath 从文件读取,或传 - 从 stdin 读取。`,
|
||||
Example: ` dws sheet table-put --node NODE_ID \
|
||||
--sheets '[{"name":"Sheet1","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]'
|
||||
|
||||
dws sheet table-put --node NODE_ID --sheets @table.json
|
||||
cat table.json | dws sheet table-put --node NODE_ID --sheets -`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheets"); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readTableJSONFlag(cmd, "sheets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets, err := parseTablePutSheets(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("table_put", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheets": sheets,
|
||||
})
|
||||
},
|
||||
}
|
||||
tablePutCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tablePutCmd.Flags().String("sheets", "", "sheet table JSON、@文件路径 或 - 表示 stdin (必填)")
|
||||
|
||||
return []*cobra.Command{tableGetCmd, tablePutCmd}
|
||||
}
|
||||
|
||||
func readTableJSONFlag(cmd *cobra.Command, flagName string) (string, error) {
|
||||
value := mustGetFlag(cmd, flagName)
|
||||
switch {
|
||||
case value == "-":
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 stdin 失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
case strings.HasPrefix(value, "@"):
|
||||
data, err := os.ReadFile(strings.TrimPrefix(value, "@"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 JSON 文件失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseTablePutSheets(raw string) ([]any, error) {
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err)
|
||||
}
|
||||
|
||||
switch value := payload.(type) {
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
return nil, fmt.Errorf("--sheets must contain at least one sheet spec")
|
||||
}
|
||||
return value, nil
|
||||
case map[string]any:
|
||||
if sheets, ok := value["sheets"]; ok {
|
||||
items, ok := sheets.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--sheets.sheets must be a JSON array")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("--sheets.sheets must contain at least one sheet spec")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return []any{value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("--sheets must be a JSON array, an object with sheets, or a single sheet spec object")
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetTableCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetTableCaller struct {
|
||||
calls []sheetTableCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *sheetTableCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetTableCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetTableCaller) Format() string { return "json" }
|
||||
func (c *sheetTableCaller) DryRun() bool { return c.dryRun }
|
||||
func (*sheetTableCaller) Fields() string { return "" }
|
||||
func (*sheetTableCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetTableCommand(t *testing.T, caller *sheetTableCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if stdin != nil {
|
||||
root.SetIn(stdin)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetTableCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
for _, path := range []string{"table-get", "table-put", "table-read", "table-write"} {
|
||||
cmd, remaining, err := root.Find([]string{path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", path, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
if cmd, remaining, _ := root.Find([]string{"table"}); cmd != nil && len(remaining) == 0 {
|
||||
t.Fatal("dws sheet table parent should not be registered")
|
||||
}
|
||||
|
||||
getCmd, _, _ := root.Find([]string{"table-get"})
|
||||
for _, flag := range []string{"node", "sheet-id", "range", "no-header"} {
|
||||
if getCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-get: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
putCmd, _, _ := root.Find([]string{"table-put"})
|
||||
for _, flag := range []string{"node", "sheets"} {
|
||||
if putCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-put: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
err := executeSheetTableCommand(t, caller, nil,
|
||||
"table-get", "--node", "node1", "--sheet-id", "sheet1", "--range", "A1:B2", "--no-header")
|
||||
if err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := sheetTableCall{
|
||||
productID: "sheet",
|
||||
toolName: "table_get",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"range": "A1:B2",
|
||||
"noHeader": true,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetOmitsUnsetOptionalArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "node1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetPreservesExplicitFalseNoHeader(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1", "--no-header=false"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
if got, ok := caller.calls[0].args["noHeader"]; !ok || got != false {
|
||||
t.Fatalf("noHeader = %#v, present=%v; want explicit false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetRequiresNodeBeforeCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get"); err == nil {
|
||||
t.Fatal("table-get without --node returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutNormalizesAcceptedInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "array", input: `[ {"name":"Sheet1","columns":["name"],"data":[["Alice"]]} ]`},
|
||||
{name: "wrapped", input: `{"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}`},
|
||||
{name: "single", input: `{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", tt.input); err != nil {
|
||||
t.Fatalf("table-put returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "sheet" || call.toolName != "table_put" || call.args["nodeId"] != "node1" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
sheets, ok := call.args["sheets"].([]any)
|
||||
if !ok || len(sheets) != 1 {
|
||||
t.Fatalf("sheets = %#v, want one-element array", call.args["sheets"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutReadsStdinAndFile(t *testing.T) {
|
||||
payload := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
|
||||
t.Run("stdin", func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, strings.NewReader(payload), "table-put", "--node", "node1", "--sheets", "-"); err != nil {
|
||||
t.Fatalf("table-put stdin returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "table.json")
|
||||
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write table JSON: %v", err)
|
||||
}
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", "@"+path); err != nil {
|
||||
t.Fatalf("table-put file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSheetTablePutRejectsInvalidInputBeforeCall(t *testing.T) {
|
||||
for _, input := range []string{`not-json`, `123`, `[]`, `{"sheets":{}}`, `{"sheets":[]}`} {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err == nil {
|
||||
t.Errorf("input %q returned nil error", input)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("input %q made %d remote calls, want 0", input, len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutDryRunSkipsRemoteCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{dryRun: true}
|
||||
input := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err != nil {
|
||||
t.Fatalf("table-put dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchUpdateRejectsTableCommands(t *testing.T) {
|
||||
for _, toolName := range []string{"table-get", "table-put"} {
|
||||
_, err := translateBatchOp(map[string]any{
|
||||
"toolName": toolName,
|
||||
"input": map[string]any{"sheet-id": "sheet1"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), toolName) {
|
||||
t.Errorf("translateBatchOp(%q) error = %v, want rejection naming command", toolName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,5 +261,29 @@ name 不能包含 / \ ? * [ ] : 等特殊字符,最长 100 字符。`,
|
||||
deleteSheetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteSheetCmd.Flags().String("sheet-id", "", "要删除的工作表 ID 或名称 (必填)")
|
||||
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd}
|
||||
showGridlineCmd := newGridlineVisibilityCmd("show-gridline", "显示工作表网格线", "visible")
|
||||
hideGridlineCmd := newGridlineVisibilityCmd("hide-gridline", "隐藏工作表网格线", "hidden")
|
||||
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd, showGridlineCmd, hideGridlineCmd}
|
||||
}
|
||||
|
||||
func newGridlineVisibilityCmd(use, short, visibility string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Example: fmt.Sprintf(" dws sheet %s --node NODE_ID --sheet-id SHEET_ID", use),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("set_gridline_visibility", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"visibility": visibility,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@ func TestWukongSyncDocCommands(t *testing.T) {
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
tableGetCmd := requireWukongSyncCommand(t, root, "table-get")
|
||||
requireWukongSyncFlags(t, tableGetCmd, "node", "sheet-id", "range", "no-header")
|
||||
|
||||
tablePutCmd := requireWukongSyncCommand(t, root, "table-put")
|
||||
requireWukongSyncFlags(t, tablePutCmd, "node", "sheets")
|
||||
|
||||
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
|
||||
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
@@ -117,6 +118,7 @@
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
@@ -117,6 +118,7 @@
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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 darwin || linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func authTokenCiphertextPaths(service string) ([]string, error) {
|
||||
dir := StorageDir(service)
|
||||
dirEntries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read keychain storage: %w", err)
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(dirEntries))
|
||||
for _, dirEntry := range dirEntries {
|
||||
if !isAuthTokenCiphertextFile(dirEntry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect keychain entry %q: %w", dirEntry.Name(), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("keychain entry %q is not a regular file", dirEntry.Name())
|
||||
}
|
||||
paths = append(paths, filepath.Join(dir, dirEntry.Name()))
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func isAuthTokenCiphertextFile(name string) bool {
|
||||
legacyName := safeFileName(AccountToken)
|
||||
return name == legacyName ||
|
||||
(strings.HasPrefix(name, strings.TrimSuffix(legacyName, ".enc")+"_") && strings.HasSuffix(name, ".enc"))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 keychain
|
||||
|
||||
// Windows stores token entries in the DPAPI-protected user registry rather
|
||||
// than auth-token*.enc files, so there is no file inventory to validate.
|
||||
func platformValidateAuthTokenEntries(string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -42,10 +42,16 @@ const (
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
var ErrDEKMissing = errors.New("dek missing")
|
||||
var (
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// ErrCiphertextKeyMismatch means encrypted data exists but none of the
|
||||
// available DEKs can decrypt it. Write paths must not overwrite the data.
|
||||
ErrCiphertextKeyMismatch = errors.New("ciphertext key mismatch")
|
||||
)
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
type KeychainAccess interface {
|
||||
@@ -108,6 +114,10 @@ func IsDEKMissing(err error) bool {
|
||||
return errors.Is(err, ErrDEKMissing)
|
||||
}
|
||||
|
||||
func IsCiphertextKeyMismatch(err error) bool {
|
||||
return errors.Is(err, ErrCiphertextKeyMismatch)
|
||||
}
|
||||
|
||||
func Diagnose() Diagnostic {
|
||||
return platformDiagnose()
|
||||
}
|
||||
@@ -129,6 +139,21 @@ func Remove(service, account string) error {
|
||||
return platformRemove(service, account)
|
||||
}
|
||||
|
||||
// MigrateToFileDEK re-encrypts the legacy and profile-scoped auth token entries
|
||||
// for service with the macOS file DEK. It is supported only on macOS and must
|
||||
// be invoked from a process that can still access the system Keychain. When
|
||||
// dryRun is true, all selected entries are validated without modifying data.
|
||||
func MigrateToFileDEK(service string, dryRun bool) (int, error) {
|
||||
return platformMigrateToFileDEK(service, dryRun)
|
||||
}
|
||||
|
||||
// ValidateAuthTokenEntries verifies every persisted auth-token ciphertext,
|
||||
// including profile slots not yet registered in profiles.json, without
|
||||
// creating or rotating key material.
|
||||
func ValidateAuthTokenEntries(service string) error {
|
||||
return platformValidateAuthTokenEntries(service)
|
||||
}
|
||||
|
||||
// Exists checks if an entry exists in the keychain.
|
||||
func Exists(service, account string) bool {
|
||||
val, err := Get(service, account)
|
||||
|
||||
@@ -165,19 +165,7 @@ func platformDiagnose() Diagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
func getSystemDEKReadOnly(service string) ([]byte, error) {
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -320,6 +308,71 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// decryptWithAvailableDEK decrypts an existing entry without creating or
|
||||
// migrating key material. In normal macOS mode, entries previously written by
|
||||
// the explicit file-DEK fallback remain readable so another local process
|
||||
// cannot replace them with ciphertext encrypted by a different key.
|
||||
func decryptWithAvailableDEK(service string, data []byte) (string, []byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
key, err := fileDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: file-DEK cannot decrypt existing entry", ErrCiphertextKeyMismatch)
|
||||
}
|
||||
return plaintext, key, nil
|
||||
}
|
||||
|
||||
systemKey, systemKeyErr := getSystemDEKReadOnly(service)
|
||||
if IsUnavailable(systemKeyErr) {
|
||||
return "", nil, systemKeyErr
|
||||
}
|
||||
if systemKeyErr == nil {
|
||||
if plaintext, err := decryptData(data, systemKey); err == nil {
|
||||
return plaintext, systemKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
fileKey, fileKeyErr := fileDEKReadOnly(service)
|
||||
if fileKeyErr == nil {
|
||||
if plaintext, err := decryptData(data, fileKey); err == nil {
|
||||
return plaintext, fileKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
if systemKeyErr != nil && fileKeyErr != nil {
|
||||
return "", nil, systemKeyErr
|
||||
}
|
||||
return "", nil, fmt.Errorf("%w: available DEKs cannot decrypt existing entry", ErrCiphertextKeyMismatch)
|
||||
}
|
||||
|
||||
// keyForNewEntry preserves the backend used by the canonical auth-token entry
|
||||
// when a related account is added from a normal macOS process. This prevents
|
||||
// profile-scoped token slots from mixing DEK backends without allowing an
|
||||
// unrelated file-backed secret to downgrade a system-Keychain-backed login.
|
||||
func keyForNewEntry(service, account string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
anchorPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
|
||||
anchor, err := os.ReadFile(anchorPath)
|
||||
if err == nil {
|
||||
_, key, decryptErr := decryptWithAvailableDEK(service, anchor)
|
||||
return key, decryptErr
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
@@ -328,11 +381,7 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
plaintext, _, err := decryptWithAvailableDEK(service, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -340,11 +389,26 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getOrCreateDEK(service)
|
||||
dir := StorageDir(service)
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
|
||||
var (
|
||||
key []byte
|
||||
err error
|
||||
)
|
||||
existing, readErr := os.ReadFile(targetPath)
|
||||
switch {
|
||||
case readErr == nil:
|
||||
_, key, err = decryptWithAvailableDEK(service, existing)
|
||||
case os.IsNotExist(readErr):
|
||||
key, err = keyForNewEntry(service, account)
|
||||
default:
|
||||
return readErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := StorageDir(service)
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,7 +417,6 @@ func platformSet(service, account, data string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
@@ -368,6 +431,23 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformValidateAuthTokenEntries(service string) error {
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
if _, _, err := decryptWithAvailableDEK(service, ciphertext); err != nil {
|
||||
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -222,3 +224,215 @@ func TestDisableKeychainOverwrite(t *testing.T) {
|
||||
t.Fatalf("Get() = %q, want %q", got, "overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func stubMacOSSystemDEK(t *testing.T, systemDEK []byte, getErr error) *int {
|
||||
t.Helper()
|
||||
|
||||
defaultKeychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(defaultKeychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + defaultKeychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
if account != "dek" {
|
||||
return "", errors.New("unexpected keyring account")
|
||||
}
|
||||
if getErr != nil {
|
||||
return "", getErr
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(systemDEK), nil
|
||||
}
|
||||
setCalls := 0
|
||||
keyringSet = func(service, account, value string) error {
|
||||
setCalls++
|
||||
return errors.New("unexpected keyring.Set")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
return &setCalls
|
||||
}
|
||||
|
||||
func TestSystemKeychainModePreservesFileDEKCredentials(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
setCalls := stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-dek-mode-switch"
|
||||
account := "auth-token"
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-dek-token"); err != nil {
|
||||
t.Fatalf("Set() in file-DEK mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() after switching to system Keychain mode error = %v", err)
|
||||
}
|
||||
if got != "file-dek-token" {
|
||||
t.Fatalf("Get() after switching modes = %q, want file-dek-token", got)
|
||||
}
|
||||
if err := Set(service, account, "updated-token"); err != nil {
|
||||
t.Fatalf("Set() after switching to system Keychain mode error = %v", err)
|
||||
}
|
||||
profileAccount := account + ":corp"
|
||||
if err := Set(service, profileAccount, "profile-token"); err != nil {
|
||||
t.Fatalf("Set() new profile account in system Keychain mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
got, err = Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() after switching back to file-DEK mode error = %v", err)
|
||||
}
|
||||
if got != "updated-token" {
|
||||
t.Fatalf("Get() after switching back = %q, want updated-token", got)
|
||||
}
|
||||
got, err = Get(service, profileAccount)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() new profile account in file-DEK mode error = %v", err)
|
||||
}
|
||||
if got != "profile-token" {
|
||||
t.Fatalf("Get() new profile account = %q, want profile-token", got)
|
||||
}
|
||||
if *setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemKeychainModeFallsBackWhenSystemDEKIsMissing(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
setCalls := stubMacOSSystemDEK(t, nil, keyringpkg.ErrNotFound)
|
||||
|
||||
service := "test-missing-system-dek-fallback"
|
||||
account := "auth-token"
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-dek-token"); err != nil {
|
||||
t.Fatalf("Set() in file-DEK mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() with missing system DEK error = %v", err)
|
||||
}
|
||||
if got != "file-dek-token" {
|
||||
t.Fatalf("Get() with missing system DEK = %q, want file-dek-token", got)
|
||||
}
|
||||
if *setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDEKModeDoesNotOverwriteSystemKeychainCredentials(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-system-ciphertext-preserved"
|
||||
account := "auth-token"
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
if err := Set(service, account, "system-token"); err != nil {
|
||||
t.Fatalf("Set() in system Keychain mode error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
|
||||
t.Fatalf("system Keychain Set created file DEK; stat error = %v", err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
before, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext) error = %v", err)
|
||||
}
|
||||
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
|
||||
if err := os.WriteFile(filepath.Join(StorageDir(service), "dek"), fileDEK, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(file DEK) error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-token"); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("Set() error = %v, want ciphertext key mismatch", err)
|
||||
}
|
||||
after, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("rejected file-DEK Set overwrote system Keychain ciphertext")
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() in system Keychain mode error = %v", err)
|
||||
}
|
||||
if got != "system-token" {
|
||||
t.Fatalf("Get() after rejected overwrite = %q, want system-token", got)
|
||||
}
|
||||
|
||||
profileAccount := account + ":corp"
|
||||
if err := Set(service, profileAccount, "system-profile-token"); err != nil {
|
||||
t.Fatalf("Set() new profile beside system-backed root error = %v", err)
|
||||
}
|
||||
got, err = Get(service, profileAccount)
|
||||
if err != nil || got != "system-profile-token" {
|
||||
t.Fatalf("Get() new system-backed profile = %q, %v", got, err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if _, err := Get(service, profileAccount); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("file-DEK Get() of system-backed profile error = %v, want ciphertext key mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDoesNotOverwriteCiphertextWithUnknownDEK(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-unknown-ciphertext-key"
|
||||
account := "auth-token"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
|
||||
if err := os.WriteFile(filepath.Join(dir, "dek"), fileDEK, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(file DEK) error = %v", err)
|
||||
}
|
||||
unknownDEK := bytes.Repeat([]byte{0x33}, dekBytes)
|
||||
ciphertext, err := encryptData("unknown-token", unknownDEK)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
if err := os.WriteFile(targetPath, ciphertext, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
for _, disableKeychain := range []string{"", "1"} {
|
||||
t.Setenv(DisableKeychainEnv, disableKeychain)
|
||||
if err := Set(service, account, "replacement"); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("Set() with %s=%q error = %v, want ciphertext key mismatch", DisableKeychainEnv, disableKeychain, err)
|
||||
}
|
||||
after, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, ciphertext) {
|
||||
t.Fatalf("rejected Set with %s=%q overwrote ciphertext", DisableKeychainEnv, disableKeychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,27 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformValidateAuthTokenEntries(service string) error {
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil || len(paths) == 0 {
|
||||
return err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read DEK for auth token validation: %w", err)
|
||||
}
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
if _, err := decryptData(ciphertext, key); err != nil {
|
||||
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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 darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fileDEKMigrationEntry struct {
|
||||
path string
|
||||
plaintext string
|
||||
encrypted []byte
|
||||
}
|
||||
|
||||
func platformMigrateToFileDEK(service string, dryRun bool) (int, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return 0, fmt.Errorf("file-DEK migration requires system Keychain mode; unset %s and retry", DisableKeychainEnv)
|
||||
}
|
||||
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
entries := make([]fileDEKMigrationEntry, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
plaintext, _, err := decryptWithAvailableDEK(service, ciphertext)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("validate keychain entry %q before migration: %w", filepath.Base(path), err)
|
||||
}
|
||||
entries = append(entries, fileDEKMigrationEntry{path: path, plaintext: plaintext})
|
||||
}
|
||||
if dryRun || len(entries) == 0 {
|
||||
return len(entries), nil
|
||||
}
|
||||
|
||||
fileKey, err := fileDEK(service)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prepare file DEK: %w", err)
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].encrypted, err = encryptData(entries[i].plaintext, fileKey)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encrypt keychain entry %q: %w", filepath.Base(entries[i].path), err)
|
||||
}
|
||||
if _, err := decryptData(entries[i].encrypted, fileKey); err != nil {
|
||||
return 0, fmt.Errorf("verify migrated keychain entry %q: %w", filepath.Base(entries[i].path), err)
|
||||
}
|
||||
}
|
||||
|
||||
tempPaths := make([]string, 0, len(entries))
|
||||
defer func() {
|
||||
for _, path := range tempPaths {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
for _, entry := range entries {
|
||||
tmpPath := entry.path + "." + uuid.New().String() + ".migrate.tmp"
|
||||
if err := os.WriteFile(tmpPath, entry.encrypted, 0600); err != nil {
|
||||
return 0, fmt.Errorf("stage keychain entry %q: %w", filepath.Base(entry.path), err)
|
||||
}
|
||||
tempPaths = append(tempPaths, tmpPath)
|
||||
}
|
||||
for i, entry := range entries {
|
||||
if err := os.Rename(tempPaths[i], entry.path); err != nil {
|
||||
return 0, fmt.Errorf("commit keychain entry %q: %w; rerun the migration to finish", filepath.Base(entry.path), err)
|
||||
}
|
||||
tempPaths[i] = ""
|
||||
}
|
||||
return len(entries), nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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 darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateToFileDEKReencryptsSystemEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x42}, dekBytes), nil)
|
||||
|
||||
service := "test-migrate-system-to-file"
|
||||
values := map[string]string{
|
||||
AccountToken: "legacy-token",
|
||||
AccountToken + ":corp-a": "profile-token",
|
||||
}
|
||||
if err := Set(service, "appsecret_demo", "app-secret"); err != nil {
|
||||
t.Fatalf("Set(unrelated secret) error = %v", err)
|
||||
}
|
||||
unrelatedPath := filepath.Join(StorageDir(service), safeFileName("appsecret_demo"))
|
||||
unrelatedCiphertext := []byte("unreadable unrelated ciphertext")
|
||||
if err := os.WriteFile(unrelatedPath, unrelatedCiphertext, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(unrelated secret) error = %v", err)
|
||||
}
|
||||
for account, value := range values {
|
||||
if err := Set(service, account, value); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", account, err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := MigrateToFileDEK(service, true)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateToFileDEK(dry-run) error = %v", err)
|
||||
}
|
||||
if count != len(values) {
|
||||
t.Fatalf("dry-run count = %d, want %d", count, len(values))
|
||||
}
|
||||
dekPath := filepath.Join(StorageDir(service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run created file DEK; stat error = %v", err)
|
||||
}
|
||||
|
||||
count, err = MigrateToFileDEK(service, false)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
||||
}
|
||||
if count != len(values) {
|
||||
t.Fatalf("migration count = %d, want %d", count, len(values))
|
||||
}
|
||||
if info, err := os.Stat(dekPath); err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("file DEK stat = %v, %v; want mode 0600", info, err)
|
||||
}
|
||||
|
||||
for _, disableKeychain := range []string{"1", ""} {
|
||||
t.Setenv(DisableKeychainEnv, disableKeychain)
|
||||
for account, want := range values {
|
||||
got, err := Get(service, account)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("Get(%q) with %s=%q = %q, %v; want %q", account, DisableKeychainEnv, disableKeychain, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count, err := MigrateToFileDEK(service, true); err != nil || count != len(values) {
|
||||
t.Fatalf("repeat dry-run = %d, %v; want %d auth entries", count, err, len(values))
|
||||
}
|
||||
if got, err := os.ReadFile(unrelatedPath); err != nil || !bytes.Equal(got, unrelatedCiphertext) {
|
||||
t.Fatalf("unrelated secret after migration = %q, %v; want byte-for-byte preserved", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKKeepsNewNonAuthSecretsOnSystemKeychain(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
systemDEK := bytes.Repeat([]byte{0x52}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-migrate-secret-isolation"
|
||||
if err := Set(service, AccountToken, "system-token"); err != nil {
|
||||
t.Fatalf("Set(auth token) error = %v", err)
|
||||
}
|
||||
if _, err := MigrateToFileDEK(service, false); err != nil {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
||||
}
|
||||
|
||||
nonAuthEntries := map[string]string{
|
||||
"client-secret:demo": "client-secret",
|
||||
"app-token:demo": "app-token",
|
||||
"appsecret:demo": "stored-secret",
|
||||
}
|
||||
for account, value := range nonAuthEntries {
|
||||
if err := Set(service, account, value); err != nil {
|
||||
t.Fatalf("Set(%q) after migration error = %v", account, err)
|
||||
}
|
||||
ciphertext, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", account, err)
|
||||
}
|
||||
got, err := decryptData(ciphertext, systemDEK)
|
||||
if err != nil || got != value {
|
||||
t.Fatalf("system Keychain DEK decrypt %q = %q, %v; want %q", account, got, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if got, err := Get(service, AccountToken); err != nil || got != "system-token" {
|
||||
t.Fatalf("file-DEK Get(auth token) = %q, %v; want migrated token", got, err)
|
||||
}
|
||||
for account := range nonAuthEntries {
|
||||
if got, err := Get(service, account); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("file-DEK Get(%q) = %q, %v; want ciphertext key mismatch", account, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKAbortsBeforeWritingWhenAnyEntryIsUnreadable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x24}, dekBytes), nil)
|
||||
|
||||
service := "test-migrate-preflight"
|
||||
if err := Set(service, AccountToken, "preserve-me"); err != nil {
|
||||
t.Fatalf("Set() error = %v", err)
|
||||
}
|
||||
goodPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
|
||||
goodBefore, err := os.ReadFile(goodPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(good entry) error = %v", err)
|
||||
}
|
||||
badPath := filepath.Join(StorageDir(service), safeFileName(AccountToken+":bad"))
|
||||
if err := os.WriteFile(badPath, []byte("corrupt ciphertext"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(bad entry) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := MigrateToFileDEK(service, false); err == nil || !strings.Contains(err.Error(), "validate keychain entry") {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v, want preflight failure", err)
|
||||
}
|
||||
goodAfter, err := os.ReadFile(goodPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(good entry after failure) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(goodAfter, goodBefore) {
|
||||
t.Fatal("failed migration modified a readable entry")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed migration created file DEK; stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKRequiresSystemKeychainMode(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if _, err := MigrateToFileDEK("test-migrate-mode", true); err == nil || !strings.Contains(err.Error(), DisableKeychainEnv) {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v, want mode guidance", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import "fmt"
|
||||
|
||||
func platformMigrateToFileDEK(_ string, _ bool) (int, error) {
|
||||
return 0, fmt.Errorf("system Keychain to file-DEK migration is supported only on macOS")
|
||||
}
|
||||
@@ -416,7 +416,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
}
|
||||
callStart := time.Now()
|
||||
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body)
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body, request.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -493,15 +493,19 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte, method string) (*http.Response, error) {
|
||||
endpoint = sanitizeJSONRPCEndpoint(endpoint)
|
||||
operation := strings.TrimSpace(method)
|
||||
if operation == "" {
|
||||
operation = "jsonrpc"
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"failed to create JSON-RPC request",
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("request_build_failed"),
|
||||
apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
@@ -559,37 +563,52 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
statusForLog = resp.StatusCode
|
||||
}
|
||||
delay := c.retryDelayForAttempt(attempt, retryAfter)
|
||||
logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
logging.LogRetryAttempt(c.FileLogger, operation, c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
if err := c.sleepForRetry(ctx, delay); err != nil {
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"request cancelled during retry",
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("request_cancelled"),
|
||||
apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: err,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if operation == "tools/call" {
|
||||
opts = append(opts, apperrors.WithActions(networkActions("")...))
|
||||
return nil, apperrors.NewAPI("request cancelled during retry", opts...)
|
||||
}
|
||||
opts = append(opts, apperrors.WithActions(discoveryActions("")...))
|
||||
return nil, apperrors.NewDiscovery("request cancelled during retry", opts...)
|
||||
}
|
||||
}
|
||||
}
|
||||
reason, hint := classifyRequestFailure(lastErr)
|
||||
logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId,
|
||||
string(apperrors.CategoryDiscovery), reason, 0, 0,
|
||||
category := apperrors.CategoryDiscovery
|
||||
actions := discoveryActions("")
|
||||
if operation == "tools/call" {
|
||||
category = apperrors.CategoryAPI
|
||||
actions = networkActions("")
|
||||
}
|
||||
logging.LogErrorClassified(c.FileLogger, operation, c.ExecutionId,
|
||||
string(category), reason, 0, 0,
|
||||
!isTimeoutError(lastErr), "")
|
||||
return nil, apperrors.NewDiscovery(
|
||||
fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr),
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(!isTimeoutError(lastErr)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(discoveryActions("")...),
|
||||
apperrors.WithActions(actions...),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: lastErr,
|
||||
}),
|
||||
)
|
||||
}
|
||||
message := fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr)
|
||||
if category == apperrors.CategoryAPI {
|
||||
return nil, apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
return nil, apperrors.NewDiscovery(message, opts...)
|
||||
}
|
||||
|
||||
func sanitizeJSONRPCEndpoint(endpoint string) string {
|
||||
@@ -1039,6 +1058,17 @@ func runtimeActions(snapshotPath string) []string {
|
||||
return actions
|
||||
}
|
||||
|
||||
func networkActions(snapshotPath string) []string {
|
||||
actions := []string{
|
||||
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
||||
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
||||
}
|
||||
if snapshotPath != "" {
|
||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// validateCallArguments checks string values in tool call arguments for
|
||||
// control characters and dangerous Unicode at the transport boundary.
|
||||
func validateCallArguments(args map[string]any) error {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
|
||||
})})
|
||||
client.FileLogger = logger
|
||||
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize")
|
||||
if err != nil {
|
||||
t.Fatalf("doWithRetry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -53,7 +55,7 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`))
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call")
|
||||
if err == nil {
|
||||
t.Fatal("doWithRetry() error = nil, want request failure")
|
||||
}
|
||||
@@ -67,6 +69,125 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: connection refused")
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want request failure")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "connection_refused" {
|
||||
t.Fatalf("Reason = %q, want connection_refused", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime network failure contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil
|
||||
})})
|
||||
client.MaxRetries = 1
|
||||
client.sleep = func(context.Context, time.Duration) error { return context.Canceled }
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil)
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want retry cancellation")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "request_cancelled" {
|
||||
t.Fatalf("Reason = %q, want request_cancelled", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
failure error
|
||||
operation string
|
||||
wantReason string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "initialize connection refused",
|
||||
failure: errors.New("dial tcp: connection refused"),
|
||||
operation: "initialize",
|
||||
wantReason: "connection_refused",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tools list dns failure",
|
||||
failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"),
|
||||
operation: "tools/list",
|
||||
wantReason: "dns_resolution_failed",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, tt.failure
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
err := tt.call(client)
|
||||
if err == nil {
|
||||
t.Fatal("request error = nil, want failure")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("request error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryDiscovery {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery)
|
||||
}
|
||||
if typed.Operation != tt.operation {
|
||||
t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation)
|
||||
}
|
||||
if typed.Reason != tt.wantReason {
|
||||
t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
CategoryAuth Category = "auth"
|
||||
CategoryNetwork Category = "network"
|
||||
CategorySecurity Category = "security"
|
||||
CategoryAudit Category = "audit"
|
||||
CategoryRuntime Category = "runtime"
|
||||
CategoryDebug Category = "debug"
|
||||
CategoryExternal Category = "external"
|
||||
@@ -43,9 +44,10 @@ var categoryOrder = map[Category]int{
|
||||
CategoryAuth: 1,
|
||||
CategoryNetwork: 2,
|
||||
CategorySecurity: 3,
|
||||
CategoryRuntime: 4,
|
||||
CategoryDebug: 5,
|
||||
CategoryExternal: 6,
|
||||
CategoryAudit: 4,
|
||||
CategoryRuntime: 5,
|
||||
CategoryDebug: 6,
|
||||
CategoryExternal: 7,
|
||||
}
|
||||
|
||||
// ConfigItem describes a single environment-variable configuration item.
|
||||
|
||||
@@ -143,8 +143,8 @@ func TestMaskValue(t *testing.T) {
|
||||
|
||||
func TestCategories(t *testing.T) {
|
||||
cats := Categories()
|
||||
if len(cats) != 7 {
|
||||
t.Fatalf("expected 7 categories, got %d", len(cats))
|
||||
if len(cats) != 8 {
|
||||
t.Fatalf("expected 8 categories, got %d", len(cats))
|
||||
}
|
||||
if cats[0] != CategoryCore {
|
||||
t.Errorf("expected core first, got %s", cats[0])
|
||||
|
||||
@@ -36,6 +36,14 @@ require_no_match() {
|
||||
fi
|
||||
}
|
||||
|
||||
require_no_tracked_path() {
|
||||
path="$1"
|
||||
if git ls-files -- "$path" | grep -q .; then
|
||||
git ls-files -- "$path" >&2
|
||||
err "found forbidden tracked path: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
require_heading_if_linked() {
|
||||
label="$1"
|
||||
heading="$2"
|
||||
@@ -62,6 +70,10 @@ require_no_match 'scripts/test\.sh|scripts/check-semantic-fixtures\.sh|scripts/r
|
||||
require_no_match 'scripts/check-semantic-fixtures\.py|test/semantic/cli_to_mcp|test/semantic/model_to_cli|python3 -m pytest test/semantic' CONTRIBUTING.md
|
||||
require_no_match '\.github/CODEOWNERS|Tag-triggered GitHub Release asset publishing' CHANGELOG.md
|
||||
|
||||
require_no_tracked_path 'auto-test'
|
||||
require_no_tracked_path 'eval-runs'
|
||||
require_no_tracked_path '.qoder'
|
||||
|
||||
require_heading_if_linked '[Environment Variables](#environment-variables)' '## Environment Variables'
|
||||
require_heading_if_linked '[Exit Codes](#exit-codes)' '## Exit Codes'
|
||||
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Replace GoReleaser's original assets with post-processed artifacts, verify
|
||||
# that GitHub serves exactly those bytes, and only then make the Draft public.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
PUBLISH_RELEASE="${DWS_PUBLISH_RELEASE:-true}"
|
||||
DIGEST_ATTEMPTS="${DWS_RELEASE_DIGEST_ATTEMPTS:-5}"
|
||||
DIGEST_RETRY_DELAY="${DWS_RELEASE_DIGEST_RETRY_DELAY:-2}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
target="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$target" | awk '{print $1}'
|
||||
return
|
||||
fi
|
||||
shasum -a 256 "$target" | awk '{print $1}'
|
||||
}
|
||||
|
||||
case "$PUBLISH_RELEASE" in
|
||||
1|true|yes) publish_release=1 ;;
|
||||
0|false|no) publish_release=0 ;;
|
||||
*) err "invalid DWS_PUBLISH_RELEASE value: $PUBLISH_RELEASE" ;;
|
||||
esac
|
||||
|
||||
case "$DIGEST_ATTEMPTS" in
|
||||
''|*[!0-9]*) err "DWS_RELEASE_DIGEST_ATTEMPTS must be a positive integer" ;;
|
||||
0) err "DWS_RELEASE_DIGEST_ATTEMPTS must be greater than zero" ;;
|
||||
esac
|
||||
|
||||
command -v gh >/dev/null 2>&1 || err "gh is required"
|
||||
|
||||
set -- \
|
||||
"$DIST_DIR/dws-darwin-amd64.tar.gz" \
|
||||
"$DIST_DIR/dws-darwin-arm64.tar.gz" \
|
||||
"$DIST_DIR/checksums.txt" \
|
||||
"$DIST_DIR/dws-skills.zip"
|
||||
|
||||
for asset in "$@"; do
|
||||
[ -f "$asset" ] || err "finalized release asset missing: $asset"
|
||||
done
|
||||
|
||||
gh release upload "$TAG" "$@" \
|
||||
--repo "$REPOSITORY" \
|
||||
--clobber
|
||||
|
||||
# Fail before publication if GitHub still serves a pre-signing archive. Asset
|
||||
# digests can take a moment to settle after replacement, so retry.
|
||||
for asset in "$@"; do
|
||||
name="$(basename "$asset")"
|
||||
local_digest="sha256:$(sha256_file "$asset")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$DIGEST_ATTEMPTS" ]; do
|
||||
remote_digest="$(
|
||||
gh release view "$TAG" --repo "$REPOSITORY" --json assets \
|
||||
--jq ".assets[] | select(.name == \"$name\") | .digest"
|
||||
)"
|
||||
if [ "$remote_digest" = "$local_digest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq "$DIGEST_ATTEMPTS" ]; then
|
||||
printf 'release asset digest mismatch for %s\n' "$name" >&2
|
||||
printf ' local: %s\n' "$local_digest" >&2
|
||||
printf ' remote: %s\n' "${remote_digest:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$DIGEST_RETRY_DELAY"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$publish_release" -eq 1 ]; then
|
||||
gh release edit "$TAG" --repo "$REPOSITORY" --draft=false
|
||||
else
|
||||
printf 'finalized assets verified; keeping release %s as Draft\n' "$TAG"
|
||||
fi
|
||||
@@ -10,6 +10,9 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-}"
|
||||
RELEASE_BASE_URL="${DWS_RELEASE_BASE_URL:-}"
|
||||
APPLE_CERTIFICATE_P12="${DWS_APPLE_CERTIFICATE_P12:-}"
|
||||
APPLE_CERTIFICATE_PASSWORD_FILE="${DWS_APPLE_CERTIFICATE_PASSWORD_FILE:-}"
|
||||
REQUIRE_DEVELOPER_ID_SIGNING="${DWS_REQUIRE_DEVELOPER_ID_SIGNING:-false}"
|
||||
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
@@ -195,14 +198,47 @@ create_skills_zip() {
|
||||
rm -rf "$staging"
|
||||
}
|
||||
|
||||
# ---------- darwin ad-hoc signing ----------
|
||||
# ---------- darwin signing ----------
|
||||
#
|
||||
# Unsigned arm64 binaries are SIGKILL'd by amfid on Apple Silicon (macOS 11+).
|
||||
# We unpack each dws-darwin-*.tar.gz, ad-hoc sign the dws binary, repack
|
||||
# deterministically, and rewrite the corresponding line in checksums.txt.
|
||||
# Official releases use an Apple Developer ID certificate loaded from GitHub
|
||||
# Secrets. Fork/local builds retain ad-hoc signing so they remain runnable.
|
||||
# We unpack each dws-darwin-*.tar.gz, sign the dws binary, repack deterministically,
|
||||
# and rewrite the corresponding line in checksums.txt.
|
||||
|
||||
configure_darwin_signing() {
|
||||
case "$REQUIRE_DEVELOPER_ID_SIGNING" in
|
||||
1|true|yes) require_developer_id=1 ;;
|
||||
0|false|no|"") require_developer_id=0 ;;
|
||||
*) err "invalid DWS_REQUIRE_DEVELOPER_ID_SIGNING value: $REQUIRE_DEVELOPER_ID_SIGNING" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$APPLE_CERTIFICATE_P12" ] || [ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ]; then
|
||||
[ -n "$APPLE_CERTIFICATE_P12" ] || err "DWS_APPLE_CERTIFICATE_P12 is required when Developer ID signing is configured"
|
||||
[ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "DWS_APPLE_CERTIFICATE_PASSWORD_FILE is required when Developer ID signing is configured"
|
||||
[ -f "$APPLE_CERTIFICATE_P12" ] || err "Developer ID P12 file not found: $APPLE_CERTIFICATE_P12"
|
||||
[ -f "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "Developer ID password file not found: $APPLE_CERTIFICATE_PASSWORD_FILE"
|
||||
command -v rcodesign >/dev/null 2>&1 || err "rcodesign is required for Developer ID signing"
|
||||
DARWIN_SIGNING_MODE="developer-id"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$require_developer_id" -eq 1 ]; then
|
||||
err "Developer ID signing is required but DWS_APPLE_CERTIFICATE_P12 and DWS_APPLE_CERTIFICATE_PASSWORD_FILE are not configured"
|
||||
fi
|
||||
DARWIN_SIGNING_MODE="ad-hoc"
|
||||
}
|
||||
|
||||
sign_one_darwin_binary() {
|
||||
bin="$1"
|
||||
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
|
||||
rcodesign sign \
|
||||
--p12-file "$APPLE_CERTIFICATE_P12" \
|
||||
--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE" \
|
||||
--for-notarization \
|
||||
"$bin"
|
||||
return
|
||||
fi
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
codesign --force --sign - "$bin"
|
||||
return
|
||||
@@ -272,18 +308,22 @@ sign_darwin_archives() {
|
||||
}
|
||||
|
||||
write_checksums() {
|
||||
checksum_path="$DIST_DIR/checksums.txt"
|
||||
# Append skills zip checksum to goreleaser's checksums file
|
||||
# Keep this idempotent: workflow retries must not leave duplicate entries.
|
||||
if [ -f "$DIST_DIR/dws-skills.zip" ]; then
|
||||
printf '%s %s\n' "$(sha256_file "$DIST_DIR/dws-skills.zip")" "dws-skills.zip" >> "$checksum_path"
|
||||
update_checksum_entry "dws-skills.zip" "$(sha256_file "$DIST_DIR/dws-skills.zip")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- main ----------
|
||||
|
||||
version="$(resolve_version)"
|
||||
configure_darwin_signing
|
||||
|
||||
say "==> Ad-hoc signing darwin binaries"
|
||||
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
|
||||
say "==> Developer ID signing darwin binaries"
|
||||
else
|
||||
say "==> Ad-hoc signing darwin binaries"
|
||||
fi
|
||||
sign_darwin_archives
|
||||
|
||||
say "==> Creating skills zip"
|
||||
|
||||
@@ -34,7 +34,7 @@ cli_version: ">=1.0.15"
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
|
||||
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/消息收藏/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) |
|
||||
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
|
||||
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
|
||||
@@ -132,18 +132,22 @@ Step 3 → 加 --yes 执行命令
|
||||
dws <command-path> --help
|
||||
# 例:dws calendar event list --help
|
||||
|
||||
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
|
||||
# 结构化 schema 查询:helper-only 子树(dev.*,source=mcp:<server>)
|
||||
dws schema "dev app create"
|
||||
# 登记的本地命令子树(event.*,source=cobra,从二进制 flag 合成)
|
||||
dws schema "event consume" # 叶子:parameters{<flag>:{type,required,description,default?}} + arguments[位置参数]
|
||||
dws schema event # 中间节点:列子命令
|
||||
# 其余普通产品命令不走 schema,直接看 --help。
|
||||
# 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在
|
||||
# .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。
|
||||
```
|
||||
|
||||
**何时用哪条路径:**
|
||||
- 只需看某个命令怎么调用 → `dws <cmd> --help`
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only 命令再看 `dws schema`
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only(dev.*)和登记的本地命令(event.*)可看 `dws schema` 取机读结构
|
||||
- 参考文档和 `--help` 冲突时 → **以 `--help` 为准**,文档视为过期
|
||||
|
||||
`dws schema` 在静态端点模式下只保留 helper-only 子树;普通产品命令和 flag 不再通过远程 schema 动态发现。写/删操作须先向用户确认再加 `--yes`。
|
||||
`dws schema` 覆盖两类命令:helper-only 子树(dev,CONTENT 从 MCP 实时取)和登记的本地命令(event,从 cobra flag 合成、source=cobra);其余普通产品命令和 flag 以 `--help` 为准。写/删操作须先向用户确认再加 `--yes`。
|
||||
|
||||
## 错误处理
|
||||
1. 遇到错误,加 `--verbose` 重试一次
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user