Compare commits

...
Author SHA1 Message Date
SCzheng e82574cdde Merge pull request #661 from DingTalk-Real-AI/codex/changelog-v1.0.53-beta.3
docs(changelog): prepare v1.0.53-beta.3
2026-07-17 14:08:44 +08:00
修雨 b2f917aa47 docs(changelog): prepare v1.0.53-beta.3 2026-07-17 13:57:56 +08:00
修雨 7cb0398bae Merge pull request #653 from audanye-sudo/feat/multi-account-profile-support
feat(auth): support multiple accounts in one organization
2026-07-17 13:45:34 +08:00
修雨 ef509ecdeb Merge pull request #654 from LastdianXuan/codex/fix-aitable-import-file-size
fix(aitable): require import upload file size
2026-07-17 12:05:04 +08:00
audanye-sudo fbc2575c93 fix(auth): address multi-account review feedback 2026-07-17 11:45:03 +08:00
修雨 58cb4789cd test(aitable): cover import upload in owning package 2026-07-17 11:31:53 +08:00
修雨 63b5fe3143 Merge remote-tracking branch 'origin/main' into codex/pr654-coverage-fix 2026-07-17 11:26:10 +08:00
修雨 41a65f268f Merge pull request #646 from DingTalk-Real-AI/bugfix-im-shortcut-ai-tag
fix: add AI tag to IM send shortcuts
2026-07-17 11:15:46 +08:00
修雨 03796388c3 test(shortcut): cover platform compatibility paths 2026-07-17 11:03:09 +08:00
audanye-sudo 69b31da4ba fix(auth): gate identity diagnostics behind opt-in 2026-07-17 10:47:24 +08:00
修雨 833d0cc05e Merge main into bugfix-im-shortcut-ai-tag
Resolve the shortcut catalog constraint migration and preserve both fake caller response modes.
2026-07-17 10:37:55 +08:00
张卓澎 b7cbef1c6f fix(aitable): require import upload file size 2026-07-17 10:23:40 +08:00
修雨 bdc480cf49 Merge pull request #647 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.53-beta.2
chore: update Homebrew beta formula for v1.0.53-beta.2
2026-07-17 10:01:30 +08:00
audanye-sudo 43eaadcf07 chore(docs): drop internal profile planning artifacts
Remove the internal design and execution plans from docs/plans and docs/superpowers so the public pull request contains only implementation and maintained user-facing documentation.

The four files were introduced only on this branch. No runtime code, generated output, README, CHANGELOG, or Skill documentation references them.

Verification:
- Confirmed origin/main does not contain the files.
- Confirmed no remaining repository references.
- Ran git diff --check before committing.
2026-07-17 09:39:32 +08:00
修雨 f8e1be5970 fix(homebrew): use sealed GitHub beta checksums 2026-07-17 09:38:10 +08:00
Dennis 8d1ccd1b98 fit chat shortcut aliases 2026-07-17 09:36:28 +08:00
Dennis c84b5d05f4 fix chat search shortcut keyword alias 2026-07-17 09:29:06 +08:00
audanye-sudo 5224d9c527 fix(auth): harden multi-account profile compatibility
Preserve manual-token defaults across explicit profile refreshes and selective logout while keeping legacy marker behavior compatible.

Make profile login, refresh, switch, and logout writes rollback-safe; reject unsupported future profile versions before remote side effects; and prevent cross-profile token fallback.

Forward token overrides through usage recording, use the newly authenticated identity for post-login authorization, distinguish unavailable profile state, and propagate Windows registry deletion failures.
2026-07-17 02:11:45 +08:00
audanye-sudo e9360fe11b docs(auth): document multi-account profile compatibility
Describe exact and friendly profile selector forms, deterministic organization-current behavior, profile listing semantics, and single-account or organization logout examples.

Update both mono and multi skills so agents avoid implicit account selection and request corpId:userId when an organization is ambiguous.

Record the compatibility design and implementation plan, including profiles v2 migration, legacy command support, storage mirrors, risk controls, and end-to-end acceptance criteria.
2026-07-17 00:57:12 +08:00
audanye-sudo 2d143589f8 feat(cli): add deterministic multi-account profile workflows
Accept corpId:userId and friendly organization/account selectors across global --profile, profile switch/use, event child processes, and multi-profile command execution.

List every local account in storage order with live identity-token status, preserve exact current and previous identities, and require explicit selection when an organization has no deterministic current account.

Extend auth logout to remove one exact account, every account in one organization, or all local accounts while revoking each token with its persisted credentials.

Use in-memory login tokens for identity enrichment before persistence, refresh generated Schema artifacts, and cover the complete CLI flow with isolated beta.3 end-to-end tests.
2026-07-17 00:56:57 +08:00
audanye-sudo 93854178e3 feat(auth): support exact multi-account profile identities
Store DingTalk credentials in corpId:userId identity slots while retaining organization and legacy mirrors for forward compatibility.

Resolve organization, account, friendly-name, current, previous, and deletion selectors without silently choosing among ambiguous accounts.

Make identity tokens the source of truth, serialize migration and refresh reads, reject unsafe mirror recovery, and sweep orphan token entries during reset.

Persist token source and client ID for exact remote revocation, require user identity before first-login persistence, and add cross-platform regression coverage for migration, deletion, refresh, and keychain failures.
2026-07-17 00:56:43 +08:00
修雨 669518682c chore: update beta formula for v1.0.53-beta.2 2026-07-16 19:05:16 +08:00
修雨 642e676f79 Merge pull request #642 from DingTalk-Real-AI/codex/parallelize-ci-jobs
ci: parallelize PR test and coverage jobs
2026-07-16 18:57:29 +08:00
修雨 da7b490e08 ci: include app subpackages in race shard 2026-07-16 17:05:43 +08:00
修雨 e2ab422787 ci: parallelize PR test and coverage jobs 2026-07-16 16:58:10 +08:00
修雨 4d05ea4fc1 Merge pull request #628 from PeterGuy326/codex/fix-windows-portable-export-contract
fix(auth): reject unsupported Windows portable export
2026-07-16 16:22:16 +08:00
修雨 e3bbb33c18 Merge remote-tracking branch 'origin/main' into pr628-merge
# Conflicts:
#	CHANGELOG.md
2026-07-16 16:19:31 +08:00
Dennis 0d81f061d8 fix shortcut IM AI message tag 2026-07-16 15:39:39 +08:00
修雨 950de23e74 Merge branch 'main' into codex/fix-windows-portable-export-contract 2026-07-16 14:31:53 +08:00
修雨 07d2e4597e test(auth): keep portable fixtures platform-neutral 2026-07-16 13:55:58 +08:00
修雨 57d753cd1d Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716
# Conflicts:
#	CHANGELOG.md
#	internal/app/auth_command.go
#	internal/app/auth_command_test.go
#	internal/app/config_test.go
#	internal/app/root.go
#	internal/app/skill_setup_test.go
#	internal/app/timing_test.go
#	internal/auth/portable_store.go
#	internal/logging/logger.go
2026-07-16 13:42:38 +08:00
修雨 d202d58963 Merge remote-tracking branch 'origin/main' into codex/pr628-main-sync-20260716 2026-07-16 12:40:55 +08:00
修雨 c79c5dfffe test(windows): isolate portable import side effects 2026-07-16 12:21:27 +08:00
修雨 681f2db87a Merge origin/main into codex/fix-windows-portable-export-contract 2026-07-16 12:12:41 +08:00
修雨 7dc5b6f2ed test(coverage): exercise portable auth platform guards 2026-07-16 12:12:26 +08:00
修雨 fb4e6a0fdb Merge origin/main into codex/fix-windows-portable-export-contract 2026-07-16 12:05:22 +08:00
修雨 88d82f201f Merge origin/main into codex/fix-windows-portable-export-contract
# Conflicts:
#	CHANGELOG.md
2026-07-16 11:37:36 +08:00
修雨 e00019039c fix(auth): preserve force validation before support guard 2026-07-16 10:36:51 +08:00
修雨 74fa24ee1e fix(auth): reject unsupported Windows portable import 2026-07-16 10:13:41 +08:00
修雨 1822b82232 test(logging): clarify terminal replacement coverage 2026-07-15 17:23:12 +08:00
修雨 ec29dc0e22 fix(logging): make file logger close terminal 2026-07-15 17:16:53 +08:00
修雨 bf105d3d29 fix(logging): close replaced file logger 2026-07-15 17:03:13 +08:00
修雨 48681eb41c test: isolate app audit environment 2026-07-15 16:53:55 +08:00
修雨 5e41b8a6e8 test(windows): make app coverage portable 2026-07-15 16:45:57 +08:00
修雨 296e9a73f0 fix(auth): reject unsupported Windows portable export 2026-07-15 16:04:17 +08:00
81 changed files with 7609 additions and 755 deletions
+248 -29
View File
@@ -9,6 +9,10 @@ on:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
@@ -54,10 +58,18 @@ jobs:
- name: Check GitHub Actions workflows
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
test:
name: Test
test-race:
name: "Test (race: ${{ matrix.shard }})"
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard:
- app
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -71,19 +83,85 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
if: ${{ matrix.shard == 'remaining' }}
run: make build
- name: Test with Race Detection
# The registry-first final-delivery gate validates all public commands
# and the complete generated Catalog under the race detector. Keep the
# package timeout aligned with the macOS race job so the Linux runner's
# five-minute default does not expire while that gate is still making
# progress.
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
- name: Test shard with Race Detection
env:
TEST_SHARD: ${{ matrix.shard }}
run: |
case "$TEST_SHARD" in
app)
packages=(./internal/app/...)
;;
generators)
packages=(./internal/generator/...)
;;
helpers)
packages=(./internal/helpers/...)
;;
remaining)
mapfile -t packages < <(
go list ./cmd/... ./internal/... |
grep -Ev '/internal/(app|generator|helpers)(/|$)'
)
;;
*)
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
exit 1
;;
esac
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
test-release-scripts:
name: Test (release scripts)
runs-on: ubuntu-latest
timeout-minutes: 10
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: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
test:
name: Test
needs:
- test-race
- test-release-scripts
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify test shards
env:
RACE_RESULT: ${{ needs.test-race.result }}
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
run: |
failed=0
for shard in \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT"
do
name="${shard%%:*}"
result="${shard#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
test-darwin:
name: Test (macOS auth/keychain)
runs-on: macos-latest
@@ -119,8 +197,8 @@ jobs:
- name: Test Windows auth and DPAPI packages
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test Windows auth migration diagnostics
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
- name: Test Windows auth migration and portable auth diagnostics
run: go test -v -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
coverage-darwin:
name: Coverage (macOS)
@@ -208,10 +286,80 @@ jobs:
name: coverage-windows
path: coverage-windows.txt
coverage:
name: Coverage
coverage-current:
name: Coverage (current)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Run current unit tests with coverage
run: |
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go tool cover -func=coverage.txt
- name: Upload current coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-current-profile
path: coverage.txt
retention-days: 1
coverage-supporting:
name: Coverage (supporting)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run policy and shortcut coverage
run: |
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
- name: Upload supporting coverage profiles
uses: actions/upload-artifact@v4
with:
name: coverage-supporting-profiles
path: |
coverage-policy.txt
coverage-shortcut.txt
retention-days: 1
coverage-baseline:
name: Coverage (baseline)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -227,9 +375,6 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -247,18 +392,9 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run current and baseline unit tests with coverage
- name: Run baseline unit tests with coverage
run: |
set -eu
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
rmdir "$base_worktree"
cleanup() {
@@ -274,7 +410,90 @@ jobs:
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
go tool cover -func=coverage.txt
- name: Upload baseline coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-baseline-profile
path: coverage-base.txt
retention-days: 1
coverage:
name: Coverage
needs:
- coverage-current
- coverage-supporting
- coverage-baseline
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Verify coverage profile jobs
env:
CURRENT_RESULT: ${{ needs.coverage-current.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
run: |
failed=0
for profile in \
"current:$CURRENT_RESULT" \
"supporting:$SUPPORTING_RESULT" \
"baseline:$BASELINE_RESULT"
do
name="${profile%%:*}"
result="${profile#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
path: .
- name: Download supporting coverage profiles
uses: actions/download-artifact@v4
with:
name: coverage-supporting-profiles
path: .
- name: Download baseline coverage profile
uses: actions/download-artifact@v4
with:
name: coverage-baseline-profile
path: .
- name: Enforce coverage gate
env:
+18
View File
@@ -6,6 +6,24 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.53-beta.3] - 2026-07-17
This beta validates multi-account profile support and the post-v1.0.53-beta.2 compatibility fixes for Windows portable authentication, IM shortcuts, and Aitable import uploads.
### Added
- **Multiple accounts in one DingTalk organization** — profiles are keyed by `corpId:userId`, `--profile` accepts organization IDs/names plus user IDs/names, and organization-only selection uses its explicitly remembered current account or asks for an exact account when ambiguous.
### Changed
- **Profile-scoped logout and consistent token storage** — `dws auth logout --profile` can remove one account or every account in an organization, while identity token slots remain the source of truth and legacy organization/global mirrors stay compatible without overwriting newer account credentials.
### Fixed
- **Windows portable-auth contract** — `dws auth export` and `dws auth import` now fail early without reading credentials, bundles, or writing files instead of claiming portable-bundle support for DPAPI-protected HKCU Registry credentials.
- **IM shortcut message tags and compatibility aliases** (#646) — IM send shortcuts now add the same AI-sent marker as `chat message send` by default, support `--ai-tag=false` to opt out, and preserve compatible search, conversation-ID, and page-size aliases.
- **Aitable import upload file-size validation** (#654) — `dws aitable import upload` and `dws aitable +import-upload` now require a positive `--file-size` and always send it to the upload-preparation API, preventing invalid requests without the actual file size.
## [1.0.53-beta.2] - 2026-07-16
This beta validates the accumulated post-v1.0.52 command surface, release automation, and runtime hardening changes, including enterprise contact onboarding, declarative shortcuts, Sheet/Aitable writes, multi-platform Homebrew formulas, and credential and target-validation fixes.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.53-beta.1"
version "1.0.53-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-darwin-arm64.tar.gz"
sha256 "7fef5add684189bfef16a1731fab4883fa096115dcd645c79bf9b3918758dbcb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-arm64.tar.gz"
sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-darwin-amd64.tar.gz"
sha256 "8cde0fd62849dcb52bc053022361f983953bd5a4bac1edfc96076a84636debbe"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz"
sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-linux-arm64.tar.gz"
sha256 "17989f94b422d6490249c5e095049a9efc1c3861176fc249d5e7eea283f5e675"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-arm64.tar.gz"
sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-linux-amd64.tar.gz"
sha256 "439d6a1ffc9572ed461b77a8bb27f6ddb4d4c9ce9d58474c0180955fc8a1d8d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz"
sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-skills.zip"
sha256 "a93ba8a73f2e319038a7bcb32038b9e7bd93d1e9af012a430ebf994498c140d4"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip"
sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8"
end
def install
+16 -7
View File
@@ -283,16 +283,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
`dws` can stay logged in to several DingTalk accounts at once, including multiple accounts in the same organization. A profile is uniquely identified by `corpId:userId`; the current profile decides which identity a command runs as.
```bash
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
dws auth login # add or refresh one account
dws profile list # list every logged-in account
dws profile switch <corpId:userId> # persistently switch; use - to toggle back
dws profile switch "<corpName>:<userName>" # friendly input; names must be unique
dws --profile <corpId> contact user search --query "..." # use that org's explicitly recorded current account
dws --profile <corpId:userId> contact user search --query "..." # use one exact account without changing the default
```
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.
Selectors support `corpId:userId`, `corpId:userName`, `corpName:userId`, and `corpName:userName`. Friendly names are input aliases only; use the stable `profile` value returned by `profile list` for automation. Duplicate organization or account names fail with explicit `corpId:userId` candidates. If an organization has multiple accounts but no recorded current account, `--profile <corpId>` fails instead of choosing the first or most recently used account.
`currentProfile`, `previousProfile`, and per-organization defaults are stored as exact identities. `primaryProfile` remains in JSON only for compatibility and is not used for selection. `profile list` reads status and expiry from each real identity Token without refreshing it. `auth logout --profile <corpId>` removes all local accounts in that organization; an exact selector or local profile name removes one account.
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list profiles, group by `corpId`, and use the unique `isOrgCurrent=true` account for each organization. If a multi-account organization has no default, ask the user to choose an account first. Writes default to the current account — confirm both organization and account before cross-org writes.
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:
@@ -302,7 +308,7 @@ env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --form
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.
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 account with `dws auth logout --profile <corpId:userId>`, or all accounts in one organization with `--profile <corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
</details>
@@ -323,6 +329,9 @@ dws auth status # confirm "Refresh Token: valid"
```
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
Windows export and import are intentionally rejected before credentials or
bundles are read: Windows stores credentials as DPAPI-protected HKCU Registry
values, and the current file-DEK bundle has no safe DPAPI-to-portable conversion.
</details>
+13 -7
View File
@@ -280,16 +280,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
`dws` 可以同时登录多个钉钉账号,同一组织也能保留多个账号。一个 profile 由 `corpId + userId` 唯一确定。
```bash
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
dws auth login # 新增或刷新一个账号
dws profile list # 列出全部账号,profile 字段是稳定的 corpId:userId
dws profile switch <corpId:userId> # 持久切换账号;用 - 切回上一个
dws profile switch "组织名:用户名" # 名称输入要求唯一
dws --profile <corpId> contact user search --query "..." # 使用该组织明确记录的当前账号
dws --profile <corpId:userId> contact user search --query "..." # 单次精确指定账号,不改默认账号
```
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`。名称只用于输入,自动化应使用 `profile list` 返回的稳定 `profile`。组织名或用户名重名时会列出候选并报错;同组织多账号但没有明确当前账号时,只传组织也会报错,不会选择第一项或最近使用账号。
`currentProfile`、`previousProfile` 和组织默认账号都保存精确身份。`primaryProfile` 只为 JSON 兼容保留,不再参与选择。`profile list` 直接读取各身份 Token 计算状态和到期时间,不触发刷新。`auth logout --profile <corpId>` 退出该组织全部账号;精确选择器或本地 profile 名只退出一个账号。
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list`,每个组织使用唯一的 `isOrgCurrent=true` 账号;若多账号组织没有默认账号,先让用户指定账号。写操作默认只在当前账号执行——跨组织写之前先确认目标组织和账号。
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
@@ -299,7 +305,7 @@ env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --form
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,优先使用 `dws auth logout --profile <corpId:userId>` 只清理受影响账号;只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
</details>
+20 -6
View File
@@ -23,6 +23,11 @@ The repository defines five focused checks in addition to its existing CI:
variance to avoid failing unchanged code on test-path noise. Set
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
the overall target fail closed as well.
CI generates the candidate, supporting, and merge-base profiles on three
independent runners, then downloads all profiles into the aggregate
`Coverage` job and applies the same fail-closed gate. The split changes only
scheduling: the tested packages, profile contents, merge-base comparison,
and final coverage thresholds remain unchanged.
- **CLI Smoke** builds the release binary, reads the root command list from the
structured Interface contract, and renders offline help for every public
top-level command. It rejects Cobra's unknown-command root-help fallback and
@@ -53,9 +58,10 @@ make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
```
`make coverage-gate` is the enforcement step, not a profile generator. It
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
expects the candidate, policy, shortcut, and merge-base profiles
(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and
`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local
checkout can reproduce the Linux/overall CI gate sequentially with:
```sh
base_ref=$(git merge-base HEAD origin/main)
@@ -65,17 +71,25 @@ rmdir "$base_worktree"
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
trap cleanup EXIT HUP INT TERM
go test -count=1 -coverprofile=coverage.txt -covermode=atomic \
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
git worktree add --detach "$base_worktree" "$base_ref"
(
cd "$base_worktree"
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
go test -count=1 -p 1 \
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
make coverage-gate BASE_REF="$base_ref"
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
make coverage-gate BASE_REF="$base_ref"
```
The native-platform target likewise expects `PROFILE` to have already been
+9
View File
@@ -56,10 +56,19 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
return "", tokenErr
}
if strings.TrimSpace(authpkg.RuntimeProfile()) != "" {
if tokenErr != nil {
return "", tokenErr
}
return "", nil
}
manager := newLegacyTokenManager(configDir)
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
return strings.TrimSpace(leg), nil
}
if tokenErr != nil {
return "", tokenErr
}
return "", nil
}
+53
View File
@@ -5,7 +5,15 @@ package app
import (
"context"
"errors"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestResolveAuxiliaryAccessToken_explicitToken(t *testing.T) {
@@ -24,3 +32,48 @@ func TestResolveAuxiliaryAccessToken_emptyConfigDir(t *testing.T) {
t.Fatal("expected error for empty config directory")
}
}
func TestResolveAccessTokenFromDirPreservesRefreshFailure(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "expired-access",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp_refresh",
UserID: "user_refresh",
ClientID: "client_refresh",
Source: "mcp",
}); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = refreshFailureRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("refresh endpoint rejected token")
})
token, err := resolveAccessTokenFromDir(context.Background(), configDir)
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
if err == nil {
t.Fatal("resolveAccessTokenFromDir() error = nil")
}
if !strings.Contains(err.Error(), "refresh endpoint rejected token") {
t.Fatalf("error = %q, want original refresh failure", err)
}
}
type refreshFailureRoundTripFunc func(*http.Request) (*http.Response, error)
func (f refreshFailureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+6
View File
@@ -217,6 +217,12 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
t.Fatalf("legacy token = %q, %v", got, err)
}
authpkg.SetRuntimeProfile("corp:user")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" {
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
}
authpkg.SetRuntimeProfile("")
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} }
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} }
edition.Override(&edition.Hooks{})
+182 -35
View File
@@ -30,6 +30,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -85,7 +86,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
newAuthMigrateKeychainCommand(),
newAuthExportCommand(),
newAuthImportCommand(),
newAuthExchangeCommand(),
newAuthExchangeCommand(patCaller),
newAuthResetCommand(),
)
return cmd
@@ -146,6 +147,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
}
tokenData, err = authDeviceLogin(provider, loginCtx)
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
@@ -158,6 +162,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
}
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
if err != nil {
@@ -167,17 +174,13 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
ResetRuntimeTokenCache()
clearCompatCache()
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
ResetRuntimeTokenCache()
clearCompatCache()
}
w := cmd.OutOrStdout()
runPostLoginAuthorization := func() error {
if !recommendAuthMode {
return nil
}
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
defer restoreProfile()
recommendScopeMode := pat.LoginRecommendScopeRecommended
var initialPlan *pat.LoginRecommendPlan
if postLoginTUIMode {
@@ -303,7 +306,10 @@ var (
authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions
authRunDirectPATWait = runDirectPATAuthCheckWaitOnly
authResolveProfile = authpkg.ResolveProfile
authResolveProfileDeletion = authpkg.ResolveProfileDeletionScope
authRevokeToken = authpkg.RevokeTokenRemote
authRevokeTokenForData = authpkg.RevokeTokenRemoteForData
authLoadTokenForProfile = authpkg.LoadTokenDataForProfile
authDeleteProfileToken = authpkg.DeleteTokenDataForProfile
authEnsureProfilesMigration = authpkg.EnsureProfilesMigration
authLoadProfiles = authpkg.LoadProfiles
@@ -423,9 +429,10 @@ func newAuthLogoutCommand() *cobra.Command {
Short: "清除认证信息(默认退出所有组织)",
Long: `清除本机钉钉登录态。
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
默认退出全部账号。--profile 传组织时退出该组织全部账号;传精确账号或本地 profile 名时只退出一个账号。`,
Example: ` dws auth logout
dws auth logout --profile <corpId>
dws auth logout --profile <corpId>:<userId>
dws auth logout --profile "钉钉"`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -455,7 +462,7 @@ func newAuthLogoutCommand() *cobra.Command {
return nil
},
}
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名")
return cmd
}
@@ -468,7 +475,8 @@ func newAuthStatusCommand() *cobra.Command {
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
Example: ` dws auth status
dws auth status --profile <corpId>
dws auth status --profile "钉钉"
dws auth status --profile <corpId>:<userId>
dws auth status --profile "钉钉:孙博文"
dws auth status --profile <corpId> --format json`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -477,6 +485,17 @@ func newAuthStatusCommand() *cobra.Command {
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
profileSelector = strings.TrimSpace(profileSelector)
if profileSelector != "" {
selected, resolveErr := authpkg.ResolveProfile(configDir, profileSelector)
if resolveErr != nil {
return apperrors.NewValidation(resolveErr.Error())
}
if selected == nil {
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
}
profileSelector = authpkg.ProfileSelector(*selected)
}
restoreProfile := pushRuntimeProfile(profileSelector)
defer restoreProfile()
@@ -484,6 +503,7 @@ func newAuthStatusCommand() *cobra.Command {
refreshed := false
var tokenData *authpkg.TokenData
var statusErr error
var refreshFailure error
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := authOAuthStatus(provider); err == nil {
@@ -498,18 +518,23 @@ func newAuthStatusCommand() *cobra.Command {
refreshed = true
}
} else if edition.Get().AutoPurgeToken {
refreshFailure = refreshErr
_ = authDeleteTokenData(configDir)
} else if tokenData != nil {
_ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
refreshFailure = refreshErr
_ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired)
}
}
if authStatusAuthenticated(tokenData) {
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
authenticated = true
}
} else {
statusErr = err
}
diagnostic := authStatusDiagnosticFromError(statusErr)
if refreshFailure != nil {
diagnostic = authStatusRefreshDiagnostic(refreshFailure)
}
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
@@ -554,7 +579,7 @@ func newAuthStatusCommand() *cobra.Command {
return nil
},
}
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名")
return cmd
}
@@ -618,13 +643,33 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authResolveProfile(configDir, selector); err != nil {
selected, exact, err := authResolveProfileDeletion(configDir, selector)
if err != nil {
return apperrors.NewValidation(err.Error())
}
restoreProfile := pushRuntimeProfile(selector)
defer restoreProfile()
_ = authRevokeToken(ctx)
if err := authDeleteProfileToken(configDir, selector); err != nil {
if selected == nil {
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
stableSelector := selected.CorpID
if exact {
stableSelector = authpkg.ProfileSelector(*selected)
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
} else if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil {
for _, profile := range cfg.Profiles {
if profile.CorpID != selected.CorpID {
continue
}
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
}
}
if err := authDeleteProfileToken(configDir, stableSelector); err != nil {
if strings.Contains(err.Error(), "not found") {
return apperrors.NewValidation(err.Error())
}
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
@@ -642,9 +687,9 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
_ = authRevokeToken(ctx)
} else {
for _, profile := range cfg.Profiles {
restoreProfile := pushRuntimeProfile(profile.CorpID)
_ = authRevokeToken(ctx)
restoreProfile()
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
_ = authRevokeTokenForData(ctx, data)
}
}
}
if err := authDeleteAllTokenData(configDir); err != nil {
@@ -665,7 +710,19 @@ func pushRuntimeProfile(selector string) func() {
}
}
func replaceRuntimeProfile(selector string) func() {
previous := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile(strings.TrimSpace(selector))
return func() {
authpkg.SetRuntimeProfile(previous)
}
}
func newAuthExportCommand() *cobra.Command {
return newAuthExportCommandWithSupport(authpkg.PortableExportSupportError)
}
func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command {
cmd := &cobra.Command{
Use: "export",
Short: "导出可迁移认证包",
@@ -690,6 +747,9 @@ func newAuthExportCommand() *cobra.Command {
if !asBase64 && output == "" {
return apperrors.NewValidation("--output is required unless --base64 is used")
}
if err := supportError(); err != nil {
return apperrors.NewValidation(err.Error())
}
if !authPortableExportSupported() {
return apperrors.NewValidation(fmt.Sprintf(
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
@@ -733,6 +793,10 @@ func newAuthExportCommand() *cobra.Command {
}
func newAuthImportCommand() *cobra.Command {
return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError)
}
func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command {
cmd := &cobra.Command{
Use: "import",
Short: "导入可迁移认证包",
@@ -761,7 +825,9 @@ func newAuthImportCommand() *cobra.Command {
if err != nil {
return apperrors.NewInternal("failed to read --force")
}
if err := supportError(); err != nil {
return apperrors.NewValidation(err.Error())
}
configDir := defaultConfigDir()
if !force && authPortableTargetPopulated(configDir) {
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
@@ -797,7 +863,7 @@ func newAuthImportCommand() *cobra.Command {
return cmd
}
func newAuthExchangeCommand() *cobra.Command {
func newAuthExchangeCommand(caller edition.ToolCaller) *cobra.Command {
cmd := &cobra.Command{
Use: "exchange",
Short: "Exchange an authorization code for credentials",
@@ -819,6 +885,9 @@ func newAuthExchangeCommand() *cobra.Command {
configDir := defaultConfigDir()
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, caller, data)
}
configureOAuthProviderCompatibility(provider, configDir)
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
defer cancel()
@@ -1141,6 +1210,19 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
if err != nil {
return authLoginConfig{}, err
}
flow := "oauth"
if strings.TrimSpace(token) != "" {
flow = "token"
} else if device {
flow = "device"
}
logging.AuthDebug(
"auth.login.request",
"flow", flow,
"profile_selector", strings.TrimSpace(profileSelector),
"target_corp_id", targetCorpID,
"recommend", recommend,
)
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
@@ -1176,7 +1258,11 @@ type contactProfileIdentity struct {
UserName string
}
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
type tokenOverrideToolCaller interface {
CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error)
}
func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edition.ToolCaller, data *authpkg.TokenData) error {
if caller == nil || data == nil {
return nil
}
@@ -1184,24 +1270,62 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
if corpID == "" {
return nil
}
logging.AuthDebug(
"auth.login.identity.lookup.start",
"corp_id", corpID,
"user_id", strings.TrimSpace(data.UserID),
"user_name", strings.TrimSpace(data.UserName),
"corp_name", strings.TrimSpace(data.CorpName),
)
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
logging.AuthDebug(
"auth.login.identity.lookup.result",
"source", "token_exchange",
"corp_id", corpID,
"user_id", strings.TrimSpace(data.UserID),
"user_name", strings.TrimSpace(data.UserName),
"corp_name", strings.TrimSpace(data.CorpName),
)
return nil
}
restoreProfile := pushRuntimeProfile(corpID)
defer restoreProfile()
ResetRuntimeTokenCache()
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
"profile": corpID,
})
var (
result *edition.ToolResult
err error
)
if tokenCaller, ok := caller.(tokenOverrideToolCaller); ok && strings.TrimSpace(data.AccessToken) != "" {
result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil)
} else {
if strings.TrimSpace(data.UserID) == "" {
return fmt.Errorf("login identity lookup requires an in-memory token override")
}
return nil
}
if err != nil {
logging.AuthDebug(
"auth.login.identity.lookup.error",
"corp_id", corpID,
"existing_user_id", strings.TrimSpace(data.UserID),
"error", err,
)
if strings.TrimSpace(data.UserID) != "" {
return nil
}
return err
}
identity, ok := contactProfileIdentityFromToolResult(result)
if !ok {
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
return nil
}
logging.AuthDebug(
"auth.login.identity.lookup.result",
"source", "contact.get_current_user_profile",
"corp_id", strings.TrimSpace(identity.CorpID),
"user_id", strings.TrimSpace(identity.UserID),
"user_name", strings.TrimSpace(identity.UserName),
"corp_name", strings.TrimSpace(identity.CorpName),
)
if identity.CorpID != "" && identity.CorpID != corpID {
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
}
@@ -1217,12 +1341,23 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
updated.UserName = identity.UserName
}
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
logging.AuthDebug(
"auth.login.identity.resolved",
"corp_id", corpID,
"user_id", strings.TrimSpace(data.UserID),
"user_name", strings.TrimSpace(data.UserName),
"changed", false,
)
return nil
}
if err := authSaveTokenData(configDir, &updated); err != nil {
return err
}
*data = updated
logging.AuthDebug(
"auth.login.identity.resolved",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"user_name", strings.TrimSpace(data.UserName),
"changed", true,
)
return nil
}
@@ -1249,6 +1384,7 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
OrgName string `json:"orgName"`
UserID string `json:"userId"`
UserIDLower string `json:"userid"`
OrgUserID string `json:"orgUserId"`
OrgUserName string `json:"orgUserName"`
Name string `json:"name"`
} `json:"orgEmployeeModel"`
@@ -1264,7 +1400,7 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
}
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
@@ -1351,6 +1487,17 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
}
}
func authStatusRefreshDiagnostic(err error) *authStatusDiagnostic {
if err == nil {
return nil
}
return &authStatusDiagnostic{
Reason: "token_refresh_failed",
Message: fmt.Sprintf("Token 刷新失败: %v", err),
Hint: "请重新运行 dws auth login 完成授权。",
}
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
resp := authStatusResponse{
Success: true,
+69 -16
View File
@@ -28,6 +28,9 @@ type authCoverageCaller struct {
func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result, c.err
}
func (c *authCoverageCaller) CallToolWithToken(ctx context.Context, _ string, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, productID, toolName, args)
}
func (*authCoverageCaller) Format() string { return "json" }
func (*authCoverageCaller) DryRun() bool { return false }
func (*authCoverageCaller) Fields() string { return "" }
@@ -67,12 +70,16 @@ func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string
func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
oldEdition := edition.Get()
oldClientID := authpkg.ClientID()
oldClientSecret := authpkg.ClientSecret()
oldRunForm := authRunForm
oldPrompt := authLoginManualCredentialsPrompt
oldSaveConfig := authSaveAppConfig
oldResolve := authResolveProfile
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetClientID(oldClientID)
authpkg.SetClientSecret(oldClientSecret)
authRunForm = oldRunForm
authLoginManualCredentialsPrompt = oldPrompt
authSaveAppConfig = oldSaveConfig
@@ -352,14 +359,22 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("caller error should propagate")
}
if err := enrichAuthLoginProfileFromContact(
ctx,
"cfg",
&authCoverageCaller{err: errors.New("call")},
&authpkg.TokenData{CorpID: "ding", UserID: "known", AccessToken: "token"},
); err != nil {
t.Fatalf("optional contact metadata failure with known userId = %v", err)
}
for _, text := range []string{"", "not-json", `{"result":[]}`, `{"result":[{"orgEmployeeModel":{}}]}`} {
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: text}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding"}); err != nil {
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil {
t.Fatalf("invalid contact %q: %v", text, err)
}
}
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding"}); err == nil {
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err == nil {
t.Fatal("corp mismatch should fail")
}
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
@@ -371,11 +386,7 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
t.Fatal(err)
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, &authpkg.TokenData{CorpID: "ding"}); err == nil {
t.Fatal("enrichment save should fail")
}
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
data := &authpkg.TokenData{CorpID: "ding"}
data := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
t.Fatalf("enriched = %#v, %v", data, err)
}
@@ -394,6 +405,7 @@ func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
device := authpkg.NewDeviceFlowProvider(configDir, nil)
_, _ = authDeviceLogin(device, ctx)
oauth := authpkg.NewOAuthProvider(configDir, nil)
oauth.NoBrowser = true
_, _ = authOAuthStatus(oauth)
_, _ = authOAuthAccessToken(oauth, ctx)
_, _ = authOAuthLogin(oauth, ctx, true)
@@ -409,7 +421,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
oldDelete := authDeleteTokenData
oldMark := authMarkProfileStatus
oldResolve := authResolveProfile
oldResolveDeletion := authResolveProfileDeletion
oldRevoke := authRevokeToken
oldRevokeForData := authRevokeTokenForData
oldLoadTokenForProfile := authLoadTokenForProfile
oldDeleteProfile := authDeleteProfileToken
oldMigrate := authEnsureProfilesMigration
oldLoadProfiles := authLoadProfiles
@@ -421,7 +436,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
authDeleteTokenData = oldDelete
authMarkProfileStatus = oldMark
authResolveProfile = oldResolve
authResolveProfileDeletion = oldResolveDeletion
authRevokeToken = oldRevoke
authRevokeTokenForData = oldRevokeForData
authLoadTokenForProfile = oldLoadTokenForProfile
authDeleteProfileToken = oldDeleteProfile
authEnsureProfilesMigration = oldMigrate
authLoadProfiles = oldLoadProfiles
@@ -510,20 +528,51 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
t.Fatalf("mark-expired = %v, marked=%v", err, marked)
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) { return nil, false, errors.New("missing") }
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
t.Fatal("missing profile should fail")
}
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "ding"}, nil }
authRevokeToken = func(context.Context) error { return errors.New("ignored") }
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
return &authpkg.Profile{CorpID: "ding", UserID: "user"}, true, nil
}
authLoadTokenForProfile = func(string, string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{CorpID: "ding", UserID: "user"}, nil
}
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return errors.New("ignored") }
var deletedSelector string
authDeleteProfileToken = func(_ string, selector string) error {
deletedSelector = selector
return errors.New("delete")
}
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
t.Fatal("profile delete should fail")
}
authDeleteProfileToken = func(string, string) error { return nil }
if deletedSelector != "ding:user" {
t.Fatalf("exact deletion selector = %q, want stable identity selector", deletedSelector)
}
authDeleteProfileToken = func(_ string, selector string) error {
deletedSelector = selector
return nil
}
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil {
t.Fatal(err)
}
if deletedSelector != "ding:user" {
t.Fatalf("exact deletion selector = %q, want stable identity selector", deletedSelector)
}
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
return &authpkg.Profile{CorpID: "ding", UserID: "user"}, false, nil
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding", UserID: "user"}}}, nil
}
if err := logoutOneProfile(nil, context.Background(), "cfg", "organization-name"); err != nil {
t.Fatal(err)
}
if deletedSelector != "ding" {
t.Fatalf("organization deletion selector = %q, want stable corpId", deletedSelector)
}
authEnsureProfilesMigration = func(string) error { return errors.New("migrate") }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
@@ -536,6 +585,10 @@ func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
}
revokes := 0
authRevokeToken = func(context.Context) error { revokes++; return nil }
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { revokes++; return nil }
authLoadTokenForProfile = func(string, string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token"}, nil
}
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
authDeleteAllTokenData = func(string) error { return nil }
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 1 {
@@ -618,7 +671,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
authDeleteAppConfig = oldDeleteConfig
})
export := newAuthExportCommand()
export := newAuthExportCommandWithSupport(func() error { return nil })
badString := &cobra.Command{}
badString.Flags().Bool("output", false, "")
if err := export.RunE(badString, nil); err == nil {
@@ -658,7 +711,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
if err := export.RunE(export, nil); err != nil {
t.Fatal(err)
}
export = newAuthExportCommand()
export = newAuthExportCommandWithSupport(func() error { return nil })
_, out, _ := authCoverageRoot(export, "table", false)
_ = export.Flags().Set("base64", "true")
export.SetOut(&appFailWriter{err: errors.New("stdout")})
@@ -676,7 +729,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
t.Fatal(err)
}
importCmd := newAuthImportCommand()
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
badInput := &cobra.Command{}
badInput.Flags().Bool("input", false, "")
if err := importCmd.RunE(badInput, nil); err == nil {
@@ -734,7 +787,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
t.Fatal(err)
}
exchange := newAuthExchangeCommand()
exchange := newAuthExchangeCommand(nil)
badCode := &cobra.Command{}
badCode.Flags().Bool("code", false, "")
if err := exchange.RunE(badCode, nil); err == nil {
+539 -21
View File
@@ -20,9 +20,11 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -58,7 +60,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
return err
}
exportCmd := newAuthExportCommand()
exportCmd := newAuthExportCommandWithSupport(func() error { return nil })
var exported bytes.Buffer
exportCmd.SetOut(&exported)
exportCmd.SetErr(&bytes.Buffer{})
@@ -84,7 +86,7 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
return authpkg.PortableImportReport{}, err
}
importCmd := newAuthImportCommand()
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", inputPath, "--base64"})
@@ -96,6 +98,260 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
}
}
func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(t *testing.T) {
exportCmd := newAuthExportCommandWithSupport(func() error {
return errors.New("portable auth export is unavailable for the test backend")
})
exportCmd.SetOut(&bytes.Buffer{})
exportCmd.SetErr(&bytes.Buffer{})
exportCmd.SetArgs([]string{"--base64"})
err := exportCmd.Execute()
if err == nil {
t.Fatal("auth export should reject an unsupported credential backend")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "test backend") {
t.Fatalf("error = %v, want backend-specific reason", err)
}
}
func TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
t.Cleanup(CloseFileLogger)
exportCmd := NewRootCommand()
exportCmd.SetOut(&bytes.Buffer{})
exportCmd.SetErr(&bytes.Buffer{})
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
err := exportCmd.Execute()
if err == nil {
t.Fatal("auth export should reject the Windows DPAPI backend")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want substring %q", err, want)
}
}
}
func TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, ".dws")
keychainDir := filepath.Join(root, "keychain")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv(keychain.StorageDirEnv, keychainDir)
importCmd := newAuthImportCommandWithSupport(func() error {
return errors.New("portable auth import is unavailable for the test backend")
})
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", filepath.Join(root, "missing-bundle.tar.gz")})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import should reject an unsupported credential backend")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "test backend") {
t.Fatalf("error = %v, want backend-specific reason", err)
}
for _, path := range []string{configDir, keychainDir} {
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("unsupported import touched %s: stat error = %v", path, statErr)
}
}
}
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
root := t.TempDir()
// NewRootCommand initializes the normal CLI file logger below configDir.
// Register its cleanup after TempDir so the Windows handle is closed before
// testing removes the temporary directory.
t.Cleanup(CloseFileLogger)
configDir := filepath.Join(root, ".dws")
keychainDir := filepath.Join(root, "keychain")
inputPath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(inputPath, []byte("the capability guard must run before this input is read"), 0o600); err != nil {
t.Fatalf("write input sentinel error = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv(keychain.StorageDirEnv, keychainDir)
importCmd := NewRootCommand()
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import should reject the Windows DPAPI backend")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want substring %q", err, want)
}
}
// The root command may create configDir/logs as part of normal CLI startup.
// The capability guard must still run before any auth state is imported.
for _, path := range []string{
keychainDir,
authpkg.ProfilesPath(configDir),
filepath.Join(configDir, "app.json"),
filepath.Join(configDir, "token.json"),
} {
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr)
}
}
}
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackendWithPopulatedCredentialBeforeRead(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
previous, previousErr := authpkg.LoadTokenDataKeychain()
if previousErr != nil && !errors.Is(previousErr, authpkg.ErrTokenDataNotFound) {
t.Fatalf("capture existing Windows credential: %v", previousErr)
}
hadPrevious := previousErr == nil
t.Cleanup(func() {
if hadPrevious {
_ = authpkg.SaveTokenDataKeychain(previous)
} else {
_ = authpkg.DeleteTokenDataKeychain()
}
})
want := &authpkg.TokenData{
AccessToken: "windows-existing-access",
RefreshToken: "windows-existing-refresh",
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "windows-existing-corp",
}
if err := authpkg.SaveTokenDataKeychain(want); err != nil {
t.Fatalf("seed cleanup-scoped Windows DPAPI credential: %v", err)
}
originalTarget := authPortableTargetPopulated
originalRead := authReadFile
targetChecks := 0
bundleReads := 0
authPortableTargetPopulated = func(configDir string) bool {
targetChecks++
return originalTarget(configDir)
}
authReadFile = func(path string) ([]byte, error) {
bundleReads++
return originalRead(path)
}
t.Cleanup(func() {
authPortableTargetPopulated = originalTarget
authReadFile = originalRead
})
root := t.TempDir()
inputPath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(inputPath, []byte("unsupported Windows import must not read this bundle"), 0o600); err != nil {
t.Fatalf("write bundle sentinel: %v", err)
}
t.Setenv("DWS_CONFIG_DIR", filepath.Join(root, ".dws"))
importCmd := newAuthImportCommand()
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", inputPath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import should reject a populated Windows DPAPI backend")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
for _, required := range []string{"Windows", "DPAPI", "HKCU"} {
if !strings.Contains(err.Error(), required) {
t.Fatalf("error = %v, want substring %q", err, required)
}
}
if strings.Contains(err.Error(), "--force") {
t.Fatalf("unsupported Windows import suggested impossible --force remediation: %v", err)
}
if targetChecks != 0 || bundleReads != 0 {
t.Fatalf("unsupported Windows import inspected credentials/bundle: target_checks=%d bundle_reads=%d", targetChecks, bundleReads)
}
got, err := authpkg.LoadTokenDataKeychain()
if err != nil {
t.Fatalf("reload Windows DPAPI credential after rejection: %v", err)
}
if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || got.CorpID != want.CorpID {
t.Fatalf("Windows auth state changed after rejected import: got=%#v want=%#v", got, want)
}
}
func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
root := t.TempDir()
t.Cleanup(CloseFileLogger)
configDir := filepath.Join(root, ".dws")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "existing",
RefreshToken: "existing-refresh",
RefreshExpAt: time.Now().Add(24 * time.Hour),
}); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
bundlePath := filepath.Join(root, "bundle.tar.gz")
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
var stderr bytes.Buffer
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetArgs([]string{"--input", bundlePath})
err := importCmd.Execute()
if err == nil {
t.Fatal("auth import without --force should fail when auth exists")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %v, want --force hint", err)
}
}
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
originalTarget := authPortableTargetPopulated
authPortableTargetPopulated = func(string) bool { return true }
@@ -110,10 +366,9 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
t.Fatalf("write bundle stub error = %v", err)
}
importCmd := newAuthImportCommand()
var stderr bytes.Buffer
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
importCmd.SetOut(&bytes.Buffer{})
importCmd.SetErr(&stderr)
importCmd.SetErr(&bytes.Buffer{})
importCmd.SetArgs([]string{"--input", bundlePath})
err := importCmd.Execute()
if err == nil {
@@ -244,7 +499,7 @@ func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
}
}
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
func TestAuthStatusRefreshFailureReportsUnauthenticatedDiagnostic(t *testing.T) {
// Isolate keychain storage to a per-test directory so the saved
// token can't leak into other test packages running in parallel.
t.Setenv(keychain.StorageDirEnv, t.TempDir())
@@ -263,6 +518,9 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "dingcorp",
UserID: "user-dingcorp",
ClientID: "client-dingcorp",
Source: "mcp",
})
if err != nil {
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
@@ -280,7 +538,7 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "status"})
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
@@ -291,8 +549,18 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
t.Fatal("secure token data should remain in keychain after refresh failure")
}
if !bytes.Contains(out.Bytes(), []byte("\"authenticated\"")) {
t.Fatalf("output should still report authenticated status:\n%s", out.String())
var resp authStatusResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if resp.Authenticated {
t.Fatalf("authenticated = true after refresh failure: %+v", resp)
}
if resp.Reason != "token_refresh_failed" {
t.Fatalf("reason = %q, want token_refresh_failed: %+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "refresh failed") {
t.Fatalf("message = %q, want original refresh failure", resp.Message)
}
}
@@ -340,8 +608,35 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
t.Fatalf("currentProfile = %q, want unchanged exact secondary identity", cfg.CurrentProfile)
}
}
func TestAuthStatusRejectsAmbiguousProfileSelector(t *testing.T) {
first := authLogoutTestToken("corp_first")
first.CorpName = "Shared Org"
second := authLogoutTestToken("corp_second")
second.CorpName = "Shared Org"
setupAuthLogoutProfiles(t, first, second)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status", "--profile", "Shared Org"})
err := cmd.Execute()
if err == nil {
t.Fatalf("auth status accepted ambiguous profile selector\noutput:\n%s", out.String())
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("error = %T %v, want validation error", err, err)
}
for _, candidate := range []string{"corp_first", "corp_second"} {
if !strings.Contains(err.Error(), candidate) {
t.Fatalf("error = %q, want candidate %q", err.Error(), candidate)
}
}
}
@@ -515,8 +810,8 @@ func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q", cfg.PrimaryProfile, cfg.CurrentProfile)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
@@ -536,6 +831,102 @@ func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
}
}
func TestAuthLogoutExactProfilePreservesSameCorpAccount(t *testing.T) {
first := authLogoutTestToken("corp_same")
first.UserID = "user_1"
second := authLogoutTestToken("corp_same")
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
configDir := setupAuthLogoutProfiles(t, first, second)
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_same:user_2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout exact profile error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != "user_1" {
t.Fatalf("profiles = %#v, want only user_1 retained", cfg.Profiles)
}
if authpkg.TokenDataExistsKeychainForIdentity("corp_same", "user_2") {
t.Fatal("selected identity token should be deleted")
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "corp_same")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(org) error = %v", err)
}
if loaded.UserID != "user_1" || loaded.AccessToken != first.AccessToken {
t.Fatalf("org current token = %#v, want retained user_1", loaded)
}
}
func TestAuthLogoutLocalProfileNameRevokesOnlySelectedAccount(t *testing.T) {
first := authLogoutTestToken("corp_same")
first.UserID = "user_1"
first.UserName = "账号一"
second := authLogoutTestToken("corp_same")
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
second.UserName = "账号二"
configDir := setupAuthLogoutProfiles(t, first, second)
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
var selector string
for _, profile := range cfg.Profiles {
if profile.UserID == "user_2" {
selector = profile.Name
}
}
if selector == "" || selector == second.CorpName {
t.Fatalf("second local profile name = %q, want unique non-org alias", selector)
}
requests := 0
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
requests++
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", selector})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout local profile error = %v\noutput:\n%s", err, out.String())
}
if requests != 1 {
t.Fatalf("remote revoke requests = %d, want 1", requests)
}
}
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
newRoot := func(t *testing.T) *cobra.Command {
t.Helper()
@@ -621,8 +1012,15 @@ func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
}
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
t.Setenv("DWS_DEBUG_AUTH", "1")
var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("profile", "", "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
@@ -647,6 +1045,11 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
if !cfg.Yes {
t.Fatal("Yes = false, want true")
}
if got := logs.String(); !strings.Contains(got, `"msg":"auth.login.request"`) ||
!strings.Contains(got, `"profile_selector":""`) ||
!strings.Contains(got, `"target_corp_id":""`) {
t.Fatalf("login request diagnostic log missing selector resolution:\n%s", got)
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
@@ -697,6 +1100,12 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}}
authpkg.SetRuntimeProfile("corp_old:user_old")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
var authorizationProfiles []string
fake.beforeCall = func(string) {
authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile())
}
cmd := newAuthLoginCommand(fake)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -715,6 +1124,61 @@ func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
if got := fake.args[0]["recommend"]; got != true {
t.Fatalf("--recommend plan recommend = %#v, want true", got)
}
for _, profile := range authorizationProfiles {
if profile != "" {
t.Fatalf("manual token post-login profile = %q, want empty runtime selector", profile)
}
}
if got := authpkg.RuntimeProfile(); got != "corp_old:user_old" {
t.Fatalf("runtime profile after authorization = %q, want restored selector", got)
}
}
func TestAuthLoginRecommendUsesNewExactIdentity(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldOAuthLogin := authOAuthLogin
oldInteractive := authLoginInteractiveTerminal
t.Cleanup(func() {
authOAuthLogin = oldOAuthLogin
authLoginInteractiveTerminal = oldInteractive
authpkg.SetRuntimeProfile("")
})
authLoginInteractiveTerminal = func() bool { return false }
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "new-token",
CorpID: "corp_same",
UserID: "user_new",
ExpiresAt: time.Now().Add(time.Hour),
}, nil
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"data":{"items":[],"selectedScopes":[]}}`,
}}
var authorizationProfiles []string
fake.beforeCall = func(string) {
authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile())
}
authpkg.SetRuntimeProfile("corp_same:user_old")
cmd := newAuthLoginCommand(fake)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--recommend"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth login --recommend error = %v", err)
}
for _, profile := range authorizationProfiles {
if profile != "corp_same:user_new" {
t.Fatalf("post-login authorization profile = %q, want new exact identity", profile)
}
}
if got := authpkg.RuntimeProfile(); got != "corp_same:user_old" {
t.Fatalf("runtime profile after authorization = %q, want restored old identity", got)
}
}
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
@@ -939,7 +1403,7 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
}
}
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
func TestEnrichAuthLoginProfileFromContactBeforePersist(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
@@ -954,12 +1418,8 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
ClientID: "client-id",
Source: "mcp",
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
`{"success":true,"result":[{"isAdmin":false,"orgEmployeeModel":{"jobNumber":"202397","orgId":null,"orgName":"钉钉(中国)信息技术有限公司","orgUserId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
@@ -970,6 +1430,16 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() before persist error = %v", err)
}
if len(cfg.Profiles) != 0 {
t.Fatalf("identity enrichment persisted token early: %#v", cfg.Profiles)
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
if err != nil {
@@ -981,8 +1451,50 @@ func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
}
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
if len(fake.args[0]) != 0 {
t.Fatalf("contact profile args = %#v, want no arguments", fake.args[0])
}
if len(fake.tokens) != 1 || fake.tokens[0] != "access-token" {
t.Fatalf("token overrides = %v, want access-token", fake.tokens)
}
}
func TestEnrichAuthLoginProfileLogsIdentityResolutionWithoutCredentials(t *testing.T) {
t.Setenv("DWS_DEBUG_AUTH", "1")
var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
token := &authpkg.TokenData{
AccessToken: "secret-access-token",
RefreshToken: "secret-refresh-token",
CorpID: "ding_same_corp",
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding_same_corp","orgName":"同一组织","userId":"user_two","orgUserName":"账号二"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), t.TempDir(), fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
}
got := logs.String()
for _, want := range []string{
`"msg":"auth.login.identity.lookup.start"`,
`"msg":"auth.login.identity.lookup.result"`,
`"corp_id":"ding_same_corp"`,
`"user_id":"user_two"`,
`"user_name":"账号二"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("diagnostic logs missing %q:\n%s", want, got)
}
}
for _, secret := range []string{"secret-access-token", "secret-refresh-token"} {
if strings.Contains(got, secret) {
t.Fatalf("diagnostic logs exposed credential %q:\n%s", secret, got)
}
}
}
@@ -996,6 +1508,7 @@ type authLoginRecommendSequenceCaller struct {
responses []string
tools []string
args []map[string]any
tokens []string
beforeCall func(toolName string)
}
@@ -1017,6 +1530,11 @@ func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string,
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (f *authLoginRecommendSequenceCaller) CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
f.tokens = append(f.tokens, token)
return f.CallTool(ctx, productID, toolName, args)
}
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
+44 -10
View File
@@ -74,6 +74,19 @@ func TestCrossPlatformCoverageToolCallerAdapterCoverage(t *testing.T) {
t.Fatal("adapter flags were not forwarded")
}
adapter.flags.DryRun = false
if _, err := (*toolCallerAdapter)(nil).CallToolWithToken(context.Background(), "token", "doc", "get", nil); err == nil {
t.Fatal("nil adapter token override succeeded")
}
authpkg.SetRuntimeProfile("saved-profile")
adapter.flags.Token = "saved-token"
runner.result = executor.Result{Response: map[string]any{"content": []any{map[string]any{"type": "text", "text": "ok"}}}}
if _, err := adapter.CallToolWithToken(context.Background(), "temporary-token", "doc", "get", nil); err != nil {
t.Fatal(err)
}
if adapter.flags.Token != "saved-token" || authpkg.RuntimeProfile() != "saved-profile" {
t.Fatal("token override state was not restored")
}
authpkg.SetRuntimeProfile("")
runner.err = errors.New("runner failure")
if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) {
@@ -2000,6 +2013,31 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatal(err)
}
for _, profile := range cfg.Profiles {
data := &authpkg.TokenData{
AccessToken: "token-" + profile.CorpID,
CorpID: profile.CorpID,
UserID: profile.UserID,
}
var err error
if profile.UserID == "" {
err = authpkg.SaveTokenDataKeychainForCorpID(profile.CorpID, data)
} else {
err = authpkg.SaveTokenDataKeychainForIdentity(profile.CorpID, profile.UserID, data)
}
if err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() {
for _, profile := range cfg.Profiles {
if profile.UserID == "" {
_ = authpkg.DeleteTokenDataKeychainForCorpID(profile.CorpID)
} else {
_ = authpkg.DeleteTokenDataKeychainForIdentity(profile.CorpID, profile.UserID)
}
}
})
oldInteractive := profileSwitchInteractiveTerminal
oldSelector := profileSwitchSelector
t.Cleanup(func() {
@@ -2094,15 +2132,11 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
}
_ = profileSwitchSortedProfiles(cfg.Profiles)
for _, raw := range []string{"", "bad", now.Format(time.RFC3339)} {
_, _ = parseProfileSwitchTime(raw)
}
for _, p := range cfg.Profiles {
_, _ = profileSwitchSortTime(p)
_ = profileSwitchOptionLabel(p, cfg)
_, _ = profileSwitchProfileCells(p, cfg)
}
_ = profileSwitchProfileIndex(cfg.Profiles, "missing")
_ = profileSwitchProfileIndex(cfg.Profiles, "missing", cfg)
_ = profileSwitchBorder("a", "b", "c")
_ = profileSwitchTableLine("org", "status")
_ = profileSwitchStyledTableLine("org", "status", profileSwitchNormalRowStyle())
@@ -2116,9 +2150,9 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
_ = profileSwitchTitleStyle()
_ = profileSwitchMutedStyle()
writeProfileListTable(io.Discard, nil)
writeProfileListTable(io.Discard, cfg)
if err := writeProfileListJSON(io.Discard, cfg); err != nil || writeProfileUseJSON(io.Discard, nil, nil) != nil || writeProfileUseJSON(io.Discard, &cfg.Profiles[0], cfg) != nil {
writeProfileListTable(io.Discard, "", nil)
writeProfileListTable(io.Discard, "", cfg)
if err := writeProfileListJSON(io.Discard, "", cfg); err != nil || writeProfileUseJSON(io.Discard, nil, nil) != nil || writeProfileUseJSON(io.Discard, &cfg.Profiles[0], cfg) != nil {
t.Fatal("profile JSON write failed")
}
_ = profileUseMessage(nil)
@@ -2126,8 +2160,8 @@ func TestCrossPlatformCoverageProfileCommandAndModelCoverage(t *testing.T) {
_ = profileUseMessage(&p)
_ = profileOrgName(p)
}
_ = profileViews(nil)
_ = profileViews(cfg)
_ = profileViews("", nil)
_ = profileViews("", cfg)
for _, limit := range []int{0, 2, 5, 40} {
_ = clipProfileCell("abcdefgh", limit)
_ = clipProfileDisplayCell("中文abcdefgh", limit)
+7 -8
View File
@@ -800,15 +800,14 @@ 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.
// Forward the exact account so the detached _bus child resolves the same
// credentials as the parent, including when one organization has multiple
// logged-in users.
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
args = append(args, "--profile", cid)
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
CorpID: identity.CorpID,
UserID: identity.UserID,
}))
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", ticketMode)
+3 -2
View File
@@ -41,17 +41,18 @@ func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
args := personalBusSpawnArgs(personal.Identity{
CorpID: "dinga626d60c1128d449",
UserID: "user_123",
SourceID: "open",
}, "", "")
found := false
for i := 0; i+1 < len(args); i++ {
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449:user_123" {
found = true
break
}
}
if !found {
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
t.Errorf("spawn args must forward --profile <corpId>:<userId>; got %v", args)
}
// No CorpID → no --profile appended (avoid an empty flag value).
+1 -1
View File
@@ -47,7 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
_ = cmd.PersistentFlags().MarkHidden("output")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定组织或账号;支持 corpId/corpName 与 userId/userName 组合,推荐使用 profile list 返回的 corpId:userId;多个按 CSV 逗号分隔")
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
_ = cmd.PersistentFlags().MarkHidden("token")
+61 -2
View File
@@ -50,8 +50,9 @@ func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
}
resultPayload := entry["result"].(map[string]any)
if resultPayload["runtimeProfile"] != wantCorpID {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
wantProfile := wantCorpID + ":user-" + wantCorpID
if resultPayload["runtimeProfile"] != wantProfile {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantProfile)
}
}
}
@@ -75,6 +76,37 @@ func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
}
}
func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) {
first := authLogoutTestToken("corp_same")
first.UserID = "user_1"
second := authLogoutTestToken("corp_same")
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
configDir := setupAuthLogoutProfiles(t, first, second)
selections, multi, err := resolveMultiProfileSelections(
configDir,
"corp_same,corp_same:user_1,corp_same:user_2",
)
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi {
t.Fatal("multi = false, want true")
}
if len(selections) != 2 {
t.Fatalf("selections len = %d, want 2: %#v", len(selections), selections)
}
got := []string{
authpkg.ProfileSelector(selections[0].Profile),
authpkg.ProfileSelector(selections[1].Profile),
}
if strings.Join(got, ",") != "corp_same:user_2,corp_same:user_1" {
t.Fatalf("resolved identities = %v, want current user_2 then user_1", got)
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
@@ -91,11 +123,38 @@ func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
}
if got := result.Response["content"].(map[string]any)["runtimeProfile"]; got != "corp_a:user-corp_a" {
t.Fatalf("fallback runtime profile = %#v, want exact identity selector", got)
}
if got := authpkg.RuntimeProfile(); got != "corp_a" {
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
}
}
func TestRuntimeRunnerRejectsAmbiguousSingleProfile(t *testing.T) {
first := authLogoutTestToken("corp_first")
first.CorpName = "Shared Org"
second := authLogoutTestToken("corp_second")
second.CorpName = "Shared Org"
setupAuthLogoutProfiles(t, first, second)
authpkg.SetRuntimeProfile("Shared Org")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
_, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
})
if err == nil {
t.Fatal("Run() accepted ambiguous single profile selector")
}
for _, candidate := range []string{"corp_first", "corp_second"} {
if !strings.Contains(err.Error(), candidate) {
t.Fatalf("error = %q, want candidate %q", err.Error(), candidate)
}
}
}
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
cfg, err := authpkg.LoadProfiles(configDir)
+184 -89
View File
@@ -18,7 +18,6 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
@@ -34,15 +33,17 @@ func newProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "组织 profile 管理",
Long: `管理本机已登录的钉钉组织 profile。
Long: `管理本机已登录的钉钉账号 profile。
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
profile switch/use 才会持久修改默认组织上下文。`,
每个 profile 由 corpId + userId 唯一确定,同一组织可保存多个账号。业务命令可通过
全局 --profile 临时指定组织或账号,profile switch/use 才会持久修改默认账号。`,
Example: ` dws profile list
dws profile switch
dws profile switch <corpId>
dws profile switch <corpId>:<userId>
dws profile switch "<corpName>:<userName>"
dws profile switch -
dws --profile <corpId> contact user get-self`,
dws --profile <corpId>:<userId> contact user get-self`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
@@ -58,8 +59,8 @@ func newProfileListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出已登录组织 profile",
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
Short: "列出全部已登录账号 profile",
Long: "列出本机全部已登录账号。状态和到期时间直接读取各身份 Token,列表本身不会刷新 Token。",
Example: ` dws profile list
dws profile list --format json`,
Args: cobra.NoArgs,
@@ -75,9 +76,9 @@ func newProfileListCommand() *cobra.Command {
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
return writeProfileListJSON(cmd.OutOrStdout(), configDir, cfg)
}
writeProfileListTable(cmd.OutOrStdout(), cfg)
writeProfileListTable(cmd.OutOrStdout(), configDir, cfg)
return nil
},
}
@@ -85,9 +86,9 @@ func newProfileListCommand() *cobra.Command {
func newProfileUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use [name|corpId|-]",
Short: "切换当前组织 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
Use: "use [profile-selector|-]",
Short: "切换当前账号 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。选择器支持组织 ID/名称、账号 ID/名称组合或本地 profile 名;- 切回上一个账号。",
Example: ` dws profile use <corpId>
dws profile use --name "钉钉"
dws profile use -`,
@@ -103,18 +104,21 @@ func newProfileUseCommand() *cobra.Command {
func newProfileSwitchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "switch [name|corpId|-]",
Short: "切换当前组织 profile",
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
Use: "switch [profile-selector|-]",
Short: "切换当前账号 profile",
Long: `切换默认账号 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
需要只影响单次业务命令时,请使用全局 --profile。`,
选择器支持 corpId:userId、corpId:userName、corpName:userId、corpName:userName,
也兼容单独的 corpId、唯一 corpName 和本地 profile 名。组织或账号名称重名时会报错,
要求改用稳定的 corpId:userId。不带参数时交互选择;单次执行请使用全局 --profile。`,
Example: ` dws profile switch
dws profile switch <corpId>
dws profile switch <corpId>:<userId>
dws profile switch "<corpName>:<userName>"
dws profile switch --corpId <corpId>
dws profile switch --name "钉钉"
dws profile switch -
dws --profile <corpId> contact user get-self`,
dws --profile <corpId>:<userId> contact user get-self`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -142,6 +146,7 @@ var (
profileSwitchTUIRunner = runProfileSwitchTUI
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
profileLoadProfiles = authpkg.LoadProfiles
profileLoadTokenData = authpkg.LoadTokenDataForProfile
profileUsePrevious = authpkg.UsePreviousProfile
profileSetCurrent = authpkg.SetCurrentProfile
profileRunTeaProgram = (*tea.Program).Run
@@ -247,7 +252,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <corpId|corpId:userId|corpName:userName>")
}
if err := profileEnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
@@ -261,10 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = strings.TrimSpace(cfg.PrimaryProfile)
}
if choice == "" {
choice = cfg.Profiles[0].CorpID
choice = authpkg.ProfileSelector(cfg.Profiles[0])
}
return profileSwitchTUIRunner(cmd, cfg, choice)
}
@@ -306,7 +308,7 @@ func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string
if cfg != nil {
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
}
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID, cfg)
if model.selected < 0 {
model.selected = 0
}
@@ -315,40 +317,7 @@ func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string
}
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
sorted := append([]authpkg.Profile(nil), profiles...)
sort.SliceStable(sorted, func(i, j int) bool {
left, leftOK := profileSwitchSortTime(sorted[i])
right, rightOK := profileSwitchSortTime(sorted[j])
if leftOK && rightOK && !left.Equal(right) {
return left.After(right)
}
if leftOK != rightOK {
return leftOK
}
return false
})
return sorted
}
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
if t, ok := parseProfileSwitchTime(raw); ok {
return t, true
}
}
return time.Time{}, false
}
func parseProfileSwitchTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return t, true
return append([]authpkg.Profile(nil), profiles...)
}
func (m profileSwitchTUIModel) Init() tea.Cmd {
@@ -459,17 +428,31 @@ func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return strings.TrimSpace(m.profiles[m.selected].CorpID)
return authpkg.ProfileSelector(m.profiles[m.selected])
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
corpID = strings.TrimSpace(corpID)
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
selector = strings.TrimSpace(selector)
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
return i
}
}
return -1
}
fallback := -1
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID {
return i
if strings.TrimSpace(p.CorpID) == selector {
if fallback < 0 {
fallback = i
}
if profileIsOrgCurrent(p, cfg) {
return i
}
}
}
return -1
return fallback
}
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
@@ -481,11 +464,29 @@ func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) st
}
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
orgName := profileOrgName(p)
if cfg != nil {
sameCorp := 0
for _, candidate := range cfg.Profiles {
if candidate.CorpID == p.CorpID {
sameCorp++
}
}
if sameCorp > 1 {
user := strings.TrimSpace(p.UserName)
if user == "" {
user = strings.TrimSpace(p.UserID)
}
if user != "" {
orgName += " / " + user
}
}
}
return orgName, profileSwitchProfileStatus(p, cfg)
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && p.CorpID == cfg.CurrentProfile {
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
return "当前组织"
}
return ""
@@ -575,6 +576,7 @@ type profileUseResponse struct {
}
type profileView struct {
Profile string `json:"profile"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId,omitempty"`
@@ -588,15 +590,16 @@ type profileView struct {
LastUsedAt string `json:"lastUsedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
IsOrgCurrent bool `json:"isOrgCurrent"`
}
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
func writeProfileListJSON(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) error {
resp := profileListResponse{
Success: true,
PrimaryProfile: cfg.PrimaryProfile,
CurrentProfile: cfg.CurrentProfile,
PreviousProfile: cfg.PreviousProfile,
Profiles: profileViews(cfg),
Profiles: profileViews(configDir, cfg),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
@@ -612,44 +615,50 @@ func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.Pro
primaryProfile = cfg.PrimaryProfile
currentProfile = cfg.CurrentProfile
}
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
resp.Profile = profileViewFromProfile(
*profile,
cfg,
primaryProfile,
currentProfile,
profileCountForCorp(cfg, profile.CorpID) <= 1,
nil,
)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) {
if cfg == nil || len(cfg.Profiles) == 0 {
fmt.Fprintln(w, "未找到已登录 profile")
return
}
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
view := profileViewFromProfile(
p,
cfg,
cfg.PrimaryProfile,
cfg.CurrentProfile,
profileCountForCorp(cfg, p.CorpID) == 1,
loadProfileTokenState(configDir, p),
)
current := ""
if p.CorpID == cfg.CurrentProfile {
if view.IsCurrent {
current = "*"
}
primary := ""
if p.CorpID == cfg.PrimaryProfile {
primary = "*"
}
user := p.UserName
if user == "" {
user = p.UserID
}
status := p.Status
if status == "" {
status = authpkg.ProfileStatusActive
}
fmt.Fprintf(
w,
"%-3s %-3s %-28s %-34s %-10s %s\n",
"%-3s %-28s %-34s %-10s %s\n",
current,
primary,
clipProfileCell(profileOrgName(p), 28),
clipProfileCell(p.CorpID, 34),
status,
view.Status,
user,
)
}
@@ -677,19 +686,40 @@ func profileOrgName(p authpkg.Profile) string {
return strings.TrimSpace(p.CorpID)
}
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
type profileTokenState struct {
Status string
ExpiresAt string
RefreshExpAt string
}
func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
if cfg == nil {
return nil
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
views = append(views, profileViewFromProfile(
p,
cfg,
cfg.PrimaryProfile,
cfg.CurrentProfile,
profileCountForCorp(cfg, p.CorpID) == 1,
loadProfileTokenState(configDir, p),
))
}
return views
}
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
return profileView{
func profileViewFromProfile(
p authpkg.Profile,
cfg *authpkg.ProfilesConfig,
primaryProfile, currentProfile string,
onlyAccountInOrg bool,
tokenState *profileTokenState,
) profileView {
isOrgCurrent := profileIsOrgCurrent(p, cfg)
view := profileView{
Profile: authpkg.ProfileSelector(p),
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
@@ -701,9 +731,74 @@ func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile st
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: p.CorpID == primaryProfile,
IsCurrent: p.CorpID == currentProfile,
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg),
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg),
IsOrgCurrent: isOrgCurrent,
}
if tokenState != nil {
view.Status = tokenState.Status
view.ExpiresAt = tokenState.ExpiresAt
view.RefreshExpAt = tokenState.RefreshExpAt
}
return view
}
func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState {
data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile))
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
}
if err != nil {
return &profileTokenState{Status: authpkg.ProfileStatusUnavailable}
}
status := authpkg.ProfileStatusExpired
if data.IsAccessTokenValid() {
status = authpkg.ProfileStatusActive
}
return &profileTokenState{
Status: status,
ExpiresAt: profileTokenTime(data.ExpiresAt),
RefreshExpAt: profileTokenTime(data.RefreshExpAt),
}
}
func profileTokenTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339)
}
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, isOrgCurrent, onlyAccountInOrg bool) bool {
selector = strings.TrimSpace(selector)
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
}
return selector == strings.TrimSpace(profile.CorpID) && (isOrgCurrent || onlyAccountInOrg)
}
func profileCountForCorp(cfg *authpkg.ProfilesConfig, corpID string) int {
if cfg == nil {
return 0
}
count := 0
for _, profile := range cfg.Profiles {
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(corpID) {
count++
}
}
return count
}
func profileIsOrgCurrent(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) bool {
if cfg == nil {
return false
}
selector := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(profile.CorpID)])
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
}
return profileCountForCorp(cfg, profile.CorpID) == 1
}
func clipProfileCell(value string, limit int) string {
+201 -19
View File
@@ -16,9 +16,11 @@ package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
@@ -80,8 +82,10 @@ func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
if !resp.Success {
t.Fatal("success = false, want true")
}
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
if resp.PrimaryProfile != "" ||
resp.CurrentProfile != "corp_secondary:user-corp_secondary" ||
resp.PreviousProfile != "corp_primary:user-corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
@@ -96,6 +100,181 @@ func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
}
}
func TestProfileListRootCommandJSONIncludesAllAccountsInSameCorp(t *testing.T) {
first := authLogoutTestToken("corp_same")
first.UserID = "user_1"
first.UserName = "账号一"
second := authLogoutTestToken("corp_same")
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
second.UserName = "账号二"
setupAuthLogoutProfiles(t, first, second)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2: %#v", len(resp.Profiles), resp.Profiles)
}
got := make(map[string]profileView, len(resp.Profiles))
for _, profile := range resp.Profiles {
got[profile.Profile] = profile
}
if _, ok := got["corp_same:user_1"]; !ok {
t.Fatalf("profiles missing corp_same:user_1: %#v", resp.Profiles)
}
current, ok := got["corp_same:user_2"]
if !ok {
t.Fatalf("profiles missing corp_same:user_2: %#v", resp.Profiles)
}
if !current.IsOrgCurrent || !current.IsCurrent || current.IsPrimary {
t.Fatalf("last login account markers = %#v, want org-current/current and deprecated primary=false", current)
}
if got["corp_same:user_1"].IsOrgCurrent {
t.Fatalf("older account unexpectedly marked org current: %#v", got["corp_same:user_1"])
}
}
func TestProfileListUsesRealIdentityTokenState(t *testing.T) {
token := authLogoutTestToken("corp_real")
token.ExpiresAt = time.Date(2026, 7, 16, 17, 38, 0, 0, time.Local)
token.RefreshExpAt = time.Date(2026, 8, 16, 17, 38, 0, 0, time.Local)
configDir := setupAuthLogoutProfiles(t, token)
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.Profiles[0].Status = authpkg.ProfileStatusActive
cfg.Profiles[0].ExpiresAt = "2026-07-16T22:29:00+08:00"
cfg.Profiles[0].RefreshExpAt = "2026-09-16T22:29:00+08:00"
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if len(resp.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1", len(resp.Profiles))
}
got := resp.Profiles[0]
if got.ExpiresAt != token.ExpiresAt.Format(time.RFC3339) {
t.Fatalf("expiresAt = %q, want real token %q", got.ExpiresAt, token.ExpiresAt.Format(time.RFC3339))
}
if got.RefreshExpAt != token.RefreshExpAt.Format(time.RFC3339) {
t.Fatalf("refreshExpAt = %q, want real token %q", got.RefreshExpAt, token.RefreshExpAt.Format(time.RFC3339))
}
if got.Status != authpkg.ProfileStatusExpired {
t.Fatalf("status = %q, want expired", got.Status)
}
}
func TestProfileListDistinguishesMissingAndUnavailableTokenState(t *testing.T) {
originalLoad := profileLoadTokenData
t.Cleanup(func() { profileLoadTokenData = originalLoad })
profile := authpkg.Profile{CorpID: "corp", UserID: "user"}
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
return nil, authpkg.ErrTokenDataNotFound
}
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusRevoked {
t.Fatalf("missing token status = %q, want revoked", state.Status)
}
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
return nil, errors.New("keychain unavailable")
}
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusUnavailable {
t.Fatalf("unavailable token status = %q, want unavailable", state.Status)
}
}
func TestProfileListCurrentFlagsUseStoredExactSelectors(t *testing.T) {
first := authLogoutTestToken("corp_same")
first.UserID = "user_1"
first.UserName = "账号一"
second := authLogoutTestToken("corp_same")
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
second.UserName = "账号二"
configDir := setupAuthLogoutProfiles(t, first, second)
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.PrimaryProfile = ""
cfg.CurrentProfile = "corp_same:user_1"
cfg.OrgCurrentProfiles["corp_same"] = "corp_same:user_1"
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
got := make(map[string]profileView, len(resp.Profiles))
for _, profile := range resp.Profiles {
got[profile.Profile] = profile
}
if !got["corp_same:user_1"].IsCurrent || !got["corp_same:user_1"].IsOrgCurrent {
t.Fatalf("first account flags = %#v, want current and org current", got["corp_same:user_1"])
}
if got["corp_same:user_2"].IsCurrent || got["corp_same:user_2"].IsOrgCurrent {
t.Fatalf("second account flags = %#v, want neither current nor org current", got["corp_same:user_2"])
}
if got["corp_same:user_1"].IsPrimary || got["corp_same:user_2"].IsPrimary {
t.Fatalf("deprecated isPrimary should be false without primaryProfile: %#v", got)
}
}
func TestProfileListTableOmitsDeprecatedPrimaryColumn(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_table"))
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
}
header := strings.SplitN(out.String(), "\n", 2)[0]
if strings.Contains(header, "PRI") {
t.Fatalf("profile list header still contains deprecated PRI column: %q", header)
}
}
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
@@ -118,8 +297,9 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
@@ -145,8 +325,9 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" ||
cfg.PreviousProfile != "corp_primary:user-corp_primary" {
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err = authpkg.LoadTokenData(configDir)
if err != nil {
@@ -178,8 +359,9 @@ func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *t
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
@@ -209,8 +391,8 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
}
cmd = NewRootCommand()
@@ -226,8 +408,8 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary:user-corp_secondary", cfg.CurrentProfile)
}
}
@@ -287,8 +469,8 @@ func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
}
}
@@ -358,7 +540,7 @@ func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
}
}
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
func TestProfileSwitchTUIPreservesStoredOrderInsteadOfSortingByTime(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "old",
CurrentProfile: "old",
@@ -370,7 +552,7 @@ func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
}
model := newProfileSwitchTUIModel(cfg, "old")
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
wantOrder := []string{"new", "fallback", "old"}
wantOrder := []string{"old", "new", "fallback"}
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
}
@@ -507,8 +689,8 @@ func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
}
}
@@ -549,7 +731,7 @@ func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
},
}
var buf bytes.Buffer
writeProfileListTable(&buf, cfg)
writeProfileListTable(&buf, "", cfg)
out := buf.String()
for _, want := range []string{
"ORG_NAME",
+1 -1
View File
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageProfileRemainingCoverage(t *testing.T) {
t.Fatal(err)
}
}
if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" {
if len(choices) != 3 || choices[0] != "current" || choices[1] != "first" || choices[2] != "first" {
t.Fatalf("profile choices = %#v", choices)
}
+22 -9
View File
@@ -24,6 +24,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -748,7 +749,10 @@ func validateOptionalPath(flagName, path string) error {
// fileLogger holds the package-level file logger for diagnostics.
// It is initialized by configureLogLevel and closed by CloseFileLogger.
var fileLogger *logging.FileLogger
var (
fileLoggerMu sync.Mutex
fileLogger *logging.FileLogger
)
// configureLogLevel sets the global slog level based on --debug and --verbose flags
// and initializes the file logger for diagnostics.
@@ -770,18 +774,24 @@ func configureLogLevel(flags *GlobalFlags) {
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
// regardless of stderr level. All slog calls are captured for diagnostics.
// A previously closed rotating writer may have been reopened by slog calls
// made while constructing another root command, so close it before replacing
// the package pointer during reentrant executions.
CloseFileLogger()
fileLogger = logging.Setup(defaultConfigDir())
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
logger := logging.Setup(defaultConfigDir())
fileHandler := slog.NewJSONHandler(logger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
defaultLogger := slog.New(logging.NewMultiHandler(stderrHandler, fileHandler))
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
fileLoggerMu.Lock()
defer fileLoggerMu.Unlock()
previous := fileLogger
fileLogger = logger
slog.SetDefault(defaultLogger)
if previous != nil {
_ = previous.Close()
}
}
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
func FileLoggerInstance() *slog.Logger {
fileLoggerMu.Lock()
defer fileLoggerMu.Unlock()
if fileLogger == nil {
return nil
}
@@ -790,8 +800,11 @@ func FileLoggerInstance() *slog.Logger {
// CloseFileLogger flushes and closes the file logger.
func CloseFileLogger() {
fileLoggerMu.Lock()
defer fileLoggerMu.Unlock()
if fileLogger != nil {
fileLogger.Close()
_ = fileLogger.Close()
fileLogger = nil
}
}
+21 -4
View File
@@ -193,13 +193,25 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
rawProfile := authpkg.RuntimeProfile()
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), rawProfile)
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if multi {
return r.runMultiProfile(ctx, invocation, selections)
}
if strings.TrimSpace(rawProfile) != "" {
profile, err := authpkg.ResolveProfile(defaultConfigDir(), rawProfile)
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if profile == nil {
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
}
authpkg.SetRuntimeProfile(authpkg.ProfileSelector(*profile))
defer authpkg.SetRuntimeProfile(rawProfile)
}
return r.runSingle(ctx, invocation, true)
}
@@ -313,10 +325,11 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
if profile == nil {
return nil, false, fmt.Errorf("profile %q not found", selector)
}
if seen[profile.CorpID] {
identitySelector := authpkg.ProfileSelector(*profile)
if seen[identitySelector] {
continue
}
seen[profile.CorpID] = true
seen[identitySelector] = true
selections = append(selections, multiProfileSelection{
Selector: selector,
Profile: *profile,
@@ -334,13 +347,17 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
failed := 0
for _, selection := range selections {
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
authpkg.SetRuntimeProfile(resolvedSelector)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
entry := map[string]any{
"selector": selection.Selector,
"profile": resolvedSelector,
"corpId": selection.Profile.CorpID,
"corpName": selection.Profile.CorpName,
"userId": selection.Profile.UserID,
"userName": selection.Profile.UserName,
"ok": err == nil,
}
if err != nil {
@@ -67,7 +67,7 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
t.Fatalf("create isolated test directory %s: %v", dir, err)
}
}
t.Setenv("HOME", homeDir)
setTestHome(t, homeDir)
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
@@ -470,7 +470,7 @@ func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testi
}
func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
args := []string{
@@ -518,7 +518,7 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("create isolated config directory: %v", err)
}
t.Setenv("HOME", sandboxRoot)
setTestHome(t, sandboxRoot)
t.Setenv("DWS_CONFIG_DIR", configDir)
capture, err := executeManualAgentExampleCapture(t, []string{
+2
View File
@@ -369,6 +369,7 @@ func TestSkillInstallCommandValidation(t *testing.T) {
func TestSkillInstallInvalidTarget(t *testing.T) {
tempDir := t.TempDir()
t.Cleanup(CloseFileLogger)
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
@@ -392,6 +393,7 @@ func TestSkillInstallInvalidTarget(t *testing.T) {
func TestSkillInstallRequiresAuth(t *testing.T) {
// Setup: Create config directory without token
tempDir := t.TempDir()
t.Cleanup(CloseFileLogger)
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
+7 -8
View File
@@ -88,7 +88,7 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
// Isolate HOME so the ~/.dws/skills/<mode>/ fallback (added by the release
// pipeline cache work) does not pick up real cached content on the
// developer machine.
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
_, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
if err == nil {
t.Fatalf("expected error when source missing")
@@ -110,8 +110,9 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if !strings.Contains(got[0], filepath.Join(".claude", "skills", "dws")) {
t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
want := filepath.Join(home, ".claude", "skills", "dws")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
}
}
@@ -133,11 +134,9 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
}
if filepath.Base(got[0]) == "dws" {
t.Fatalf("multi target must not end with /dws, got %s", got[0])
}
if !strings.HasSuffix(got[0], filepath.Join(".claude", "skills")) {
t.Fatalf("expected suffix .claude/skills, got %s", got[0])
want := filepath.Join(home, ".claude", "skills")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
}
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func setTestHome(t *testing.T, home string) {
t.Helper()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
// os.UserHomeDir uses USERPROFILE on Windows rather than HOME.
t.Setenv("USERPROFILE", home)
}
}
func TestCrossPlatformCoverageConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) {
firstConfig := t.TempDir()
secondConfig := t.TempDir()
t.Cleanup(CloseFileLogger)
t.Setenv("DWS_CONFIG_DIR", firstConfig)
configureLogLevel(&GlobalFlags{})
firstLogger := FileLoggerInstance()
t.Setenv("DWS_CONFIG_DIR", secondConfig)
configureLogLevel(&GlobalFlags{})
firstLogPath := filepath.Join(firstConfig, "logs", "dws.log")
if err := os.Remove(firstLogPath); err != nil {
t.Fatalf("remove previous logger file: %v", err)
}
firstLogger.Info("late write to replaced logger")
if _, err := os.Stat(firstLogPath); !os.IsNotExist(err) {
t.Fatalf("replaced logger recreated its log file: %v", err)
}
previousSameDirLogger := FileLoggerInstance()
configureLogLevel(&GlobalFlags{})
currentLogger := FileLoggerInstance()
CloseFileLogger()
secondLogPath := filepath.Join(secondConfig, "logs", "dws.log")
if err := os.Remove(secondLogPath); err != nil {
t.Fatalf("remove current logger file: %v", err)
}
previousSameDirLogger.Info("late write to same-directory replaced logger")
currentLogger.Info("late write to closed current logger")
if _, err := os.Stat(secondLogPath); !os.IsNotExist(err) {
t.Fatalf("closed logger recreated same-directory log file: %v", err)
}
}
+34 -1
View File
@@ -14,9 +14,12 @@
package app
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
@@ -49,8 +52,38 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set DWS_CONFIG_DIR: " + err.Error())
}
for key, value := range map[string]string{
audit.EnvAudit: "1",
audit.EnvAuditDir: filepath.Join(tmpDir, "audit"),
audit.EnvRetentionDays: "1",
audit.EnvForwardURL: "",
audit.EnvForwardToken: "",
audit.EnvForwardRedact: "none",
audit.EnvAuditDebug: "",
} {
if err := os.Setenv(key, value); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + key + ": " + err.Error())
}
}
// Keep the process-wide audit sink outside per-test TempDir trees. Cobra
// skips PersistentPostRunE on expected command errors, and Windows cannot
// remove a TempDir while the audit lock is still open.
setupAuditSink()
openBrowserFunc = func(string) error { return nil }
code := m.Run()
_ = os.RemoveAll(tmpDir)
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
if code == 0 {
code = 1
}
}
os.Exit(code)
}
+25 -2
View File
@@ -17,7 +17,9 @@ import (
"context"
"fmt"
"log/slog"
"sync"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -27,8 +29,9 @@ import (
// interface so that private overlays can invoke MCP tools without importing
// internal packages.
type toolCallerAdapter struct {
runner executor.Runner
flags *GlobalFlags
runner executor.Runner
flags *GlobalFlags
tokenMu sync.Mutex
}
var toolCallerDryRun = func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
@@ -63,6 +66,26 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
return convertResult(result), nil
}
// CallToolWithToken invokes a helper with an in-memory token override. It is
// used during login before the new token has been persisted to any profile
// slot.
func (a *toolCallerAdapter) CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
if a == nil || a.flags == nil {
return nil, fmt.Errorf("ToolCaller token override is not configured")
}
a.tokenMu.Lock()
defer a.tokenMu.Unlock()
previousToken := a.flags.Token
previousProfile := authpkg.RuntimeProfile()
a.flags.Token = token
authpkg.SetRuntimeProfile("")
defer func() {
a.flags.Token = previousToken
authpkg.SetRuntimeProfile(previousProfile)
}()
return a.CallTool(ctx, productID, toolName, args)
}
func (a *toolCallerAdapter) Format() string {
if a != nil && a.flags != nil {
return a.flags.Format
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestToolCallerTokenOverrideClearsUnpersistedRuntimeProfile(t *testing.T) {
authpkg.SetRuntimeProfile("corp_not_persisted")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
flags := &GlobalFlags{}
runner := runtimeProfileCaptureRunner{flags: flags}
caller := &toolCallerAdapter{runner: runner, flags: flags}
result, err := caller.CallToolWithToken(
context.Background(),
"temporary-access-token",
"contact",
"get_current_user_profile",
nil,
)
if err != nil {
t.Fatalf("CallToolWithToken() error = %v", err)
}
if got := result.Content[0].Text; got != `{"profile":"","token":"temporary-access-token"}` {
t.Fatalf("CallToolWithToken() result = %s", got)
}
if authpkg.RuntimeProfile() != "corp_not_persisted" {
t.Fatalf("runtime profile = %q, want restored selector", authpkg.RuntimeProfile())
}
if flags.Token != "" {
t.Fatalf("token override leaked after call: %q", flags.Token)
}
}
type runtimeProfileCaptureRunner struct {
flags *GlobalFlags
}
func (r runtimeProfileCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": []any{map[string]any{
"type": "text",
"text": `{"profile":"` + authpkg.RuntimeProfile() + `","token":"` + r.flags.Token + `"}`,
}},
},
}, nil
}
+16
View File
@@ -15,6 +15,7 @@ package app
import (
"context"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -38,6 +39,21 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
return res, err
}
func (r recordingToolCaller) CallToolWithToken(
ctx context.Context,
token, product, tool string,
args map[string]any,
) (*edition.ToolResult, error) {
inner, ok := r.inner.(tokenOverrideToolCaller)
if !ok {
return nil, fmt.Errorf("ToolCaller token override is not configured")
}
recordedArgs := cloneToolArgs(args)
res, err := inner.CallToolWithToken(ctx, token, product, tool, args)
usage.Append(product, tool, recordedArgs, err == nil, r.inner.DryRun())
return res, err
}
func (r recordingToolCaller) Format() string { return r.inner.Format() }
func (r recordingToolCaller) DryRun() bool { return r.inner.DryRun() }
func (r recordingToolCaller) Fields() string { return r.inner.Fields() }
+29 -1
View File
@@ -25,6 +25,7 @@ import (
type crossPlatformCoverageCaller struct {
args map[string]any
token string
dryRun bool
}
@@ -33,6 +34,16 @@ func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, a
return &edition.ToolResult{}, nil
}
func (c *crossPlatformCoverageCaller) CallToolWithToken(
_ context.Context,
token, _, _ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.token = token
c.args = args
return &edition.ToolResult{}, nil
}
func (*crossPlatformCoverageCaller) Format() string { return "json" }
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
@@ -88,6 +99,23 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
t.Fatal("sensitive text must not be recorded")
}
tokenCaller, ok := caller.(tokenOverrideToolCaller)
if !ok {
t.Fatal("recording caller dropped token override support")
}
if _, err := tokenCaller.CallToolWithToken(
context.Background(),
"temporary-token",
"contact",
"get_current_user_profile",
nil,
); err != nil {
t.Fatal(err)
}
if inner.token != "temporary-token" || len(inner.args) != 0 {
t.Fatalf("token override forwarding = token %q args %#v", inner.token, inner.args)
}
inner.dryRun = true
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
t.Fatal(err)
@@ -96,7 +124,7 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
if len(records) != 2 {
t.Fatalf("dry-run call must not be recorded: %#v", records)
}
}
+37
View File
@@ -306,6 +306,43 @@ func TestRevokeTokenRemote(t *testing.T) {
// Can't easily test since LogoutURL is a const. Just test that it doesn't panic with real URL.
}
func TestRevokeTokenRemoteForDataUsesExactTokenMetadata(t *testing.T) {
configDir := t.TempDir()
var got struct {
ClientID string `json:"clientId"`
AccessToken string `json:"accessToken"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != MCPRevokeTokenPath {
t.Errorf("revoke path = %q, want %q", r.URL.Path, MCPRevokeTokenPath)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode revoke body: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(srv.URL), 0o600); err != nil {
t.Fatalf("write mcp_url: %v", err)
}
SetClientID("wrong-global-client")
t.Cleanup(func() { SetClientID("") })
data := &TokenData{
AccessToken: "exact-account-token",
ClientID: "exact-account-client",
Source: "mcp",
}
if err := RevokeTokenRemoteForData(t.Context(), data); err != nil {
t.Fatalf("RevokeTokenRemoteForData() error = %v", err)
}
if got.ClientID != data.ClientID || got.AccessToken != data.AccessToken {
t.Fatalf("revoke body = %#v, want exact token metadata", got)
}
}
// ─── oauth_helpers.go ──────────────────────────────────────────────────
type tokenResponse struct {
+4
View File
@@ -495,6 +495,10 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
IdentityEnricher: func(_ context.Context, data *TokenData) error {
data.UserID = "user123"
return nil
},
}
token, err := provider.loginOnce(context.Background(), 1)
+296 -38
View File
@@ -396,7 +396,8 @@ func TestCrossPlatformCoverageDeviceFlowLoginRetry(t *testing.T) {
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "device-access", "refreshToken": "refresh", "expiresIn": 7200, "corpId": "corp-device",
"accessToken": "device-access", "refreshToken": "refresh", "expiresIn": 7200,
"corpId": "corp-device", "userId": "user-device",
})
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
_ = json.NewEncoder(w).Encode(CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}})
@@ -493,7 +494,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
}
normalizeProfilesConfig(cfg)
if len(cfg.Profiles) != 1 || cfg.Profiles[0].Name != "Acme" ||
cfg.PrimaryProfile != "corp-a" || cfg.CurrentProfile != "corp-a" || cfg.PreviousProfile != "" {
cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" {
t.Fatalf("normalized profiles = %#v", cfg)
}
@@ -515,6 +516,16 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
if err := UpsertProfileFromTokenWithCurrent(dir, second, true); err != nil {
t.Fatal(err)
}
if err := SaveTokenDataKeychainForIdentity(first.CorpID, first.UserID, first); err != nil {
t.Fatal(err)
}
if err := SaveTokenDataKeychainForCorpID(second.CorpID, second); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = DeleteTokenDataKeychainForIdentity(first.CorpID, first.UserID)
_ = DeleteTokenDataKeychainForCorpID(second.CorpID)
})
profilesForAmbiguity, err := LoadProfiles(dir)
if err != nil {
t.Fatal(err)
@@ -575,7 +586,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
{CorpID: "two", Name: "Acme-12345678"},
{CorpID: "three", Name: "Acme-12345678-2"},
}}
if got := chooseProfileName(nameCfg, &TokenData{CorpID: "corp-12345678", CorpName: "Acme"}); got != "Acme-12345678-3" {
if got := chooseProfileName(nameCfg, &TokenData{CorpID: "corp-12345678", CorpName: "Acme"}); got != "Acme-2" {
t.Fatalf("collision profile name = %q", got)
}
if chooseProfileName(&ProfilesConfig{}, &TokenData{}) != "profile" {
@@ -585,12 +596,7 @@ func TestCrossPlatformCoverageProfilesLifecycleEdges(t *testing.T) {
!shouldRefreshProfileName(&Profile{}, first) {
t.Fatal("profile refresh-name decisions failed")
}
if findProfile(nil, "x") != nil || findProfile(cfg, "") != nil ||
profileIndexByCorpID(nil, "x") != -1 || firstProfileCorpID(nil) != "" {
t.Fatal("nil profile helper failed")
}
if shortCorpID("short") != "short" || shortCorpID("corp-12345678") != "12345678" ||
timeOrRFC3339(time.Time{}) != "" {
if shortCorpID("short") != "short" || shortCorpID("corp-12345678") != "12345678" {
t.Fatal("profile formatting helpers failed")
}
}
@@ -1234,6 +1240,12 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
})
t.Run("import validation", func(t *testing.T) {
oldGOOS := portableRuntimeGOOS
t.Cleanup(func() {
portableRuntimeGOOS = oldGOOS
})
portableRuntimeGOOS = func() string { return "linux" }
t.Setenv(keychain.StorageDirEnv, t.TempDir())
if _, err := ImportPortableAuthBundle(t.TempDir(), nil); err == nil {
t.Fatal("nil portable reader succeeded")
@@ -1443,31 +1455,43 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
oldMarshalIndent := tokenJSONMarshalIndent
oldMarshal := tokenJSONMarshal
oldMkdir := tokenMkdirAll
oldRead := tokenReadFile
oldWrite := tokenWriteFile
oldRename := tokenRename
oldRemove := tokenRemove
oldGlob := tokenGlob
oldSaveCorp := tokenSaveKeychainForCorpID
oldSaveIdentity := tokenSaveKeychainForIdentity
oldSaveLegacy := tokenSaveKeychain
oldLoadCorp := tokenLoadKeychainForCorpID
oldLoadIdentity := tokenLoadKeychainIdentity
oldLoadLegacy := tokenLoadKeychain
oldExists := tokenKeychainExists
oldDeleteCorp := tokenDeleteKeychainForCorpID
oldDeleteIdentity := tokenDeleteKeychainIdentity
oldDeleteLegacy := tokenDeleteKeychain
oldRemoveAuthEntries := tokenRemoveAuthTokenEntries
oldLoadSecure := tokenLoadSecure
oldDeleteSecure := tokenDeleteSecure
oldEnsureProfiles := profilesEnsureMigration
oldResolve := tokenResolveProfile
oldResolveDeletion := tokenResolveDeletion
oldResolveSelection := tokenResolveSelection
oldUpsert := tokenUpsertProfile
oldRemoveProfile := tokenRemoveProfile
oldSync := tokenSyncLegacyMirror
oldSyncOrganization := tokenSyncOrganizationMirror
oldLoadProfiles := tokenLoadProfiles
oldSaveProfiles := tokenSaveProfiles
oldWriteMarker := tokenWriteMarker
oldWriteManualMarker := tokenWriteManualMarker
oldDeleteMarker := tokenDeleteMarker
oldParseURL := tokenParseURL
oldNewRequest := tokenNewRequest
oldDefaultDir := tokenDefaultConfigDir
oldLoadData := tokenLoadData
oldRevokeURL := tokenRevokeURL
oldMCPBaseURL := tokenMCPBaseURL
oldLogoutURL := tokenLogoutURL
oldLogoutContinue := tokenLogoutContinueURL
oldLogoutClient := tokenLogoutHTTPClient
@@ -1477,31 +1501,43 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
tokenJSONMarshalIndent = oldMarshalIndent
tokenJSONMarshal = oldMarshal
tokenMkdirAll = oldMkdir
tokenReadFile = oldRead
tokenWriteFile = oldWrite
tokenRename = oldRename
tokenRemove = oldRemove
tokenGlob = oldGlob
tokenSaveKeychainForCorpID = oldSaveCorp
tokenSaveKeychainForIdentity = oldSaveIdentity
tokenSaveKeychain = oldSaveLegacy
tokenLoadKeychainForCorpID = oldLoadCorp
tokenLoadKeychainIdentity = oldLoadIdentity
tokenLoadKeychain = oldLoadLegacy
tokenKeychainExists = oldExists
tokenDeleteKeychainForCorpID = oldDeleteCorp
tokenDeleteKeychainIdentity = oldDeleteIdentity
tokenDeleteKeychain = oldDeleteLegacy
tokenRemoveAuthTokenEntries = oldRemoveAuthEntries
tokenLoadSecure = oldLoadSecure
tokenDeleteSecure = oldDeleteSecure
profilesEnsureMigration = oldEnsureProfiles
tokenResolveProfile = oldResolve
tokenResolveDeletion = oldResolveDeletion
tokenResolveSelection = oldResolveSelection
tokenUpsertProfile = oldUpsert
tokenRemoveProfile = oldRemoveProfile
tokenSyncLegacyMirror = oldSync
tokenSyncOrganizationMirror = oldSyncOrganization
tokenLoadProfiles = oldLoadProfiles
tokenSaveProfiles = oldSaveProfiles
tokenWriteMarker = oldWriteMarker
tokenWriteManualMarker = oldWriteManualMarker
tokenDeleteMarker = oldDeleteMarker
tokenParseURL = oldParseURL
tokenNewRequest = oldNewRequest
tokenDefaultConfigDir = oldDefaultDir
tokenLoadData = oldLoadData
tokenRevokeURL = oldRevokeURL
tokenMCPBaseURL = oldMCPBaseURL
tokenLogoutURL = oldLogoutURL
tokenLogoutContinueURL = oldLogoutContinue
tokenLogoutHTTPClient = oldLogoutClient
@@ -1566,10 +1602,12 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Fatalf("corp marker error = %v", err)
}
SetRuntimeProfile("")
tokenWriteManualMarker = func(string) error { return fail }
if err := saveTokenDataLocked(dir, &TokenData{}); !errors.Is(err, fail) {
t.Fatalf("legacy marker error = %v", err)
}
tokenWriteMarker = oldWriteMarker
tokenWriteManualMarker = oldWriteManualMarker
edition.Override(&edition.Hooks{Name: "coverage", SaveToken: func(string, []byte) error { return nil }})
if err := saveTokenDataLocked(dir, data); err != nil {
@@ -1624,6 +1662,9 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Fatalf("failed migration load = %#v %v", got, err)
}
tokenSaveKeychain = func(*TokenData) error { return nil }
tokenLoadKeychain = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
tokenReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
tokenWriteManualMarker = func(string) error { return nil }
deleted := false
tokenDeleteSecure = func(string) error { deleted = true; return nil }
if _, err := LoadTokenDataForProfile(dir, ""); err != nil || !deleted {
@@ -1647,25 +1688,70 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Run("delete branches", func(t *testing.T) {
dir := t.TempDir()
fail := errors.New("fail")
selected := &Profile{CorpID: "corp"}
tokenResolveProfile = func(string, string) (*Profile, error) { return nil, fail }
selected := &Profile{CorpID: "corp", UserID: "user"}
profilesEnsureMigration = func(string) error { return fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("delete migration error = %v", err)
}
profilesEnsureMigration = func(string) error { return nil }
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return nil, fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("delete profiles load error = %v", err)
}
cfg := &ProfilesConfig{
CurrentProfile: ProfileSelector(*selected),
OrgCurrentProfiles: map[string]string{"corp": ProfileSelector(*selected)},
Profiles: []Profile{*selected},
}
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return cfg, nil }
tokenResolveDeletion = func(*ProfilesConfig, string) (*Profile, bool, error) { return nil, false, fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("delete resolution error = %v", err)
}
tokenResolveProfile = func(string, string) (*Profile, error) { return selected, nil }
tokenDeleteKeychainForCorpID = func(string) error { return fail }
tokenResolveDeletion = func(*ProfilesConfig, string) (*Profile, bool, error) { return selected, true, nil }
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) {
return &TokenData{CorpID: "corp", UserID: "user"}, nil
}
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
return &TokenData{CorpID: "corp", UserID: "user"}, nil
}
tokenLoadKeychain = func() (*TokenData, error) {
return &TokenData{CorpID: "corp", UserID: "user"}, nil
}
tokenReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
tokenSaveProfiles = func(string, *ProfilesConfig) error { return nil }
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil }
tokenSaveKeychainForCorpID = func(string, *TokenData) error { return nil }
tokenSaveKeychain = func(*TokenData) error { return nil }
tokenWriteMarker = func(string) error { return nil }
tokenDeleteMarker = func(string) error { return nil }
tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
tokenSyncLegacyMirror = func(string) error { return nil }
tokenSyncOrganizationMirror = func(Profile) error { return nil }
tokenDeleteSecure = func(string) error { return nil }
tokenDeleteKeychainForCorpID = func(string) error { return nil }
tokenDeleteKeychainIdentity = func(string, string) error { return fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("delete identity keychain error = %v", err)
}
tokenDeleteKeychainIdentity = func(string, string) error { return nil }
cfg.OrgCurrentProfiles = map[string]string{"corp": ProfileSelector(*selected)}
tokenRemoveProfile = func(string, string) (*Profile, error) {
cfg.OrgCurrentProfiles = nil
return selected, nil
}
tokenDeleteKeychainForCorpID = func(string) error { return fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("delete corp keychain error = %v", err)
}
tokenDeleteKeychainForCorpID = func(string) error { return nil }
cfg.OrgCurrentProfiles = map[string]string{"corp": ProfileSelector(*selected)}
tokenRemoveProfile = func(string, string) (*Profile, error) { return nil, fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("remove profile error = %v", err)
}
tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
cfg.OrgCurrentProfiles = nil
tokenSyncLegacyMirror = func(string) error { return fail }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
t.Fatalf("sync mirror error = %v", err)
@@ -1676,7 +1762,8 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Fatalf("secure cleanup error = %v", err)
}
tokenResolveProfile = func(string, string) (*Profile, error) { return nil, nil }
cfg.CurrentProfile = ""
cfg.Profiles = nil
tokenDeleteKeychain = func() error { return fail }
tokenDeleteMarker = func(string) error { return nil }
if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
@@ -1704,22 +1791,17 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Run("delete all", func(t *testing.T) {
fail := errors.New("fail")
base := func() string {
tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
tokenDeleteKeychainForCorpID = func(string) error { return nil }
tokenRemoveAuthTokenEntries = func(string) error { return nil }
tokenRemove = func(string) error { return os.ErrNotExist }
tokenGlob = func(string) ([]string, error) { return nil, nil }
tokenDeleteKeychain = func() error { return nil }
tokenDeleteSecure = func(string) error { return nil }
tokenDeleteMarker = func(string) error { return nil }
return t.TempDir()
}
dir := base()
tokenLoadProfiles = func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Profiles: []Profile{{CorpID: "corp"}}}, nil
}
tokenDeleteKeychainForCorpID = func(string) error { return fail }
tokenRemoveAuthTokenEntries = func(string) error { return fail }
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
t.Fatalf("delete-all corp error = %v", err)
t.Fatalf("delete-all auth namespace error = %v", err)
}
dir = base()
tokenRemove = func(string) error { return fail }
@@ -1739,11 +1821,6 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
t.Fatalf("delete-all quarantine error = %v", err)
}
dir = base()
tokenDeleteKeychain = func() error { return fail }
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
t.Fatalf("delete-all legacy keychain error = %v", err)
}
dir = base()
tokenDeleteSecure = func(string) error { return fail }
if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
t.Fatalf("delete-all secure error = %v", err)
@@ -1759,6 +1836,9 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
resetClientIDFromMCP()
SetClientID("client")
fail := errors.New("fail")
tokenLoadData = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "access", ClientID: "client", Source: "direct"}, nil
}
tokenParseURL = func(string) (*url.URL, error) { return nil, fail }
if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
t.Fatalf("logout parse error = %v", err)
@@ -1791,16 +1871,14 @@ func TestCrossPlatformCoverageTokenStorageAndRevocationCoverageEdges(t *testing.
}
SetClientIDFromMCP("mcp-client")
tokenRevokeURL = func() string { return "" }
if err := RevokeTokenRemote(context.Background()); err != nil {
t.Fatal(err)
}
tokenRevokeURL = func() string { return "https://revoke.test" }
tokenLoadData = func(string) (*TokenData, error) { return nil, fail }
if err := RevokeTokenRemote(context.Background()); err != nil {
t.Fatal("missing token revoke should be a no-op")
}
tokenLoadData = func(string) (*TokenData, error) { return &TokenData{AccessToken: "access"}, nil }
tokenMCPBaseURL = func() string { return "https://revoke.test" }
tokenLoadData = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "access", ClientID: "mcp-client", Source: "mcp"}, nil
}
tokenJSONMarshal = func(any) ([]byte, error) { return nil, fail }
if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
t.Fatalf("revoke marshal error = %v", err)
@@ -2201,8 +2279,8 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
}
primaryOnly := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{Name: "primary", CorpID: "primary"}}}
profilesLoad = func(string) (*ProfilesConfig, error) { return primaryOnly, nil }
if got, err := ResolveProfile(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
t.Fatalf("primary profile resolution = %#v %v", got, err)
if got, err := ResolveProfile(dir, ""); err != nil || got != nil {
t.Fatalf("primary-only profile resolution = %#v %v", got, err)
}
profilesLoad = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
if got, err := ResolveProfile(dir, ""); err != nil || got != nil {
@@ -2216,8 +2294,8 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
}
cfgNoCurrent := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{CorpID: "primary"}}}
profilesLoad = func(string) (*ProfilesConfig, error) { return cfgNoCurrent, nil }
if got, err := resolveProfileForLoad(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
t.Fatalf("profile-for-load primary fallback = %#v %v", got, err)
if got, err := resolveProfileForLoad(dir, ""); err != nil || got != nil {
t.Fatalf("profile-for-load primary-only result = %#v %v", got, err)
}
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
@@ -2236,6 +2314,10 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
Profiles: []Profile{{Name: "primary", CorpID: "primary"}, {Name: "current", CorpID: "current"}, {Name: "previous", CorpID: "previous"}},
}
profilesSave = func(string, *ProfilesConfig) error { return nil }
profilesLoadCorp = func(corpID string) (*TokenData, error) {
return &TokenData{CorpID: corpID, AccessToken: "x"}, nil
}
profilesSaveCorp = func(string, *TokenData) error { return nil }
profilesSyncLegacyMirror = func(string) error { return fail }
if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
t.Fatalf("set-current mirror error = %v", err)
@@ -2263,9 +2345,13 @@ func TestCrossPlatformCoverageProfilesCoverageEdges(t *testing.T) {
deletedLegacy, deletedMarker := false, false
profilesDeleteLegacy = func() error { deletedLegacy = true; return fail }
profilesDeleteMarker = func(string) error { deletedMarker = true; return fail }
if err := syncLegacyTokenMirrorLocked(dir); err != nil || !deletedLegacy || !deletedMarker {
if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) || !deletedLegacy || deletedMarker {
t.Fatalf("empty mirror cleanup = %v %v %v", err, deletedLegacy, deletedMarker)
}
profilesDeleteLegacy = func() error { return nil }
if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) || !deletedMarker {
t.Fatalf("empty mirror marker cleanup = %v %v", err, deletedMarker)
}
normalizeProfilesConfig(nil)
}
@@ -2905,3 +2991,175 @@ func TestCrossPlatformCoverageDeviceFlowHighLevelCoverageEdges(t *testing.T) {
}
dfPrintBox(io.Discard, []string{"short"})
}
func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testing.T) {
cfg := &ProfilesConfig{
Profiles: []Profile{
{Name: "local-a", CorpID: "corp-a", CorpName: "Shared", UserID: "u1", UserName: "Alice"},
{Name: "duplicate", CorpID: "corp-a", CorpName: "Shared", UserID: "u2", UserName: "Alice"},
{Name: "duplicate", CorpID: "corp-b", CorpName: "Shared", UserID: "u3", UserName: "Bob"},
{Name: "solo", CorpID: "corp-c", CorpName: "Solo", UserID: "u4", UserName: "Carol"},
},
}
for _, tc := range []struct {
selector string
wantErr bool
}{
{"", true},
{"missing:u1", true},
{"Shared:u1", true},
{"corp-a:u1", false},
{"corp-a:Alice", true},
{"corp-a:missing", true},
{"corp-a", true},
{"corp-c", false},
{"Solo", false},
{"local-a", false},
{"duplicate", true},
{"missing", true},
} {
_, _, err := resolveProfileSelection("", cfg, tc.selector)
if (err != nil) != tc.wantErr {
t.Fatalf("resolveProfileSelection(%q) error = %v", tc.selector, err)
}
}
if _, _, err := resolveProfileSelection("", nil, "x"); err == nil {
t.Fatal("nil profile config selection succeeded")
}
cfg.OrgCurrentProfiles = map[string]string{"corp-a": "corp-a:u2"}
if got, _, err := resolveProfileSelection("", cfg, "corp-a"); err != nil || got.UserID != "u2" {
t.Fatalf("organization current selection = %#v %v", got, err)
}
for _, tc := range []struct {
selector string
wantErr bool
exact bool
}{
{"", true, false},
{"corp-a:u1", false, true},
{"corp-a", false, false},
{"Solo", false, false},
{"local-a", false, true},
{"duplicate", true, true},
{"missing", true, false},
} {
_, exact, err := resolveProfileDeletionSelection(cfg, tc.selector)
if (err != nil) != tc.wantErr || (!tc.wantErr && exact != tc.exact) {
t.Fatalf("resolveProfileDeletionSelection(%q) = exact %v, %v", tc.selector, exact, err)
}
}
if _, _, err := resolveProfileDeletionSelection(nil, "x"); err == nil {
t.Fatal("nil deletion config succeeded")
}
for _, selector := range []string{"", "corp-a", "missing", "Solo", "Shared"} {
_, _ = resolveOrganizationCorpID(cfg, selector)
}
if got, err := resolveOrganizationCorpID(nil, "x"); err != nil || got != "" {
t.Fatalf("nil organization resolution = %q %v", got, err)
}
if _, _, err := resolveOrganizationDefault(cfg, "missing", "missing", nil); err == nil {
t.Fatal("empty organization default succeeded")
}
if got, _, err := resolveOrganizationDefault(cfg, "corp-c", "Solo", profilesForCorpID(cfg, "corp-c")); err != nil || got.UserID != "u4" {
t.Fatalf("single account organization default = %#v %v", got, err)
}
if _, _, err := resolveOrganizationDefault(&ProfilesConfig{}, "corp-a", "corp-a", profilesForCorpID(cfg, "corp-a")); err == nil {
t.Fatal("ambiguous organization default succeeded")
}
if got := profileSelectorCandidates([]*Profile{nil, &cfg.Profiles[1], &cfg.Profiles[0]}); len(got) != 2 || got[0] != "corp-a:u1" {
t.Fatalf("selector candidates = %#v", got)
}
for selector, want := range map[string]bool{
"": false,
"corp-a:u1": true,
"Shared:Bob": true,
"corp-c": true,
"local-a": true,
"definitely-missing": false,
} {
if got := profileSelectorReferenceExists(cfg, selector); got != want {
t.Fatalf("profileSelectorReferenceExists(%q) = %v", selector, got)
}
}
if profileSelectorReferenceExists(nil, "corp-a") {
t.Fatal("nil profile selector reference exists")
}
oldAcquire := profilesAcquireDualLock
oldEnsure := profilesEnsureMigration
oldLoad := profilesLoad
t.Cleanup(func() {
profilesAcquireDualLock = oldAcquire
profilesEnsureMigration = oldEnsure
profilesLoad = oldLoad
})
profilesAcquireDualLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
profilesEnsureMigration = func(string) error { return nil }
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
if got, exact, err := ResolveProfileDeletionScope("cfg", "corp-a:u1"); err != nil || !exact || got.UserID != "u1" {
t.Fatalf("deletion scope = %#v %v %v", got, exact, err)
}
profilesEnsureMigration = func(string) error { return errors.New("migration") }
if _, _, err := ResolveProfileDeletionScope("cfg", "corp-a"); err == nil {
t.Fatal("deletion scope migration failure succeeded")
}
profilesEnsureMigration = func(string) error { return nil }
profilesLoad = func(string) (*ProfilesConfig, error) { return nil, errors.New("load") }
if _, _, err := ResolveProfileDeletionScope("cfg", "corp-a"); err == nil {
t.Fatal("deletion scope load failure succeeded")
}
oldLoadIdentity := profilesLoadIdentity
oldLoadCorp := profilesLoadCorp
oldSaveIdentity := profilesSaveIdentity
t.Cleanup(func() {
profilesLoadIdentity = oldLoadIdentity
profilesLoadCorp = oldLoadCorp
profilesSaveIdentity = oldSaveIdentity
})
profile := Profile{CorpID: "corp-a", UserID: "u1"}
profilesLoadIdentity = func(string, string) (*TokenData, error) { return &TokenData{AccessToken: "identity"}, nil }
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "identity" {
t.Fatalf("identity token load = %#v %v", got, err)
}
fail := errors.New("fail")
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, fail }
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
t.Fatalf("identity load failure = %v", err)
}
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("missing organization mirror = %v", err)
}
profilesLoadCorp = func(string) (*TokenData, error) { return nil, fail }
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
t.Fatalf("organization mirror failure = %v", err)
}
for _, data := range []*TokenData{
{CorpID: "corp-a"},
{CorpID: "corp-a", UserID: "other"},
} {
profilesLoadCorp = func(string) (*TokenData, error) { return data, nil }
if _, err := loadTokenForProfileIdentity(profile); err == nil {
t.Fatalf("invalid organization mirror %#v succeeded", data)
}
}
profilesLoadCorp = func(string) (*TokenData, error) {
return &TokenData{CorpID: "corp-a", UserID: "u1", AccessToken: "mirror"}, nil
}
profilesSaveIdentity = func(string, string, *TokenData) error { return fail }
if _, err := loadTokenForProfileIdentity(profile); !errors.Is(err, fail) {
t.Fatalf("identity repair save failure = %v", err)
}
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" {
t.Fatalf("identity repair = %#v %v", got, err)
}
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil {
t.Fatalf("organization-only token load = %v", err)
}
}
+17 -12
View File
@@ -75,15 +75,16 @@ var (
)
type DeviceFlowProvider struct {
configDir string
clientID string
scope string
baseURL string
terminalBaseURL string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
configDir string
clientID string
scope string
baseURL string
terminalBaseURL string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
IdentityEnricher func(context.Context, *TokenData) error
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -267,9 +268,10 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0)
oauthProvider := &OAuthProvider{
configDir: p.configDir,
clientID: p.clientID,
logger: p.logger,
configDir: p.configDir,
clientID: p.clientID,
logger: p.logger,
IdentityEnricher: p.IdentityEnricher,
}
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
if err != nil {
@@ -352,6 +354,9 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
+89 -3
View File
@@ -14,6 +14,7 @@
package auth
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -52,6 +53,14 @@ func TokenAccountForCorpID(corpID string) string {
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
}
// TokenAccountForIdentity returns the stable keychain account used for one
// DingTalk identity. The hash avoids collisions caused by delimiter escaping
// or keychain/file-name restrictions.
func TokenAccountForIdentity(corpID, userID string) string {
identity := strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
return fmt.Sprintf("%s:id:%x", keychain.AccountToken, sha256.Sum256([]byte(identity)))
}
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
corpID = strings.TrimSpace(corpID)
@@ -61,6 +70,16 @@ func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
}
// SaveTokenDataKeychainForIdentity saves TokenData to an identity-scoped slot.
func SaveTokenDataKeychainForIdentity(corpID, userID string, data *TokenData) error {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
if corpID == "" || userID == "" {
return fmt.Errorf("corpId and userId are required for identity token storage")
}
return saveTokenDataKeychainAccount(TokenAccountForIdentity(corpID, userID), data)
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
jsonData, err := authKeychainMarshal(data, "", " ")
if err != nil {
@@ -93,6 +112,16 @@ func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
}
// LoadTokenDataKeychainForIdentity loads TokenData from an identity-scoped slot.
func LoadTokenDataKeychainForIdentity(corpID, userID string) (*TokenData, error) {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
if corpID == "" || userID == "" {
return nil, fmt.Errorf("corpId and userId are required for identity token storage")
}
return loadTokenDataKeychainAccount(TokenAccountForIdentity(corpID, userID))
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
jsonStr, err := authKeychainGet(keychain.Service, account)
if err != nil {
@@ -127,6 +156,9 @@ func preflightTokenPersistence(configDir string) error {
if err != nil {
return fmt.Errorf("load token profiles: %w", err)
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
for _, profile := range cfg.Profiles {
// LoadProfiles normalizes away blank and duplicate corp IDs.
corpID := profile.CorpID
@@ -137,6 +169,19 @@ func preflightTokenPersistence(configDir string) error {
)
}
}
for _, profile := range cfg.Profiles {
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
if corpID == "" || userID == "" {
continue
}
if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf(
"identity token slot %q is unreadable; remove only this account with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
TokenAccountForIdentity(corpID, userID), ProfileSelector(profile), err,
)
}
}
if err := authValidateEntries(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",
@@ -149,10 +194,17 @@ func preflightTokenPersistence(configDir string) error {
// 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 {
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
@@ -161,8 +213,22 @@ func preflightTokenRefreshPersistence(data *TokenData) error {
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)
userID := strings.TrimSpace(data.UserID)
if userID != "" {
if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err)
}
}
checkOrganizationMirror := true
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
checkOrganizationMirror =
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
profileSelector(corpID, userID)
}
if checkOrganizationMirror {
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
}
@@ -181,6 +247,16 @@ func DeleteTokenDataKeychainForCorpID(corpID string) error {
return authKeychainRemove(keychain.Service, TokenAccountForCorpID(corpID))
}
// DeleteTokenDataKeychainForIdentity removes one identity-scoped token.
func DeleteTokenDataKeychainForIdentity(corpID, userID string) error {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
if corpID == "" || userID == "" {
return fmt.Errorf("corpId and userId are required for identity token storage")
}
return authKeychainRemove(keychain.Service, TokenAccountForIdentity(corpID, userID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return authKeychainExists(keychain.Service, keychain.AccountToken)
@@ -195,6 +271,16 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
return authKeychainExists(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychainForIdentity checks if an identity-scoped token exists.
func TokenDataExistsKeychainForIdentity(corpID, userID string) bool {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
if corpID == "" || userID == "" {
return false
}
return authKeychainExists(keychain.Service, TokenAccountForIdentity(corpID, userID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
// This should be called early in the auth flow (e.g., during GetAccessToken).
// The migration is idempotent and thread-safe.
+4
View File
@@ -293,6 +293,8 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
CorpName string `json:"corpName"`
CorpNameSnake string `json:"corp_name"`
OrgName string `json:"orgName"`
UserID string `json:"userId"`
UserName string `json:"userName"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
@@ -320,6 +322,8 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
UserID: resp.UserID,
UserName: resp.UserName,
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
+126 -7
View File
@@ -28,6 +28,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
)
// oauthHTTPClient is a dedicated HTTP client for OAuth operations with
@@ -44,6 +45,7 @@ var (
oauthPollInterval = 5 * time.Second
oauthSuccessPause = 2 * time.Second
oauthLoadToken = LoadTokenData
oauthLoadTokenLocked = loadTokenDataForProfileLocked
oauthAcquireLock = AcquireDualLock
oauthMarkProfile = MarkProfileStatus
oauthFetchClientID = FetchClientIDFromMCP
@@ -73,6 +75,9 @@ type OAuthProvider struct {
httpClient *http.Client
NoBrowser bool
TargetCorpID string
// IdentityEnricher resolves userId/userName/corpName while the freshly
// exchanged access token is still only in memory.
IdentityEnricher func(context.Context, *TokenData) error
}
// NewOAuthProvider creates a new OAuth provider.
@@ -175,6 +180,14 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
logging.AuthDebug(
"auth.login.oauth.flow.start",
"client_id", strings.TrimSpace(p.clientID),
"target_corp_id", strings.TrimSpace(p.TargetCorpID),
"callback_port", port,
"force", force,
"no_browser", p.NoBrowser,
)
// Channel to pass callback result (token data or error with CLI auth status)
type callbackResult struct {
@@ -205,6 +218,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if code == "" {
code = r.URL.Query().Get("code")
}
logging.AuthDebug(
"auth.login.oauth.callback.received",
"callback_port", port,
"has_authorization_code", code != "",
)
// Check state and handle page refresh or concurrent requests
callbackTokenMu.Lock()
@@ -264,6 +282,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Exchange code for token
tokenData, exchangeErr := oauthExchange(p, ctx, code)
if exchangeErr != nil {
logging.AuthDebug(
"auth.login.oauth.token_exchange.error",
"callback_port", port,
"error", exchangeErr,
)
// Clear in-progress state on error
callbackTokenMu.Lock()
if callbackCodeInProgress == code {
@@ -279,6 +302,16 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
return
}
logging.AuthDebug(
"auth.login.oauth.token_exchange.done",
"callback_port", port,
"corp_id", strings.TrimSpace(tokenData.CorpID),
"user_id", strings.TrimSpace(tokenData.UserID),
"user_name", strings.TrimSpace(tokenData.UserName),
"source", strings.TrimSpace(tokenData.Source),
"access_expires_at", tokenData.ExpiresAt,
"refresh_expires_at", tokenData.RefreshExpAt,
)
// Mark as processed immediately after successful exchange
callbackTokenMu.Lock()
@@ -302,6 +335,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
}
cliAuthEnabled := denialReason == ""
logging.AuthDebug(
"auth.login.oauth.organization_access.checked",
"callback_port", port,
"corp_id", strings.TrimSpace(tokenData.CorpID),
"enabled", cliAuthEnabled,
"denial_reason", denialReason,
)
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
// the terminal so portal can update copy without releasing the CLI.
@@ -555,9 +595,30 @@ continueLogin:
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
logging.AuthDebug(
"auth.login.oauth.persistence.start",
"callback_port", port,
"corp_id", strings.TrimSpace(tokenData.CorpID),
"user_id", strings.TrimSpace(tokenData.UserID),
"user_name", strings.TrimSpace(tokenData.UserName),
)
if err := p.persistLoginToken(ctx, tokenData); err != nil {
logging.AuthDebug(
"auth.login.oauth.persistence.error",
"callback_port", port,
"corp_id", strings.TrimSpace(tokenData.CorpID),
"user_id", strings.TrimSpace(tokenData.UserID),
"error", err,
)
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
logging.AuthDebug(
"auth.login.oauth.persistence.done",
"callback_port", port,
"corp_id", strings.TrimSpace(tokenData.CorpID),
"user_id", strings.TrimSpace(tokenData.UserID),
"user_name", strings.TrimSpace(tokenData.UserName),
)
// Persist app credentials (with secret) if using custom client credentials.
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
@@ -595,12 +656,13 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
} else {
_ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
@@ -635,7 +697,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Double-check: re-load from disk — another goroutine/process may have refreshed
// while we were waiting for the lock.
data, err := oauthLoadToken(p.configDir)
data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile())
if err != nil {
return nil, err
}
@@ -654,7 +716,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if !data.IsRefreshTokenValid() {
return nil, fmt.Errorf("refresh_token 已过期")
}
if err := preflightTokenRefreshPersistence(data); err != nil {
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
@@ -672,14 +734,71 @@ func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid stri
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
if uid != "" {
tokenData.UserID = uid
tokenData.UserID = strings.TrimSpace(uid)
if err := p.persistKnownLoginToken(tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
return tokenData, nil
}
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
if err := p.persistLoginToken(ctx, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
return tokenData, nil
}
func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenData) error {
corpID, userID, userName := "", "", ""
if tokenData != nil {
corpID = strings.TrimSpace(tokenData.CorpID)
userID = strings.TrimSpace(tokenData.UserID)
userName = strings.TrimSpace(tokenData.UserName)
}
logging.AuthDebug(
"auth.login.oauth.identity.before_enrich",
"corp_id", corpID,
"user_id", userID,
"user_name", userName,
)
if err := p.prepareLoginToken(ctx, tokenData); err != nil {
return err
}
logging.AuthDebug(
"auth.login.oauth.identity.after_enrich",
"corp_id", strings.TrimSpace(tokenData.CorpID),
"user_id", strings.TrimSpace(tokenData.UserID),
"user_name", strings.TrimSpace(tokenData.UserName),
)
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return err
}
return nil
}
func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenData) error {
if tokenData == nil {
return fmt.Errorf("token data is empty")
}
if p != nil && p.IdentityEnricher != nil {
if err := p.IdentityEnricher(ctx, tokenData); err != nil {
return fmt.Errorf("resolve login identity: %w", err)
}
}
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
}
return nil
}
func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error {
if tokenData == nil {
return fmt.Errorf("token data is empty")
}
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
}
return oauthSaveToken(p.configDir, tokenData)
}
// Logout clears all stored credentials.
func (p *OAuthProvider) Logout() error {
return DeleteTokenData(p.configDir)
@@ -641,6 +641,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
oldLoad := oauthLoadToken
oldLoadLocked := oauthLoadTokenLocked
oldAcquire := oauthAcquireLock
oldMark := oauthMarkProfile
oldFetch := oauthFetchClientID
@@ -651,6 +652,7 @@ func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
oldSaveLocked := oauthSaveTokenLocked
t.Cleanup(func() {
oauthLoadToken = oldLoad
oauthLoadTokenLocked = oldLoadLocked
oauthAcquireLock = oldAcquire
oauthMarkProfile = oldMark
oauthFetchClientID = oldFetch
@@ -665,6 +667,9 @@ func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
})
fail := errors.New("fail")
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default(), Output: io.Discard}
oauthLoadTokenLocked = func(configDir, _ string) (*TokenData, error) {
return oauthLoadToken(configDir)
}
valid := &TokenData{AccessToken: "valid", ExpiresAt: time.Now().Add(time.Hour)}
oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
+41 -7
View File
@@ -69,13 +69,44 @@ type PortableImportReport struct {
OSMismatch bool
}
// PortableExportSupported reports whether the current platform can produce a
// bundle that includes the file-based DEK required for import elsewhere.
// PortableExportSupported reports whether the current credential backend can
// produce a bundle that includes the file-based DEK required for import
// elsewhere.
func PortableExportSupported() bool {
if portableRuntimeGOOS() != "darwin" {
return true
return PortableExportSupportError() == nil
}
// PortableExportSupportError explains why the current credential backend
// cannot produce a portable auth bundle. A nil error means export is
// supported.
func PortableExportSupportError() error {
return portableExportSupportError(portableRuntimeGOOS(), os.Getenv(keychain.DisableKeychainEnv))
}
func portableExportSupportError(goos, disableKeychain string) error {
switch goos {
case "windows":
return fmt.Errorf("portable auth export is not supported on Windows because credentials are stored as DPAPI-protected HKCU Registry values; a portable conversion is not implemented")
case "darwin":
if disableKeychain == "" {
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)
}
}
return os.Getenv(keychain.DisableKeychainEnv) != ""
return nil
}
// PortableImportSupportError explains why the current credential backend
// cannot consume a portable auth bundle. A nil error means import is
// supported.
func PortableImportSupportError() error {
return portableImportSupportError(portableRuntimeGOOS())
}
func portableImportSupportError(goos string) error {
if goos == "windows" {
return fmt.Errorf("portable auth import is not supported on Windows because file-DEK credentials cannot be represented as DPAPI-protected HKCU Registry values; a portable conversion is not implemented")
}
return nil
}
// PortableAuthTargetPopulated reports whether local auth files would be
@@ -128,8 +159,8 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
if w == nil {
return fmt.Errorf("missing output writer")
}
if !PortableExportSupported() {
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)
if err := PortableExportSupportError(); err != nil {
return err
}
keychainDir := keychain.StorageDir(keychain.Service)
if _, err := portableStat(keychainDir); err != nil {
@@ -180,6 +211,9 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
if r == nil {
return PortableImportReport{}, fmt.Errorf("missing input reader")
}
if err := PortableImportSupportError(); err != nil {
return PortableImportReport{}, err
}
gz, err := gzip.NewReader(r)
if err != nil {
return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err)
+204 -11
View File
@@ -14,30 +14,126 @@
package auth
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestPortableExportSupported(t *testing.T) {
if runtime.GOOS != "darwin" {
if !PortableExportSupported() {
t.Fatal("PortableExportSupported() should be true on non-darwin")
func TestCrossPlatformCoveragePortableExportSupported(t *testing.T) {
switch runtime.GOOS {
case "windows":
if PortableExportSupported() {
t.Fatal("PortableExportSupported() should be false for the Windows DPAPI backend")
}
var bundle bytes.Buffer
err := ExportPortableAuthBundle(t.TempDir(), &bundle)
if err == nil || !strings.Contains(err.Error(), "DPAPI") {
t.Fatalf("ExportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
}
if bundle.Len() != 0 {
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes on unsupported Windows backend", bundle.Len())
}
return
case "darwin":
t.Setenv(keychain.DisableKeychainEnv, "")
if PortableExportSupported() {
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
}
t.Setenv(keychain.DisableKeychainEnv, "1")
if !PortableExportSupported() {
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
}
return
default:
if !PortableExportSupported() {
t.Fatal("PortableExportSupported() should be true for file-DEK platforms")
}
}
t.Setenv(keychain.DisableKeychainEnv, "")
if PortableExportSupported() {
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
}
func TestCrossPlatformCoveragePortableExportSupportError(t *testing.T) {
tests := []struct {
name string
goos string
disableKeychain string
wantError string
}{
{name: "windows dpapi", goos: "windows", disableKeychain: "1", wantError: "DPAPI"},
{name: "darwin keychain", goos: "darwin", wantError: keychain.DisableKeychainEnv},
{name: "darwin file dek", goos: "darwin", disableKeychain: "1"},
{name: "linux file dek", goos: "linux"},
}
t.Setenv(keychain.DisableKeychainEnv, "1")
if !PortableExportSupported() {
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := portableExportSupportError(test.goos, test.disableKeychain)
if test.wantError == "" {
if err != nil {
t.Fatalf("portableExportSupportError() error = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("portableExportSupportError() error = %v, want substring %q", err, test.wantError)
}
})
}
}
func TestCrossPlatformCoveragePortableImportSupportError(t *testing.T) {
tests := []struct {
name string
goos string
wantError string
}{
{name: "windows dpapi", goos: "windows", wantError: "DPAPI"},
{name: "darwin", goos: "darwin"},
{name: "linux", goos: "linux"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := portableImportSupportError(test.goos)
if test.wantError == "" {
if err != nil {
t.Fatalf("portableImportSupportError() error = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("portableImportSupportError() error = %v, want substring %q", err, test.wantError)
}
})
}
}
func TestCrossPlatformCoverageImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows DPAPI contract requires a native Windows runner")
}
root := t.TempDir()
configDir := filepath.Join(root, ".dws")
keychainDir := filepath.Join(root, "keychain")
t.Setenv(keychain.StorageDirEnv, keychainDir)
bundle := validPortableAuthBundleForTest(t)
if _, err := ImportPortableAuthBundle(configDir, bytes.NewReader(bundle)); err == nil || !strings.Contains(err.Error(), "DPAPI") {
t.Fatalf("ImportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
}
for _, path := range []string{configDir, keychainDir} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, err)
}
}
}
@@ -192,7 +288,7 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
if err != nil {
t.Fatalf("LoadProfiles() after import error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profiles after import = %#v", cfg)
}
if len(cfg.Profiles) != 2 {
@@ -215,9 +311,106 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
}
}
func TestPortableAuthBundleRoundTripPreservesSameCorpAccounts(t *testing.T) {
requirePortableFileBackend(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile("") })
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
sourceConfig := filepath.Join(t.TempDir(), ".dws")
first := &TokenData{
AccessToken: "access-first",
RefreshToken: "refresh-first",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_same",
CorpName: "Same Org",
UserID: "user_1",
UserName: "账号一",
}
second := *first
second.AccessToken = "access-second"
second.RefreshToken = "refresh-second"
second.UserID = "user_2"
second.UserName = "账号二"
if err := SaveTokenData(sourceConfig, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
if err := SaveTokenData(sourceConfig, &second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
}
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
targetConfig := filepath.Join(t.TempDir(), ".dws")
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
}
for selector, wantToken := range map[string]string{
"corp_same:user_1": "access-first",
"corp_same:user_2": "access-second",
"corp_same": "access-second",
} {
loaded, err := LoadTokenDataForProfile(targetConfig, selector)
if err != nil {
t.Fatalf("LoadTokenDataForProfile(%s) error = %v", selector, err)
}
if loaded.AccessToken != wantToken {
t.Fatalf("profile %s token = %q, want %q", selector, loaded.AccessToken, wantToken)
}
}
}
func requirePortableFileBackend(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage")
}
}
func validPortableAuthBundleForTest(t *testing.T) []byte {
t.Helper()
var bundle bytes.Buffer
gz := gzip.NewWriter(&bundle)
tw := tar.NewWriter(gz)
manifest := portableAuthBundleManifest{
Version: 1,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
OS: "linux",
KeychainService: keychain.Service,
ConfigFiles: []string{"app.json"},
}
if err := writePortableManifest(tw, manifest); err != nil {
t.Fatalf("writePortableManifest() error = %v", err)
}
entries := map[string]string{
path.Join("keychain", keychain.Service, keychain.AccountToken+".enc"): "encrypted-token",
"config/app.json": "{}",
}
for name, content := range entries {
hdr := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader(%s) error = %v", name, err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatalf("Write(%s) error = %v", name, err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("tar.Close() error = %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip.Close() error = %v", err)
}
return bundle.Bytes()
}
+1011 -208
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -46,7 +46,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
if err := preflightTokenPersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
if err := preflightTokenRefreshPersistence(&TokenData{CorpID: "corp"}); err != nil {
if err := preflightTokenRefreshPersistence(t.TempDir(), &TokenData{CorpID: "corp"}); err != nil {
t.Fatal(err)
}
@@ -79,7 +79,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
if err := preflightTokenPersistence(dir); !errors.Is(err, fail) {
t.Fatalf("profile slot error = %v", err)
}
if err := preflightTokenRefreshPersistence(&TokenData{CorpID: "corp"}); !errors.Is(err, fail) {
if err := preflightTokenRefreshPersistence(t.TempDir(), &TokenData{CorpID: "corp"}); !errors.Is(err, fail) {
t.Fatalf("refresh profile slot error = %v", err)
}
@@ -91,6 +91,7 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
}
func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
origGOOS := portableRuntimeGOOS
origStat := portableStat
origConfigFiles := portableConfigFilesForExport
origManifest := portableWriteManifest
@@ -99,6 +100,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
origKeychainGet := authKeychainGet
origKeychainExists := authKeychainExists
t.Cleanup(func() {
portableRuntimeGOOS = origGOOS
portableStat = origStat
portableConfigFilesForExport = origConfigFiles
portableWriteManifest = origManifest
@@ -107,6 +109,7 @@ func TestCrossPlatformCoveragePortableExportRemainingEdges(t *testing.T) {
authKeychainGet = origKeychainGet
authKeychainExists = origKeychainExists
})
portableRuntimeGOOS = func() string { return "linux" }
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Setenv(keychain.DisableKeychainEnv, "1")
+685 -69
View File
@@ -28,6 +28,8 @@ import (
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -35,35 +37,49 @@ var (
tokenJSONMarshalIndent = json.MarshalIndent
tokenJSONMarshal = json.Marshal
tokenMkdirAll = os.MkdirAll
tokenReadFile = os.ReadFile
tokenWriteFile = os.WriteFile
tokenRename = os.Rename
tokenRemove = os.Remove
tokenGlob = filepath.Glob
tokenSaveKeychainForCorpID = SaveTokenDataKeychainForCorpID
tokenSaveKeychainForIdentity = SaveTokenDataKeychainForIdentity
tokenSaveKeychain = SaveTokenDataKeychain
tokenLoadKeychainForCorpID = LoadTokenDataKeychainForCorpID
tokenLoadKeychainIdentity = LoadTokenDataKeychainForIdentity
tokenLoadKeychain = LoadTokenDataKeychain
tokenKeychainExists = TokenDataExistsKeychain
tokenDeleteKeychainForCorpID = DeleteTokenDataKeychainForCorpID
tokenDeleteKeychainIdentity = DeleteTokenDataKeychainForIdentity
tokenDeleteKeychain = DeleteTokenDataKeychain
tokenRemoveAuthTokenEntries = keychain.RemoveAuthTokenEntries
tokenLoadSecure = LoadSecureTokenData
tokenDeleteSecure = DeleteSecureData
tokenResolveProfile = resolveProfileForLoad
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
tokenRemoveProfile = removeProfileLocked
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
tokenLoadProfiles = LoadProfiles
tokenWriteMarker = WriteTokenMarker
tokenDeleteMarker = DeleteTokenMarker
tokenParseURL = url.Parse
tokenNewRequest = http.NewRequestWithContext
tokenDefaultConfigDir = getDefaultConfigDir
tokenLoadData = LoadTokenData
tokenRevokeURL = GetRevokeTokenURL
tokenLogoutURL = LogoutURL
tokenLogoutContinueURL = LogoutContinueURL
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
tokenResolveProfile = func(configDir, selector string) (*Profile, error) {
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
return profile, err
}
tokenResolveDeletion = resolveProfileDeletionSelection
tokenResolveSelection = resolveProfileSelection
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
tokenRemoveProfile = removeProfileLocked
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
tokenLoadProfiles = LoadProfiles
tokenSaveProfiles = SaveProfiles
tokenWriteMarker = WriteTokenMarker
tokenWriteManualMarker = WriteManualTokenMarker
tokenDeleteMarker = DeleteTokenMarker
tokenParseURL = url.Parse
tokenNewRequest = http.NewRequestWithContext
tokenDefaultConfigDir = getDefaultConfigDir
tokenLoadData = LoadTokenData
tokenRevokeURL = GetRevokeTokenURL
tokenMCPBaseURL = GetMCPBaseURL
tokenLogoutURL = LogoutURL
tokenLogoutContinueURL = LogoutContinueURL
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
)
// TokenData holds the OAuth token set persisted to disk.
@@ -109,14 +125,29 @@ const tokenJSONFile = "token.json"
// TokenMarker is a lightweight file the host application reads to detect
// whether the CLI has a valid token without accessing the keychain.
type TokenMarker struct {
UpdatedAt string `json:"updated_at"`
UpdatedAt string `json:"updated_at"`
ManualToken bool `json:"manual_token,omitempty"`
}
// WriteTokenMarker writes a token.json marker containing only an updated_at
// timestamp. The host application uses this file's presence and mtime to
// decide whether it needs to trigger a new auth exchange.
func WriteTokenMarker(configDir string) error {
marker := TokenMarker{UpdatedAt: time.Now().Format(time.RFC3339)}
return writeTokenMarker(configDir, false)
}
// WriteManualTokenMarker marks the legacy global keychain slot as an explicit
// `auth login --token` credential. The additive field keeps older hosts, which
// only inspect token.json presence and mtime, fully compatible.
func WriteManualTokenMarker(configDir string) error {
return writeTokenMarker(configDir, true)
}
func writeTokenMarker(configDir string, manual bool) error {
marker := TokenMarker{
UpdatedAt: time.Now().Format(time.RFC3339),
ManualToken: manual,
}
data, _ := tokenJSONMarshalIndent(marker, "", " ")
if err := tokenMkdirAll(configDir, 0o700); err != nil {
return err
@@ -128,6 +159,23 @@ func WriteTokenMarker(configDir string) error {
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
}
func manualTokenMarkerActive(configDir string) (bool, error) {
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
var marker TokenMarker
if err := json.Unmarshal(data, &marker); err != nil {
// Historical hosts only require the marker's presence. A malformed old
// marker must not make profile authentication unusable.
return false, nil
}
return marker.ManualToken, nil
}
// DeleteTokenMarker removes the token.json marker file.
func DeleteTokenMarker(configDir string) error {
if err := tokenRemove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
@@ -158,26 +206,126 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
return saveTokenViaHook(h, configDir, data)
}
if data != nil && strings.TrimSpace(data.CorpID) != "" {
if err := tokenSaveKeychainForCorpID(data.CorpID, data); err != nil {
corpID := strings.TrimSpace(data.CorpID)
userID := strings.TrimSpace(data.UserID)
cfg, err := tokenLoadProfiles(configDir)
if err != nil {
return err
}
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
runtimeSelector := strings.TrimSpace(RuntimeProfile())
makeCurrent := runtimeSelector == ""
exactSelector := profileSelector(corpID, userID)
mirrorOrg := makeCurrent ||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
upgradesLegacyProfile := !existingIdentity && userID != "" && legacyProfileIndexByCorpID(cfg, corpID) >= 0
logging.AuthDebug(
"auth.token.persist.plan",
"corp_id", corpID,
"user_id", userID,
"user_name", strings.TrimSpace(data.UserName),
"identity_selector", exactSelector,
"existing_identity", existingIdentity,
"upgrades_legacy_profile", upgradesLegacyProfile,
"profiles_before", len(cfg.Profiles),
"runtime_profile", runtimeSelector,
"write_identity_slot", userID != "",
"write_org_mirror", mirrorOrg,
"write_global_mirror", makeCurrent,
)
snapshot, err := snapshotTokenPersistence(configDir, cfg, corpID, userID, mirrorOrg)
if err != nil {
return err
}
preserveManualDefault := !makeCurrent &&
snapshot.marker.known &&
snapshot.marker.exists &&
snapshot.marker.manual
rollback := func(operationErr error) error {
if rollbackErr := restoreTokenPersistence(configDir, snapshot); rollbackErr != nil {
return errors.Join(operationErr, fmt.Errorf("rollback token persistence: %w", rollbackErr))
}
return operationErr
}
if userID != "" {
if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil {
return rollback(err)
}
} else {
for _, profile := range cfg.Profiles {
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) != "" {
return fmt.Errorf("cannot store profile for corpId %q without userId because account identities already exist", corpID)
}
}
}
if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
return rollback(err)
}
if mirrorOrg {
if err := tokenSaveKeychainForCorpID(corpID, data); err != nil {
return rollback(err)
}
}
if makeCurrent {
if err := tokenSaveKeychain(data); err != nil {
return err
return rollback(err)
}
} else if !preserveManualDefault {
if err := tokenSyncLegacyMirror(configDir); err != nil {
return rollback(err)
}
} else if err := tokenSyncLegacyMirror(configDir); err != nil {
return err
}
return tokenWriteMarker(configDir)
if preserveManualDefault {
if err := tokenWriteManualMarker(configDir); err != nil {
return rollback(err)
}
} else if err := tokenWriteMarker(configDir); err != nil {
return rollback(err)
}
logging.AuthDebug(
"auth.token.persist.done",
"corp_id", corpID,
"user_id", userID,
"user_name", strings.TrimSpace(data.UserName),
"identity_selector", exactSelector,
"write_identity_slot", userID != "",
"write_org_mirror", mirrorOrg,
"write_global_mirror", makeCurrent,
)
return nil
}
legacySnapshot, err := snapshotTokenSlot(tokenLoadKeychain)
if err != nil {
return err
}
markerSnapshot, err := snapshotTokenMarker(configDir)
if err != nil {
return err
}
if err := tokenSaveKeychain(data); err != nil {
return err
}
return tokenWriteMarker(configDir)
if err := tokenWriteManualMarker(configDir); err != nil {
var rollbackErr error
if restoreErr := restoreTokenSlot(
legacySnapshot,
tokenSaveKeychain,
tokenDeleteKeychain,
); restoreErr != nil {
rollbackErr = errors.Join(rollbackErr, restoreErr)
}
if restoreErr := restoreTokenMarker(configDir, markerSnapshot); restoreErr != nil {
rollbackErr = errors.Join(rollbackErr, restoreErr)
}
if rollbackErr != nil {
return errors.Join(err, fmt.Errorf("rollback manual token persistence: %w", rollbackErr))
}
return err
}
return nil
}
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
@@ -213,13 +361,38 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
return &td, nil
}
var result *TokenData
err := withProfilesLock(configDir, func() error {
var loadErr error
result, loadErr = loadTokenDataForProfileLocked(configDir, profile)
return loadErr
})
return result, err
}
func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error) {
// Default: keychain with legacy .data migration
if strings.TrimSpace(profile) == "" {
manual, err := manualTokenMarkerActive(configDir)
if err != nil {
return nil, err
}
if manual {
data, loadErr := tokenLoadKeychain()
if loadErr == nil && data != nil && strings.TrimSpace(data.CorpID) == "" {
return data, nil
}
if loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) {
return nil, loadErr
}
}
}
selected, err := tokenResolveProfile(configDir, profile)
if err != nil {
return nil, err
}
if selected != nil {
data, err := tokenLoadKeychainForCorpID(selected.CorpID)
data, err := tokenLoadProfileIdentity(*selected)
if err == nil {
return data, nil
}
@@ -231,13 +404,21 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
// the SAME org; otherwise surface the error instead of silently acting
// as a different organization (the legacy mirror may have drifted).
if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) &&
(strings.TrimSpace(selected.UserID) == "" || strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID)) {
return legacy, nil
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
return nil, lerr
}
return nil, err
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil {
return nil, err
}
if cfg != nil && cfg.Version >= profilesVersion {
return nil, ErrTokenDataNotFound
}
if tokenKeychainExists() {
return tokenLoadKeychain()
}
@@ -253,6 +434,36 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
return data, nil
}
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
if strings.TrimSpace(profile.UserID) == "" {
return tokenLoadKeychainForCorpID(profile.CorpID)
}
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
if err == nil {
return data, nil
}
if !errors.Is(err, ErrTokenDataNotFound) {
return nil, err
}
orgData, orgErr := tokenLoadKeychainForCorpID(profile.CorpID)
if orgErr != nil {
if errors.Is(orgErr, ErrTokenDataNotFound) {
return nil, err
}
return nil, orgErr
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
}
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
return nil, err
}
if saveErr := tokenSaveKeychainForIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
return nil, saveErr
}
return orgData, nil
}
// DeleteTokenData removes token data. When an edition hook (DeleteToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain + legacy cleanup.
@@ -275,25 +486,114 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
}
func deleteTokenDataForProfileLocked(configDir, profile string) error {
selected, err := tokenResolveProfile(configDir, profile)
if strings.TrimSpace(profile) == "" {
manual, err := manualTokenMarkerActive(configDir)
if err != nil {
return err
}
if manual {
return deleteManualTokenDataLocked(configDir)
}
}
if err := profilesEnsureMigration(configDir); err != nil {
return err
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil {
return err
}
effectiveSelector := strings.TrimSpace(profile)
if effectiveSelector == "" {
effectiveSelector = strings.TrimSpace(cfg.CurrentProfile)
}
var selected *Profile
exact := false
if effectiveSelector != "" {
selected, exact, err = tokenResolveDeletion(cfg, effectiveSelector)
if err != nil {
return err
}
}
if selected != nil {
keychainErr := tokenDeleteKeychainForCorpID(selected.CorpID)
_, removeErr := tokenRemoveProfile(configDir, selected.CorpID)
legacyErr := tokenSyncLegacyMirror(configDir)
secureErr := tokenDeleteSecure(configDir)
if keychainErr != nil {
return keychainErr
removed := *selected
originalCfg := cloneProfilesConfig(cfg)
identitySnapshots, err := snapshotDeletionIdentities(cfg, removed, exact)
if err != nil {
return err
}
if removeErr != nil {
return removeErr
orgSnapshot := snapshotTokenSlotForDeletion(func() (*TokenData, error) {
return tokenLoadKeychainForCorpID(removed.CorpID)
})
legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain)
markerSnapshot := snapshotTokenMarkerForDeletion(configDir)
// Clean the deprecated secure-store copy before changing the profile
// transaction. A cleanup failure therefore leaves all current metadata
// and keychain slots untouched.
if err := tokenDeleteSecure(configDir); err != nil {
return err
}
if legacyErr != nil {
return legacyErr
removeSelector := removed.CorpID
orgCurrent := false
if exact {
removeSelector = ProfileSelector(removed)
orgCurrent = exactProfileSelectorForCorp(
cfg,
removed.CorpID,
cfg.OrgCurrentProfiles[removed.CorpID],
) == ProfileSelector(removed)
}
return secureErr
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
return err
}
rollback := func(operationErr error) error {
if rollbackErr := restoreProfileDeletion(
configDir,
originalCfg,
identitySnapshots,
removed.CorpID,
orgSnapshot,
legacySnapshot,
markerSnapshot,
); rollbackErr != nil {
return errors.Join(operationErr, fmt.Errorf("rollback profile deletion: %w", rollbackErr))
}
return operationErr
}
if !exact || orgCurrent {
updated, loadErr := tokenLoadProfiles(configDir)
if loadErr != nil {
return rollback(loadErr)
}
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
if exact && replacementSelector != "" {
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
if resolveErr != nil {
return rollback(resolveErr)
}
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
return rollback(err)
}
} else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
return rollback(err)
}
}
preserveManualDefault := markerSnapshot.known &&
markerSnapshot.exists &&
markerSnapshot.manual
if !preserveManualDefault {
if err := tokenSyncLegacyMirror(configDir); err != nil {
return rollback(err)
}
}
for _, snapshot := range identitySnapshots {
if err := tokenDeleteKeychainIdentity(snapshot.profile.CorpID, snapshot.profile.UserID); err != nil {
return rollback(err)
}
}
return nil
}
keychainErr := tokenDeleteKeychain()
@@ -308,6 +608,308 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
return markerErr
}
func deleteManualTokenDataLocked(configDir string) error {
legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain)
if err := tokenDeleteSecure(configDir); err != nil {
return err
}
if err := tokenDeleteKeychain(); err != nil {
return err
}
if err := tokenDeleteMarker(configDir); err != nil {
if legacySnapshot.known {
if rollbackErr := restoreTokenSlot(
legacySnapshot,
tokenSaveKeychain,
tokenDeleteKeychain,
); rollbackErr != nil {
return errors.Join(err, fmt.Errorf("rollback manual token deletion: %w", rollbackErr))
}
}
return err
}
return nil
}
type deletionIdentitySnapshot struct {
profile Profile
token *TokenData
}
type tokenSlotSnapshot struct {
token *TokenData
known bool
exists bool
}
type tokenMarkerSnapshot struct {
known bool
exists bool
manual bool
}
type tokenPersistenceSnapshot struct {
profiles *ProfilesConfig
corpID string
userID string
identity tokenSlotSnapshot
org tokenSlotSnapshot
legacy tokenSlotSnapshot
marker tokenMarkerSnapshot
}
func cloneProfilesConfig(cfg *ProfilesConfig) *ProfilesConfig {
if cfg == nil {
return nil
}
cloned := *cfg
cloned.Profiles = append([]Profile(nil), cfg.Profiles...)
for i := range cloned.Profiles {
cloned.Profiles[i].AuthorizedDomains = append(
[]string(nil),
cloned.Profiles[i].AuthorizedDomains...,
)
}
if cfg.OrgCurrentProfiles != nil {
cloned.OrgCurrentProfiles = make(map[string]string, len(cfg.OrgCurrentProfiles))
for corpID, selector := range cfg.OrgCurrentProfiles {
cloned.OrgCurrentProfiles[corpID] = selector
}
}
return &cloned
}
func snapshotDeletionIdentities(cfg *ProfilesConfig, removed Profile, exact bool) ([]deletionIdentitySnapshot, error) {
var profiles []Profile
if exact {
profiles = []Profile{removed}
} else {
for _, candidate := range cfg.Profiles {
if strings.TrimSpace(candidate.CorpID) == strings.TrimSpace(removed.CorpID) {
profiles = append(profiles, candidate)
}
}
}
snapshots := make([]deletionIdentitySnapshot, 0, len(profiles))
for _, candidate := range profiles {
if strings.TrimSpace(candidate.UserID) == "" {
continue
}
data, err := tokenLoadKeychainIdentity(candidate.CorpID, candidate.UserID)
if err != nil {
if errors.Is(err, ErrTokenDataNotFound) {
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate})
continue
}
// A damaged target slot must remain removable. It cannot be restored
// during rollback, but every readable slot in the same transaction
// still is.
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate})
continue
}
snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate, token: data})
}
return snapshots, nil
}
func snapshotTokenSlot(load func() (*TokenData, error)) (tokenSlotSnapshot, error) {
data, err := load()
if err != nil {
if errors.Is(err, ErrTokenDataNotFound) {
return tokenSlotSnapshot{known: true}, nil
}
return tokenSlotSnapshot{}, err
}
return tokenSlotSnapshot{token: data, known: true, exists: data != nil}, nil
}
func snapshotTokenSlotForDeletion(load func() (*TokenData, error)) tokenSlotSnapshot {
snapshot, err := snapshotTokenSlot(load)
if err != nil {
return tokenSlotSnapshot{}
}
return snapshot
}
func snapshotTokenMarker(configDir string) (tokenMarkerSnapshot, error) {
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
if err != nil {
if os.IsNotExist(err) {
return tokenMarkerSnapshot{known: true}, nil
}
return tokenMarkerSnapshot{}, err
}
var marker TokenMarker
if err := json.Unmarshal(data, &marker); err != nil {
return tokenMarkerSnapshot{known: true, exists: true}, nil
}
return tokenMarkerSnapshot{known: true, exists: true, manual: marker.ManualToken}, nil
}
func snapshotTokenMarkerForDeletion(configDir string) tokenMarkerSnapshot {
snapshot, err := snapshotTokenMarker(configDir)
if err != nil {
return tokenMarkerSnapshot{}
}
return snapshot
}
func restoreProfileDeletion(
configDir string,
cfg *ProfilesConfig,
identities []deletionIdentitySnapshot,
corpID string,
org tokenSlotSnapshot,
legacy tokenSlotSnapshot,
marker tokenMarkerSnapshot,
) error {
var rollbackErr error
for _, snapshot := range identities {
if snapshot.token == nil {
continue
}
if err := tokenSaveKeychainForIdentity(
snapshot.profile.CorpID,
snapshot.profile.UserID,
snapshot.token,
); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if err := tokenSaveProfiles(configDir, cloneProfilesConfig(cfg)); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
if org.known {
if org.exists {
if err := tokenSaveKeychainForCorpID(corpID, org.token); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := tokenDeleteKeychainForCorpID(corpID); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if legacy.known {
if legacy.exists {
if err := tokenSaveKeychain(legacy.token); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := tokenDeleteKeychain(); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if marker.known {
if err := restoreTokenMarker(configDir, marker); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func snapshotTokenPersistence(
configDir string,
cfg *ProfilesConfig,
corpID, userID string,
includeOrganization bool,
) (tokenPersistenceSnapshot, error) {
snapshot := tokenPersistenceSnapshot{
profiles: cloneProfilesConfig(cfg),
corpID: corpID,
userID: userID,
}
var err error
if strings.TrimSpace(userID) != "" {
snapshot.identity, err = snapshotTokenSlot(func() (*TokenData, error) {
return tokenLoadKeychainIdentity(corpID, userID)
})
if err != nil {
return tokenPersistenceSnapshot{}, err
}
}
if includeOrganization {
snapshot.org, err = snapshotTokenSlot(func() (*TokenData, error) {
return tokenLoadKeychainForCorpID(corpID)
})
if err != nil {
return tokenPersistenceSnapshot{}, err
}
}
snapshot.legacy, err = snapshotTokenSlot(tokenLoadKeychain)
if err != nil {
return tokenPersistenceSnapshot{}, err
}
snapshot.marker, err = snapshotTokenMarker(configDir)
if err != nil {
return tokenPersistenceSnapshot{}, err
}
return snapshot, nil
}
func restoreTokenPersistence(configDir string, snapshot tokenPersistenceSnapshot) error {
var rollbackErr error
if err := tokenSaveProfiles(configDir, cloneProfilesConfig(snapshot.profiles)); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
if strings.TrimSpace(snapshot.userID) != "" && snapshot.identity.known {
if err := restoreTokenSlot(
snapshot.identity,
func(data *TokenData) error {
return tokenSaveKeychainForIdentity(snapshot.corpID, snapshot.userID, data)
},
func() error {
return tokenDeleteKeychainIdentity(snapshot.corpID, snapshot.userID)
},
); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if snapshot.org.known {
if err := restoreTokenSlot(
snapshot.org,
func(data *TokenData) error {
return tokenSaveKeychainForCorpID(snapshot.corpID, data)
},
func() error {
return tokenDeleteKeychainForCorpID(snapshot.corpID)
},
); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if snapshot.legacy.known {
if err := restoreTokenSlot(snapshot.legacy, tokenSaveKeychain, tokenDeleteKeychain); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if snapshot.marker.known {
if err := restoreTokenMarker(configDir, snapshot.marker); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func restoreTokenSlot(
snapshot tokenSlotSnapshot,
save func(*TokenData) error,
remove func() error,
) error {
if snapshot.exists {
return save(snapshot.token)
}
return remove()
}
func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error {
switch {
case !marker.exists:
return tokenDeleteMarker(configDir)
case marker.manual:
return tokenWriteManualMarker(configDir)
default:
return tokenWriteMarker(configDir)
}
}
// DeleteAllTokenData removes all profile-scoped and legacy token data.
func DeleteAllTokenData(configDir string) error {
if h := edition.Get(); h.DeleteToken != nil {
@@ -315,14 +917,10 @@ func DeleteAllTokenData(configDir string) error {
}
return withProfilesLock(configDir, func() error {
var firstErr error
// Best-effort: even if profiles.json is unreadable, still clear every
// other slot so the user can always self-heal via auth reset / logout.
if cfg, err := tokenLoadProfiles(configDir); err == nil {
for _, profile := range cfg.Profiles {
if e := tokenDeleteKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
firstErr = e
}
}
// Sweep the complete auth-token namespace so orphan identity slots that
// are not present in profiles.json cannot survive reset/logout --all.
if e := tokenRemoveAuthTokenEntries(keychain.Service); e != nil {
firstErr = e
}
if e := tokenRemove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
@@ -335,15 +933,19 @@ func DeleteAllTokenData(configDir string) error {
}
}
}
if e := tokenDeleteKeychain(); e != nil && firstErr == nil {
firstErr = e
}
if e := tokenDeleteSecure(configDir); e != nil && firstErr == nil {
firstErr = e
}
if e := tokenDeleteMarker(configDir); e != nil && firstErr == nil {
firstErr = e
}
if firstErr != nil {
// Preserve an explicit v2 empty registry so any stale mirror that
// could not be removed is never imported on a later read.
if e := profilesSave(configDir, &ProfilesConfig{Version: profilesVersion}); e != nil {
return fmt.Errorf("%v; save logged-out profile tombstone: %w", firstErr, e)
}
}
return firstErr
})
}
@@ -353,18 +955,42 @@ func DeleteAllTokenData(configDir string) error {
// This should be called before deleting local token data.
// The function is best-effort: errors are returned but callers may choose to ignore them.
func RevokeTokenRemote(ctx context.Context) error {
// Use MCP revoke endpoint when clientID is from MCP
if IsClientIDFromMCP() {
return revokeTokenViaMCP(ctx)
tokenData, err := tokenLoadData(tokenDefaultConfigDir())
if err != nil || tokenData == nil {
return nil
}
// Direct mode: use DingTalk logout endpoint
// Historical token records may not have Source. Preserve the legacy
// process-wide MCP decision only for those records.
if strings.TrimSpace(tokenData.Source) == "" && IsClientIDFromMCP() {
copy := *tokenData
copy.Source = "mcp"
tokenData = &copy
}
return RevokeTokenRemoteForData(ctx, tokenData)
}
// RevokeTokenRemoteForData revokes the supplied account token using the
// credential source and client ID persisted with that exact identity.
func RevokeTokenRemoteForData(ctx context.Context, tokenData *TokenData) error {
if tokenData == nil {
return nil
}
clientID := strings.TrimSpace(tokenData.ClientID)
if clientID == "" {
clientID = ClientID()
}
if strings.EqualFold(strings.TrimSpace(tokenData.Source), "mcp") {
return revokeTokenViaMCP(ctx, tokenData, clientID)
}
// Direct mode: use DingTalk logout endpoint.
logoutURL, err := tokenParseURL(tokenLogoutURL)
if err != nil {
return fmt.Errorf("parsing logout URL: %w", err)
}
q := logoutURL.Query()
q.Set("client_id", ClientID())
q.Set("client_id", clientID)
q.Set("continue", tokenLogoutContinueURL)
logoutURL.RawQuery = q.Encode()
@@ -388,20 +1014,10 @@ func RevokeTokenRemote(ctx context.Context) error {
}
// revokeTokenViaMCP revokes token via MCP endpoint.
func revokeTokenViaMCP(ctx context.Context) error {
revokeURL := tokenRevokeURL()
if revokeURL == "" {
return nil // No revoke endpoint available
}
// Load current token to get accessToken
tokenData, err := tokenLoadData(tokenDefaultConfigDir())
if err != nil || tokenData == nil {
return nil // No token to revoke
}
func revokeTokenViaMCP(ctx context.Context, tokenData *TokenData, clientID string) error {
revokeURL := tokenMCPBaseURL() + MCPRevokeTokenPath
body := map[string]string{
"clientId": ClientID(),
"clientId": clientID,
"accessToken": tokenData.AccessToken,
}
bodyBytes, err := tokenJSONMarshal(body)
+139 -6
View File
@@ -18,6 +18,7 @@ package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -92,7 +93,7 @@ func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T
}
}
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
func TestLoadTokenDataUsesIdentitySlotWhenOrganizationMirrorIsUnreadable(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
@@ -106,11 +107,11 @@ func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *test
}
loaded, err := LoadTokenData(configDir)
if err == nil {
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded != nil {
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
if loaded == nil || loaded.AccessToken != data.AccessToken || loaded.UserID != data.UserID {
t.Fatalf("LoadTokenData() = %#v, want identity token %#v", loaded, data)
}
}
@@ -150,6 +151,65 @@ func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
}
}
func TestExactOrgCurrentRefreshRejectsUnreadableOrgMirror(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_exact_refresh", "corp_exact", "Exact Org")
data.UserID = "user_exact"
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)
}
SetRuntimeProfile("corp_exact:user_exact")
defer SetRuntimeProfile("")
if err := preflightTokenRefreshPersistence(configDir, data); err == nil ||
!strings.Contains(err.Error(), "profile token slot") {
t.Fatalf("preflightTokenRefreshPersistence(exact current) error = %v, want unreadable org mirror", err)
}
}
func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
first := testToken("at_first", "corp_exact", "Exact Org")
first.UserID = "user_first"
second := testToken("at_second", "corp_exact", "Exact Org")
second.UserID = "user_second"
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(first.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
}
SetRuntimeProfile("corp_exact:user_first")
defer SetRuntimeProfile("")
if err := preflightTokenRefreshPersistence(configDir, first); err != nil {
t.Fatalf("preflightTokenRefreshPersistence(exact non-current) error = %v", err)
}
updated := *first
updated.AccessToken = "at_first_refreshed"
updated.RefreshToken = "rt_first_refreshed"
if err := SaveTokenData(configDir, &updated); err != nil {
t.Fatalf("SaveTokenData(exact non-current refresh) error = %v", err)
}
loaded, err := LoadTokenDataForProfile(configDir, "corp_exact:user_first")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(refreshed) error = %v", err)
}
if loaded.AccessToken != updated.AccessToken || loaded.RefreshToken != updated.RefreshToken {
t.Fatalf("refreshed exact token = %#v, want %#v", loaded, updated)
}
}
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
@@ -232,7 +292,7 @@ func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
}
if err := preflightTokenRefreshPersistence(dataB); err != nil {
if err := preflightTokenRefreshPersistence(configDir, dataB); err != nil {
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
}
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
@@ -343,6 +403,45 @@ func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
}
}
func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_future_refresh", "corp_future", "Future Org")
data.ExpiresAt = time.Now().Add(-time.Hour)
data.RefreshExpAt = time.Now().Add(time.Hour)
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
cfg.Version = profilesVersion + 1
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if err := os.WriteFile(ProfilesPath(configDir), raw, 0o600); err != nil {
t.Fatalf("write future profiles: %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.Login(context.Background(), false)
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
t.Fatalf("Login() error = %v, want future profiles rejection", 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")
@@ -374,3 +473,37 @@ func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
t.Fatalf("HTTP calls = %d, want 1", got)
}
}
func TestExchangeAuthCodeExplicitUIDSkipsIdentityOverride(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
var identityCalls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.IdentityEnricher = func(context.Context, *TokenData) error {
identityCalls.Add(1)
return errors.New("identity lookup should not run for explicit uid")
}
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
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", "explicit-user")
if err != nil {
t.Fatalf("ExchangeAuthCode() error = %v", err)
}
if data.UserID != "explicit-user" {
t.Fatalf("ExchangeAuthCode() userId = %q, want explicit-user", data.UserID)
}
if got := identityCalls.Load(); got != 0 {
t.Fatalf("IdentityEnricher calls = %d, want 0", got)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"coverage": {
"surface_products": 22,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_files": 150,
"hint_files": 46,
@@ -160,7 +160,7 @@
{
"tool_path": "calendar participant delete",
"source": "skills/mono/SKILL.md",
"line": 135,
"line": 143,
"candidates": [
"calendar attendee delete",
"calendar event delete",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"version": 1,
"source_hash": "sha256:e4e7ed2cfc099d6e3893e9b059c0041c4ce0b8e103d52743e21b096d71564864",
"source_hash": "sha256:afe01e0b6029a2be43669927798a02ca585f44b0e7cb1a31e8e2ee0c636e73ee",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"catalog": {
"agent_metadata": {
"products_with_metadata": 22,
"source": "embedded-skill-metadata",
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"surface_products": 22,
"surface_tools": 572,
+5 -3
View File
@@ -4150,6 +4150,10 @@ role-get 自行 merge)。
if err := validateRequiredFlags(cmd, "file-name"); err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
if fileSize <= 0 {
return fmt.Errorf("flag --file-size is required and must be a positive integer")
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
@@ -4157,9 +4161,7 @@ role-get 自行 merge)。
toolArgs := map[string]any{
"baseId": baseID,
"fileName": mustGetFlag(cmd, "file-name"),
}
if v, _ := cmd.Flags().GetInt64("file-size"); v > 0 {
toolArgs["fileSize"] = v
"fileSize": fileSize,
}
return callAitableTool("prepare_import_upload", toolArgs)
},
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
)
func runAitableImportUploadCommand(t *testing.T, args ...string) (*aitableTestCaller, error) {
t.Helper()
caller := &aitableTestCaller{}
installAitableDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
cmd := newAitableCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(append([]string{"import", "upload"}, args...))
return caller, cmd.ExecuteContext(context.Background())
}
func TestAitableImportUploadRequiresPositiveFileSize(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing", args: nil},
{name: "zero", args: []string{"--file-size", "0"}},
{name: "negative", args: []string{"--file-size", "-1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"--base-id", "base-smoke", "--file-name", "data.xlsx"}
caller, err := runAitableImportUploadCommand(t, append(args, test.args...)...)
if err == nil {
t.Fatal("expected --file-size validation error")
}
if !strings.Contains(err.Error(), "--file-size") {
t.Fatalf("error = %q, want --file-size validation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid file size dispatched %d MCP call(s): %#v", len(caller.calls), caller.calls)
}
})
}
}
func TestAitableImportUploadPassesFileSizeToMCP(t *testing.T) {
caller, err := runAitableImportUploadCommand(t,
"--base-id", "base-smoke",
"--file-name", "data.xlsx",
"--file-size", "204800",
)
if err != nil {
t.Fatalf("aitable import upload 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.server != "aitable" || call.tool != "prepare_import_upload" {
t.Fatalf("tool call = %s/%s, want aitable/prepare_import_upload", call.server, call.tool)
}
want := map[string]any{
"baseId": "base-smoke",
"fileName": "data.xlsx",
"fileSize": int64(204800),
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("tool args = %#v, want %#v", call.args, want)
}
}
+14
View File
@@ -61,3 +61,17 @@ func isAuthTokenCiphertextFile(name string) bool {
return name == legacyName ||
(strings.HasPrefix(name, strings.TrimSuffix(legacyName, ".enc")+"_") && strings.HasSuffix(name, ".enc"))
}
func platformRemoveAuthTokenEntries(service string) error {
paths, err := authTokenCiphertextPaths(service)
if err != nil {
return err
}
var firstErr error
for _, path := range paths {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = fmt.Errorf("remove keychain entry %q: %w", filepath.Base(path), err)
}
}
return firstErr
}
@@ -20,3 +20,7 @@ package keychain
func platformValidateAuthTokenEntries(string) error {
return nil
}
func platformRemoveAuthTokenEntries(service string) error {
return registryRemoveAuthTokenEntries(service)
}
+7
View File
@@ -166,6 +166,13 @@ func ValidateAuthTokenEntries(service string) error {
return platformValidateAuthTokenEntries(service)
}
// RemoveAuthTokenEntries deletes the legacy token plus every organization and
// identity scoped auth-token entry for service. Other keychain accounts are
// preserved.
func RemoveAuthTokenEntries(service string) error {
return platformRemoveAuthTokenEntries(service)
}
// Exists checks if an entry exists in the keychain.
func Exists(service, account string) bool {
val, err := Get(service, account)
+30
View File
@@ -146,6 +146,36 @@ func TestKeychainOverwrite(t *testing.T) {
}
}
func TestRemoveAuthTokenEntriesPreservesOtherAccounts(t *testing.T) {
service := "test-service-" + t.Name()
authAccounts := []string{
AccountToken,
AccountToken + ":corp-a",
AccountToken + ":id:0123456789abcdef",
}
for _, account := range authAccounts {
if err := Set(service, account, "secret"); err != nil {
t.Fatalf("Set(%q) error = %v", account, err)
}
}
const unrelated = "app-secret:client-a"
if err := Set(service, unrelated, "preserve"); err != nil {
t.Fatalf("Set(unrelated) error = %v", err)
}
if err := RemoveAuthTokenEntries(service); err != nil {
t.Fatalf("RemoveAuthTokenEntries() error = %v", err)
}
for _, account := range authAccounts {
if Exists(service, account) {
t.Fatalf("auth account %q still exists", account)
}
}
if got, err := Get(service, unrelated); err != nil || got != "preserve" {
t.Fatalf("unrelated account = %q, %v; want preserved", got, err)
}
}
func TestMigrationNoLegacyData(t *testing.T) {
t.Parallel()
+55 -3
View File
@@ -17,6 +17,7 @@ package keychain
import (
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
@@ -28,6 +29,14 @@ import (
"golang.org/x/sys/windows/registry"
)
var registryDeleteValue = func(key registry.Key, name string) error {
return key.DeleteValue(name)
}
var registryOpenDeleteKey = func(path string, access uint32) (registry.Key, error) {
return registry.OpenKey(registry.CURRENT_USER, path, access)
}
// ---------------------------------------------------------------------------
// Windows backend: DPAPI + HKCU registry
// ---------------------------------------------------------------------------
@@ -196,11 +205,54 @@ func registrySet(service, account string, protected []byte) error {
func registryRemove(service, account string) error {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
k, err := registryOpenDeleteKey(keyPath, registry.SET_VALUE)
if err != nil {
return nil
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil
}
return fmt.Errorf("registry open for delete failed: %w", err)
}
defer k.Close()
_ = k.DeleteValue(valueNameForAccount(account))
return deleteRegistryValue(k, valueNameForAccount(account))
}
func deleteRegistryValue(key registry.Key, name string) error {
if err := registryDeleteValue(key, name); err != nil {
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil
}
return fmt.Errorf("registry delete failed: %w", err)
}
return nil
}
func registryRemoveAuthTokenEntries(service string) error {
keyPath := registryPathForService(service)
k, err := registryOpenDeleteKey(keyPath, registry.QUERY_VALUE|registry.SET_VALUE)
if err != nil {
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil
}
return fmt.Errorf("registry open for auth token cleanup failed: %w", err)
}
defer k.Close()
names, err := k.ReadValueNames(-1)
if err != nil {
return fmt.Errorf("registry list values failed: %w", err)
}
for _, name := range names {
accountBytes, decodeErr := base64.RawURLEncoding.DecodeString(name)
if decodeErr != nil {
continue
}
account := string(accountBytes)
if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") {
continue
}
if err := k.DeleteValue(name); err != nil {
return fmt.Errorf("registry delete auth token value failed: %w", err)
}
}
return nil
}
@@ -0,0 +1,48 @@
// 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
import (
"errors"
"testing"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
func TestDeleteRegistryValuePropagatesFailure(t *testing.T) {
originalDelete := registryDeleteValue
failure := errors.New("delete failed")
registryDeleteValue = func(registry.Key, string) error { return failure }
t.Cleanup(func() { registryDeleteValue = originalDelete })
if err := deleteRegistryValue(0, "auth-token"); !errors.Is(err, failure) {
t.Fatalf("deleteRegistryValue() error = %v, want %v", err, failure)
}
}
func TestRegistryRemoveAuthTokenEntriesPropagatesOpenFailure(t *testing.T) {
originalOpen := registryOpenDeleteKey
failure := windows.ERROR_ACCESS_DENIED
registryOpenDeleteKey = func(string, uint32) (registry.Key, error) {
return 0, failure
}
t.Cleanup(func() { registryOpenDeleteKey = originalOpen })
if err := registryRemoveAuthTokenEntries("service"); !errors.Is(err, failure) {
t.Fatalf("registryRemoveAuthTokenEntries() error = %v, want %v", err, failure)
}
}
+30
View File
@@ -0,0 +1,30 @@
// 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 logging
import (
"log/slog"
"os"
)
// AuthDebugEnv enables detailed authentication diagnostics when set to "1".
const AuthDebugEnv = "DWS_DEBUG_AUTH"
// AuthDebug writes detailed authentication diagnostics only when explicitly enabled.
func AuthDebug(message string, args ...any) {
if os.Getenv(AuthDebugEnv) != "1" {
return
}
slog.Debug(message, args...)
}
+65
View File
@@ -0,0 +1,65 @@
// 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 logging
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestAuthDebugDisabledByDefault(t *testing.T) {
t.Setenv(AuthDebugEnv, "")
var output bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previous) })
AuthDebug("auth.test", "user_id", "user")
if output.Len() != 0 {
t.Fatalf("AuthDebug() logged without %s=1: %s", AuthDebugEnv, output.String())
}
}
func TestAuthDebugEnabledOnlyByExplicitOne(t *testing.T) {
for _, value := range []string{"0", "true", "yes"} {
t.Run("disabled_"+value, func(t *testing.T) {
t.Setenv(AuthDebugEnv, value)
var output bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previous) })
AuthDebug("auth.test")
if output.Len() != 0 {
t.Fatalf("AuthDebug() logged with %s=%q: %s", AuthDebugEnv, value, output.String())
}
})
}
t.Run("enabled", func(t *testing.T) {
t.Setenv(AuthDebugEnv, "1")
var output bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previous) })
AuthDebug("auth.test", "user_id", "user")
got := output.String()
if !strings.Contains(got, `"msg":"auth.test"`) || !strings.Contains(got, `"user_id":"user"`) {
t.Fatalf("AuthDebug() output = %s", got)
}
})
}
+16 -1
View File
@@ -79,7 +79,8 @@ func (fl *FileLogger) Writer() io.Writer {
return fl.writer
}
// Close flushes and closes the underlying log file.
// Close flushes and permanently closes the underlying log file. Later writes
// fail with os.ErrClosed instead of reopening the file.
func (fl *FileLogger) Close() error {
if fl == nil || fl.writer == nil {
return nil
@@ -122,6 +123,7 @@ type rotatingWriter struct {
mu sync.Mutex
file *os.File
written int64
closed bool
}
func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter {
@@ -135,6 +137,9 @@ func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWrit
func (w *rotatingWriter) open() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return os.ErrClosed
}
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
@@ -153,6 +158,9 @@ func (w *rotatingWriter) open() error {
func (w *rotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, os.ErrClosed
}
// Attempt to recover if file was lost (e.g. rotation failed due to full disk).
if w.file == nil {
@@ -177,6 +185,9 @@ func (w *rotatingWriter) Write(p []byte) (int, error) {
// reopenLocked tries to reopen the log file. Caller must hold w.mu.
func (w *rotatingWriter) reopenLocked() error {
if w.closed {
return os.ErrClosed
}
f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
@@ -199,6 +210,10 @@ func (w *rotatingWriter) close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
if w.file == nil {
return nil
}
+37
View File
@@ -14,6 +14,7 @@
package logging
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -121,3 +122,39 @@ func TestCloseOnNilLogger(t *testing.T) {
t.Errorf("Close on nil should not error: %v", err)
}
}
func TestCrossPlatformCoverageFileLoggerCloseIsTerminal(t *testing.T) {
t.Parallel()
dir := t.TempDir()
fl := Setup(dir)
logPath := filepath.Join(dir, logSubDir, logFileName)
fl.Logger.Info("before close")
if err := fl.Close(); err != nil {
t.Fatalf("close logger: %v", err)
}
if err := os.Remove(logPath); err != nil {
t.Fatalf("remove closed log file: %v", err)
}
if err := fl.writer.open(); !errors.Is(err, os.ErrClosed) {
t.Fatalf("open after close = %v, want os.ErrClosed", err)
}
fl.writer.mu.Lock()
err := fl.writer.reopenLocked()
fl.writer.mu.Unlock()
if !errors.Is(err, os.ErrClosed) {
t.Fatalf("reopen after close = %v, want os.ErrClosed", err)
}
n, err := fl.Writer().Write([]byte("late write"))
if n != 0 || !errors.Is(err, os.ErrClosed) {
t.Fatalf("write after close = (%d, %v), want (0, os.ErrClosed)", n, err)
}
fl.Logger.Info("late structured write")
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
t.Fatalf("closed logger recreated log file: %v", err)
}
if err := fl.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
}
+15 -4
View File
@@ -2107,16 +2107,27 @@ var ImportUpload = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "file-name", Type: shortcut.FlagString, Desc: "文件名(含扩展名)", Required: true},
{Name: "file-size", Type: shortcut.FlagInt, Desc: "文件大小(字节,可选)"},
{Name: "file-size", Type: shortcut.FlagInt, Desc: "文件大小(字节,必须大于 0)", Required: true},
},
Constraints: []shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"file-size"},
Description: "--file-size 必须是大于 0 的整数(实际文件大小,单位字节)",
},
},
Tips: []string{`dws aitable +import-upload --base-id B --file-name data.xlsx --file-size 204800`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("file-size") <= 0 {
return fmt.Errorf("flag --file-size is required and must be a positive integer")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
"fileName": rt.Str("file-name"),
}
if rt.Changed("file-size") {
params["fileSize"] = rt.Int("file-size")
"fileSize": rt.Int("file-size"),
}
return rt.CallMCP("prepare_import_upload", params)
},
@@ -0,0 +1,117 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package aitable
import (
"context"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type platformCoverageCaller struct {
called bool
product string
tool string
args map[string]any
}
func (f *platformCoverageCaller) reset() {
f.called, f.product, f.tool, f.args = false, "", "", nil
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.called, f.product, f.tool, f.args = true, product, tool, args
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: `{"result":[]}`}},
}, nil
}
func (f *platformCoverageCaller) Format() string { return "json" }
func (f *platformCoverageCaller) DryRun() bool { return false }
func (f *platformCoverageCaller) Fields() string { return "" }
func (f *platformCoverageCaller) JQ() string { return "" }
func newPlatformCoverageRoot() *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
return root
}
func TestCrossPlatformCoverageImportUploadRequiresPositiveFileSize(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
tests := []struct {
name string
fileSize string
wantErr bool
}{
{name: "missing", wantErr: true},
{name: "zero", fileSize: "0", wantErr: true},
{name: "negative", fileSize: "-1", wantErr: true},
{name: "positive", fileSize: "204800"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fake.reset()
root := newPlatformCoverageRoot()
args := []string{
"aitable", "+import-upload",
"--base-id", "base-smoke",
"--file-name", "data.xlsx",
"--yes",
}
if test.fileSize != "" {
args = append(args, "--file-size", test.fileSize)
}
root.SetArgs(args)
err := root.Execute()
if test.wantErr {
if err == nil {
t.Fatal("expected --file-size validation error")
}
if !strings.Contains(err.Error(), "--file-size") {
t.Fatalf("error = %q, want --file-size validation", err)
}
if fake.called {
t.Fatalf("invalid file size called %s/%s with %#v", fake.product, fake.tool, fake.args)
}
return
}
if err != nil {
t.Fatalf("positive file size returned error: %v", err)
}
if !fake.called || fake.product != "aitable" || fake.tool != "prepare_import_upload" {
t.Fatalf("tool call = called:%v %s/%s, want aitable/prepare_import_upload", fake.called, fake.product, fake.tool)
}
if got := fake.args["fileSize"]; got != 204800 {
t.Fatalf("fileSize = %#v, want 204800", got)
}
})
}
}
+6 -2
View File
@@ -120,13 +120,17 @@ var BotFind = shortcut.Shortcut{
Intent: "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
},
Tips: []string{`dws chat +bot-find --query "日报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"keyword": rt.Str("query")}
params := map[string]any{"keyword": rt.StrFirst("query", "keyword")}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
}
+12 -3
View File
@@ -28,16 +28,25 @@ var ChatSearch = shortcut.Shortcut{
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词", Required: true},
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
},
Tips: []string{`dws chat +chat-search --query "项目冲刺"`},
Execute: func(rt *shortcut.RuntimeContext) error {
query := rt.Str("query")
if query == "" {
query = rt.Str("keyword")
}
params := map[string]any{
"keyword": rt.Str("query"),
"limit": rt.Int("limit"),
"keyword": query,
"limit": rt.IntFirst("limit", "size"),
"cursor": rt.Str("cursor"),
}
if rt.Bool("exclude-muted") {
+22 -9
View File
@@ -206,21 +206,28 @@ var MessagesList = shortcut.Shortcut{
Intent: "当你想按时间拉取某个群的历史聊天记录(做回顾或分析)时使用;只读,需传群 openConversationId 和起始时间,--forward 控制往新还是往旧方向翻。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id"}},
},
Tips: []string{`dws chat +messages-list --group <openConversationId> --time "2025-03-01 00:00:00"`},
Execute: func(rt *shortcut.RuntimeContext) error {
group := rt.StrFirst("group", "conversation-id", "id")
params := map[string]any{
"openCid": rt.Str("group"),
"cid": rt.Str("group"),
"openCid": group,
"cid": group,
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
data, err := rt.CallMCPData("chat", "list_conversation_message_v2", params)
if err != nil {
@@ -312,6 +319,7 @@ var MessagesListDirect = shortcut.Shortcut{
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
},
Tips: []string{`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -327,8 +335,8 @@ var MessagesListDirect = shortcut.Shortcut{
default:
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
if err != nil {
@@ -480,14 +488,19 @@ var MessagesReadStatus = shortcut.Shortcut{
Intent: "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的别名", Hidden: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(当前用户发送的消息)", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标 userId 或 openDingTalkId 列表(不传返回全部接收者)"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "id"}},
},
Tips: []string{`dws chat +messages-read-status --conversation-id <openConversationId> --message-id <openMessageId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"openConversationId": rt.Str("conversation-id"),
"openConversationId": rt.StrFirst("conversation-id", "group", "id"),
"openMessageId": rt.Str("message-id"),
}
if v := rt.StrSlice("users"); len(v) > 0 {
@@ -0,0 +1,124 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"context"
"io"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type platformCoverageCaller struct {
product string
tool string
args map[string]any
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.product, f.tool, f.args = product, tool, args
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"result":[]}`}}}, nil
}
func (f *platformCoverageCaller) Format() string { return "json" }
func (f *platformCoverageCaller) DryRun() bool { return false }
func (f *platformCoverageCaller) Fields() string { return "" }
func (f *platformCoverageCaller) JQ() string { return "" }
func newPlatformCoverageRoot() *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
return root
}
func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
tests := []struct {
name string
argv []string
wantProduct string
wantTool string
wantArgs map[string]any
}{
{
name: "chat search keyword and size",
argv: []string{"chat", "+chat-search", "--keyword", "树莓派", "--size", "5", "--yes"},
wantProduct: "im",
wantTool: "search_groups",
wantArgs: map[string]any{"keyword": "树莓派", "limit": 5},
},
{
name: "bot find keyword",
argv: []string{"chat", "+bot-find", "--keyword", "日报", "--yes"},
wantProduct: "bot",
wantTool: "search_bots",
wantArgs: map[string]any{"keyword": "日报"},
},
{
name: "conversation messages conversation id and size",
argv: []string{
"chat", "+messages-list", "--conversation-id", "cid-1",
"--time", "2026-07-17 10:00:00", "--size", "7", "--yes",
},
wantProduct: "chat",
wantTool: "list_conversation_message_v2",
wantArgs: map[string]any{"openCid": "cid-1", "limit": 7},
},
{
name: "direct messages size",
argv: []string{
"chat", "+messages-list-direct", "--user", "u1",
"--time", "2026-07-17 10:00:00", "--size", "8", "--yes",
},
wantProduct: "chat",
wantTool: "list_individual_chat_message",
wantArgs: map[string]any{"userId": "u1", "limit": 8},
},
{
name: "read status id",
argv: []string{"chat", "+messages-read-status", "--id", "cid-1", "--message-id", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "query_msg_read_status",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(tc.argv)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if fake.product != tc.wantProduct || fake.tool != tc.wantTool {
t.Fatalf("call = %s/%s, want %s/%s", fake.product, fake.tool, tc.wantProduct, tc.wantTool)
}
for key, want := range tc.wantArgs {
if got := fake.args[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
})
}
}
+45
View File
@@ -23,6 +23,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -35,6 +36,13 @@ type RuntimeContext struct {
shortcut Shortcut
}
// AIMessageTagFlag matches `chat message send --ai-tag`: IM send shortcuts
// default to tagging delivered messages as AI-sent, while still allowing users
// to opt out with --ai-tag=false.
func AIMessageTagFlag() Flag {
return Flag{Name: "ai-tag", Type: FlagBool, Default: "true", Desc: "消息是否带 AI 发送角标(默认 true)"}
}
// Command returns the underlying cobra command (escape hatch; prefer the typed
// accessors below).
func (rt *RuntimeContext) Command() *cobra.Command { return rt.cmd }
@@ -45,6 +53,17 @@ func (rt *RuntimeContext) Str(name string) string {
return strings.TrimSpace(v)
}
// StrFirst returns the first non-empty string value across a primary flag and
// its aliases.
func (rt *RuntimeContext) StrFirst(names ...string) string {
for _, name := range names {
if v := rt.Str(name); v != "" {
return v
}
}
return ""
}
// Bool returns the bool value of a flag.
func (rt *RuntimeContext) Bool(name string) bool {
v, _ := rt.cmd.Flags().GetBool(name)
@@ -57,6 +76,19 @@ func (rt *RuntimeContext) Int(name string) int {
return v
}
// IntFirst returns the int value for a primary flag plus aliases. Explicitly set
// aliases are considered before the primary's default, matching native helper
// compatibility flags such as --size for --limit.
func (rt *RuntimeContext) IntFirst(primary string, aliases ...string) int {
for _, alias := range aliases {
f := rt.cmd.Flags().Lookup(alias)
if f != nil && f.Changed {
return rt.Int(alias)
}
}
return rt.Int(primary)
}
// StrSlice returns the string-slice value of a flag.
func (rt *RuntimeContext) StrSlice(name string) []string {
v, _ := rt.cmd.Flags().GetStringSlice(name)
@@ -75,6 +107,19 @@ func (rt *RuntimeContext) DryRun() bool { return globalBool(rt.cmd, "dry-run") }
// Yes reports whether --yes is set (skip confirmation prompts).
func (rt *RuntimeContext) Yes() bool { return globalBool(rt.cmd, "yes") }
// AddAIMessageTag attaches the clawType parameter expected by IM send APIs when
// the shortcut exposes --ai-tag and the flag is true. This mirrors the native
// `chat message send` behavior.
func (rt *RuntimeContext) AddAIMessageTag(params map[string]any) map[string]any {
if params == nil {
params = map[string]any{}
}
if f := rt.cmd.Flags().Lookup("ai-tag"); f == nil || rt.Bool("ai-tag") {
params["clawType"] = edition.ClawType()
}
return params
}
// CallMCP dispatches a single MCP tool call and prints the result, reusing the
// shared helper path so the shortcut inherits DWS's error classification
// (auth/PAT/business), dry-run preview and --format/--jq/--fields output for
+50
View File
@@ -18,6 +18,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -65,6 +66,55 @@ func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
}
}
func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
aiTag := AIMessageTagFlag()
if aiTag.Name != "ai-tag" || aiTag.Type != FlagBool || aiTag.Default != "true" {
t.Fatalf("AIMessageTagFlag() = %#v", aiTag)
}
s := Shortcut{
Service: "chat",
Command: "+compat",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "size", Type: FlagInt},
aiTag,
},
}
cmd := mount(s)
rt := &RuntimeContext{cmd: cmd, shortcut: s}
if err := cmd.Flags().Set("keyword", "树莓派"); err != nil {
t.Fatal(err)
}
if got := rt.StrFirst("query", "keyword"); got != "树莓派" {
t.Fatalf("StrFirst() = %q, want 树莓派", got)
}
if got := rt.IntFirst("limit", "size"); got != 20 {
t.Fatalf("IntFirst() default = %d, want 20", got)
}
if err := cmd.Flags().Set("size", "7"); err != nil {
t.Fatal(err)
}
if got := rt.IntFirst("limit", "size"); got != 7 {
t.Fatalf("IntFirst() alias = %d, want 7", got)
}
params := rt.AddAIMessageTag(nil)
if got := params["clawType"]; got != edition.ClawType() {
t.Fatalf("clawType = %#v, want %q", got, edition.ClawType())
}
if err := cmd.Flags().Set("ai-tag", "false"); err != nil {
t.Fatal(err)
}
params = rt.AddAIMessageTag(map[string]any{"content": "hello"})
if _, ok := params["clawType"]; ok {
t.Fatalf("clawType unexpectedly present with --ai-tag=false: %#v", params)
}
}
func TestCrossPlatformCoverageValidateFlagsRequiredAndEnum(t *testing.T) {
s := Shortcut{
Service: "contact",
+3 -2
View File
@@ -43,6 +43,7 @@ var Broadcast = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件人姓名/花名,逗号分隔的多个人", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -83,11 +84,11 @@ var Broadcast = shortcut.Shortcut{
sent = append(sent, user.name)
continue
}
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", map[string]any{
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", rt.AddAIMessageTag(map[string]any{
"receiverOpenDingTalkId": user.openDingTalkID,
"msgType": "markdown",
"content": string(content),
}); err != nil {
})); err != nil {
failed = append(failed, fmt.Sprintf("%s(发送失败:%s)", name, err.Error()))
continue
}
+9 -6
View File
@@ -52,13 +52,16 @@ var ChatMessages = shortcut.Shortcut{
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "user"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user"}},
},
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
@@ -75,8 +78,8 @@ var ChatMessages = shortcut.Shortcut{
if rt.Changed("time") && rt.Str("time") != "" {
params["time"] = rt.Str("time")
}
if rt.Changed("limit") && rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
// direction newer/older maps to the tools' boolean `forward` param
// (newer -> forward=true, older -> forward=false), matching chat.go's
@@ -90,10 +93,10 @@ var ChatMessages = shortcut.Shortcut{
}
}
if rt.Str("group") != "" {
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
tool = "list_conversation_message_v2"
params["openCid"] = rt.Str("group")
params["cid"] = rt.Str("group")
params["openCid"] = group
params["cid"] = group
} else {
tool = "list_individual_chat_message"
params["userId"] = rt.Str("user")
@@ -0,0 +1,154 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"context"
"io"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type platformCoverageCall struct {
product string
tool string
args map[string]any
}
type platformCoverageCaller struct {
calls []platformCoverageCall
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
text := `{"result":[]}`
switch product + "/" + tool {
case "contact/search_contact_by_key_word":
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
case "im/search_groups":
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (f *platformCoverageCaller) Format() string { return "json" }
func (f *platformCoverageCaller) DryRun() bool { return false }
func (f *platformCoverageCaller) Fields() string { return "" }
func (f *platformCoverageCaller) JQ() string { return "" }
func newPlatformCoverageRoot() *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
return root
}
func TestCrossPlatformCoverageAIMessageTag(t *testing.T) {
tests := []struct {
name string
argv []string
}{
{name: "dm", argv: []string{"chat", "+dm", "--to", "张三", "--text", "你好", "--yes"}},
{name: "send to group", argv: []string{"chat", "+send-to-group", "--group", "项目冲刺", "--text", "你好", "--yes"}},
{name: "broadcast", argv: []string{"chat", "+broadcast", "--to", "张三", "--text", "你好", "--yes"}},
{name: "share doc", argv: []string{"doc", "+share-doc", "--to", "张三", "--url", "https://example.com/doc", "--yes"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(tc.argv)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) == 0 {
t.Fatal("shortcut made no MCP calls")
}
send := fake.calls[len(fake.calls)-1]
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("last call = %s/%s, want chat/send_personal_message", send.product, send.tool)
}
if got := send.args["clawType"]; got != edition.ClawType() {
t.Fatalf("clawType = %#v, want %q", got, edition.ClawType())
}
})
}
t.Run("opt out", func(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+dm", "--to", "张三", "--text", "你好", "--ai-tag=false", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
send := fake.calls[len(fake.calls)-1]
if _, ok := send.args["clawType"]; ok {
t.Fatalf("clawType unexpectedly present with --ai-tag=false: %#v", send.args)
}
})
}
func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
tests := []struct {
name string
argv []string
wantTool string
wantArgs map[string]any
}{
{
name: "chat messages id and size",
argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"},
wantTool: "list_conversation_message_v2",
wantArgs: map[string]any{"openCid": "cid-1", "limit": 9},
},
{
name: "search message id and keyword",
argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--yes"},
wantTool: "search_messages_by_keyword",
wantArgs: map[string]any{"openConversationId": "cid-1", "keyword": "树莓派"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(tc.argv)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
call := fake.calls[len(fake.calls)-1]
if call.product != "chat" || call.tool != tc.wantTool {
t.Fatalf("call = %s/%s, want chat/%s", call.product, call.tool, tc.wantTool)
}
for key, want := range tc.wantArgs {
if got := call.args[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
})
}
}
+3 -2
View File
@@ -42,6 +42,7 @@ var DM = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -58,11 +59,11 @@ var DM = shortcut.Shortcut{
// Step 2 — send the single-chat message.
content, _ := json.Marshal(map[string]string{"title": text, "text": text})
return rt.CallMCP("send_personal_message", map[string]any{
return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{
"receiverOpenDingTalkId": user.openDingTalkID,
"msgType": "markdown",
"content": string(content),
})
}))
},
}
+11 -4
View File
@@ -55,17 +55,24 @@ var SearchMsg = shortcut.Shortcut{
"这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话的 openConversationId(必填)", Required: true},
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(必填)", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群会话的 openConversationId(必填)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(必填)"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
},
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --group <openConversationId> --query "周报" --days 3`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
group := strings.TrimSpace(rt.Str("group"))
query := strings.TrimSpace(rt.Str("query"))
group := rt.StrFirst("group", "conversation-id", "id")
query := rt.StrFirst("query", "keyword")
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
+3 -2
View File
@@ -45,6 +45,7 @@ var SendToGroup = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -73,11 +74,11 @@ var SendToGroup = shortcut.Shortcut{
// Step 2 — send the markdown message to the unique group.
content, _ := json.Marshal(map[string]string{"title": text, "text": text})
return rt.CallMCP("send_personal_message", map[string]any{
return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{
"openConversationId": groups[0].id,
"msgType": "markdown",
"content": string(content),
})
}))
},
}
+3 -2
View File
@@ -43,6 +43,7 @@ var ShareDoc = shortcut.Shortcut{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true},
{Name: "note", Type: shortcut.FlagString, Desc: "附言(可选)"},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws doc +share-doc --to 张三 --url https://docs.dingtalk.com/xxx --note "帮忙过一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -64,11 +65,11 @@ var ShareDoc = shortcut.Shortcut{
content, _ := json.Marshal(map[string]string{"title": title, "text": text})
// Step 3 — deliver it as a single-chat message.
return rt.CallMCP("send_personal_message", map[string]any{
return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{
"receiverOpenDingTalkId": user.openDingTalkID,
"msgType": "markdown",
"content": string(content),
})
}))
},
}
+228 -23
View File
@@ -14,6 +14,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_GO_TESTS=1
VERBOSE=0
KEEP_WORKDIR=0
E2E_VERSION="${DWS_PACKAGE_VERSION:-v1.0.53-beta.3}"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -103,6 +104,16 @@ expect_contains() {
fi
}
expect_not_contains() {
local file="$1"
local needle="$2"
if grep -F -- "$needle" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "did not expect $file to contain: $needle"
fi
}
expect_not_contains_line_command() {
local file="$1"
local command="$2"
@@ -159,13 +170,17 @@ type profileUseResponse struct {
}
type profileView struct {
Profile string `json:"profile"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId"`
UserName string `json:"userName"`
Status string `json:"status"`
ExpiresAt string `json:"expiresAt"`
RefreshExpAt string `json:"refreshExpAt"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
IsOrgCurrent bool `json:"isOrgCurrent"`
}
type authStatusResponse struct {
@@ -194,6 +209,7 @@ type multiProfileSummary struct {
type multiProfileResult struct {
Selector string `json:"selector"`
Profile string `json:"profile"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
OK bool `json:"ok"`
@@ -213,6 +229,15 @@ func main() {
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenData(configDir, data))
case "seed-orphan":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenDataKeychainForIdentity(data.CorpID, data.UserID, data))
case "seed-expired":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
data.ExpiresAt = time.Now().Add(-time.Hour)
must(auth.SaveTokenData(configDir, data))
case "seed-legacy":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
@@ -248,11 +273,19 @@ func main() {
if len(cfg.Profiles) != wantCount {
die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles)
}
if wantCount > 0 && cfg.Version != 2 {
die("profiles version=%d, want 2", cfg.Version)
}
assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3]))
assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4]))
assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5]))
assertNoSecrets(configDir)
assertProfileMetadata(cfg)
case "assert-org-current":
needArgs(4)
cfg, err := auth.LoadProfiles(configDir)
must(err)
assertEqual("orgCurrentProfiles["+os.Args[2]+"]", cfg.OrgCurrentProfiles[os.Args[2]], emptySentinel(os.Args[3]))
case "assert-list-json":
needArgs(7)
var resp profileListResponse
@@ -270,16 +303,66 @@ func main() {
assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4]))
assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5]))
assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6]))
primaryCount := 0
currentCount := 0
for _, p := range resp.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile list item missing corp identity: %#v", p)
}
if p.CorpID == resp.PrimaryProfile && !p.IsPrimary {
die("profile %s should be primary", p.CorpID)
if strings.TrimSpace(p.UserID) != "" && p.Profile != p.CorpID+":"+p.UserID {
die("profile selector=%q, want %s:%s", p.Profile, p.CorpID, p.UserID)
}
if p.CorpID == resp.CurrentProfile && !p.IsCurrent {
die("profile %s should be current", p.CorpID)
if p.IsPrimary {
primaryCount++
}
if p.IsCurrent {
currentCount++
}
}
if resp.PrimaryProfile != "" && primaryCount != 1 {
die("primary marker count=%d, want 1", primaryCount)
}
if resp.CurrentProfile != "" && currentCount != 1 {
die("current marker count=%d, want 1", currentCount)
}
orgCurrentCounts := map[string]int{}
for _, p := range resp.Profiles {
if p.IsOrgCurrent {
orgCurrentCounts[p.CorpID]++
}
}
for corpID, count := range orgCurrentCounts {
if count != 1 {
die("organization %s has %d org-current accounts, want 1", corpID, count)
}
}
case "assert-list-account":
needArgs(7)
var resp profileListResponse
readJSON(os.Args[2], &resp)
for _, p := range resp.Profiles {
if p.Profile != os.Args[3] {
continue
}
assertEqual("list account status", p.Status, os.Args[4])
assertEqual("list account isCurrent", fmt.Sprint(p.IsCurrent), os.Args[5])
assertEqual("list account isOrgCurrent", fmt.Sprint(p.IsOrgCurrent), os.Args[6])
if strings.TrimSpace(p.ExpiresAt) == "" || strings.TrimSpace(p.RefreshExpAt) == "" {
die("list account missing live token expiry: %#v", p)
}
return
}
die("list account %q not found", os.Args[3])
case "assert-list-order":
needArgs(4)
var resp profileListResponse
readJSON(os.Args[2], &resp)
want := strings.Split(os.Args[3], ",")
if len(resp.Profiles) != len(want) {
die("list order profile count=%d, want %d", len(resp.Profiles), len(want))
}
for i := range want {
assertEqual(fmt.Sprintf("list order[%d]", i), resp.Profiles[i].Profile, strings.TrimSpace(want[i]))
}
case "assert-switch-json":
needArgs(5)
@@ -338,6 +421,14 @@ func main() {
must(err)
assertEqual("token corpId", data.CorpID, os.Args[3])
assertEqual("token access", data.AccessToken, os.Args[4])
case "assert-token-missing":
needArgs(3)
if data, err := loadToken(configDir, os.Args[2]); err == nil {
die("token %q still exists: %#v", os.Args[2], data)
}
case "delete":
needArgs(3)
must(auth.DeleteTokenDataForProfile(configDir, os.Args[2]))
case "assert-empty-auth":
needArgs(2)
cfg, err := auth.LoadProfiles(configDir)
@@ -414,6 +505,18 @@ func assertProfileMetadata(cfg *auth.ProfilesConfig) {
if prev, ok := names[p.Name]; ok {
die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID)
}
if p.ExpiresAt != "" || p.RefreshExpAt != "" {
die("profile must not persist derived token expiry: %#v", p)
}
for field, value := range map[string]string{
"lastLoginAt": p.LastLoginAt,
"lastUsedAt": p.LastUsedAt,
"updatedAt": p.UpdatedAt,
} {
if _, err := time.Parse(time.RFC3339, value); err != nil {
die("profile %s %s=%q, want RFC3339 timestamp: %v", p.CorpID, field, value, err)
}
}
names[p.Name] = p.CorpID
}
}
@@ -483,13 +586,15 @@ if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
fi
log "building dws"
run go build -o "$BIN" ./cmd
run go build -ldflags="-X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.version=$E2E_VERSION" -o "$BIN" ./cmd
helper() {
go run "$HELPER_DIR" "$@"
}
log "checking command surface"
capture "$OUT_DIR/version.txt" "$BIN" version
expect_contains "$OUT_DIR/version.txt" "$E2E_VERSION"
capture "$OUT_DIR/root-help.txt" "$BIN" --help
expect_contains "$OUT_DIR/root-help.txt" "--profile"
expect_contains "$OUT_DIR/root-help.txt" "--yes"
@@ -527,6 +632,19 @@ expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes"
capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help
expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch"
log "checking embedded mono and multi skills"
MONO_HOME="$WORKDIR/home-mono"
MULTI_HOME="$WORKDIR/home-multi"
mkdir -p "$MONO_HOME" "$MULTI_HOME"
capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
expect_contains "$MULTI_HOME/.codex/skills/dws-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "corpId:userId"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "isOrgCurrent"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
@@ -534,8 +652,10 @@ helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
log "seeding first organization profile"
helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1
capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 corp_alpha corp_alpha _
helper assert-profiles 1 corp_alpha corp_alpha _
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 _ corp_alpha:user_alpha _
helper assert-list-account "$OUT_DIR/list-alpha.json" corp_alpha:user_alpha active true true
helper assert-profiles 1 _ corp_alpha:user_alpha _
helper assert-org-current corp_alpha corp_alpha:user_alpha
helper assert-token default corp_alpha access-alpha-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json
@@ -544,8 +664,10 @@ helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha
log "seeding second organization profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1
capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha
helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha
helper assert-org-current corp_alpha corp_alpha:user_alpha
helper assert-org-current corp_beta corp_beta:user_beta
helper assert-token default corp_beta access-beta-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
helper assert-token corp_beta corp_beta access-beta-v1
@@ -553,33 +675,46 @@ helper assert-token corp_beta corp_beta access-beta-v1
log "refreshing existing organization without duplicating profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2
capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha
helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha
helper assert-token corp_beta corp_beta access-beta-v2
log "seeding duplicate organization name and checking stable fallback"
helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1
capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 corp_alpha corp_gamma corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_beta
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 _ corp_gamma:user_gamma corp_beta:user_beta
helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta
helper assert-org-current corp_gamma corp_gamma:user_gamma
helper assert-duplicate-name-fallback corp_gamma "Beta Org"
expect_fail "corp_beta" "$BIN" auth status --profile "Beta Org" --format json
log "checking all friendly account selector combinations"
capture "$OUT_DIR/status-alpha-id-id.json" "$BIN" auth status --profile "corp_alpha:user_alpha" --format json
helper assert-status-json "$OUT_DIR/status-alpha-id-id.json" corp_alpha "Alpha Org" user_alpha
capture "$OUT_DIR/status-alpha-id-name.json" "$BIN" auth status --profile "corp_alpha:Alice Alpha" --format json
helper assert-status-json "$OUT_DIR/status-alpha-id-name.json" corp_alpha "Alpha Org" user_alpha
capture "$OUT_DIR/status-alpha-name-id.json" "$BIN" auth status --profile "Alpha Org:user_alpha" --format json
helper assert-status-json "$OUT_DIR/status-alpha-name-id.json" corp_alpha "Alpha Org" user_alpha
capture "$OUT_DIR/status-alpha-name-name.json" "$BIN" auth status --profile "Alpha Org:Alice Alpha" --format json
helper assert-status-json "$OUT_DIR/status-alpha-name-name.json" corp_alpha "Alpha Org" user_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta
log "switching profiles and verifying legacy mirror"
capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json
helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_gamma
helper assert-profiles 3 _ corp_alpha:user_alpha corp_gamma:user_gamma
helper assert-token default corp_alpha access-alpha-v1
capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table
expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org"
expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta"
helper assert-profiles 3 corp_alpha corp_beta corp_alpha
helper assert-profiles 3 _ corp_beta:user_beta corp_alpha:user_alpha
helper assert-token default corp_beta access-beta-v2
capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json
helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_beta
helper assert-profiles 3 _ corp_alpha:user_alpha corp_beta:user_beta
capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json
helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org"
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
log "checking profile switch validation"
expect_fail "profile selector required" "$BIN" profile switch
@@ -589,33 +724,103 @@ expect_fail "missing_org" "$BIN" profile switch missing_org
log "checking one-shot profile override without changing current profile"
capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json
helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json
helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma
capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
log "checking multiple accounts in the same organization"
helper seed corp_gamma "Beta Org" user_gamma_alt "Grace Gamma" access-gamma-alt-v1
capture "$OUT_DIR/list-same-corp-accounts.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-same-corp-accounts.json" 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma
helper assert-profiles 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma
helper assert-org-current corp_gamma corp_gamma:user_gamma_alt
helper assert-list-order "$OUT_DIR/list-same-corp-accounts.json" "corp_alpha:user_alpha,corp_beta:user_beta,corp_gamma:user_gamma,corp_gamma:user_gamma_alt"
expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma"'
expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma_alt"'
helper assert-token corp_gamma:user_gamma corp_gamma access-gamma-v1
helper assert-token corp_gamma:user_gamma_alt corp_gamma access-gamma-alt-v1
helper assert-token corp_gamma corp_gamma access-gamma-alt-v1
capture "$OUT_DIR/switch-gamma-exact.json" "$BIN" profile switch corp_gamma:user_gamma --format json
helper assert-switch-json "$OUT_DIR/switch-gamma-exact.json" corp_gamma "Beta Org"
helper assert-profiles 4 _ corp_gamma:user_gamma corp_gamma:user_gamma_alt
helper assert-org-current corp_gamma corp_gamma:user_gamma
helper assert-token default corp_gamma access-gamma-v1
helper assert-token corp_gamma corp_gamma access-gamma-v1
capture "$OUT_DIR/contact-same-corp-multi.json" "$BIN" --mock --profile corp_gamma,corp_gamma:user_gamma,corp_gamma:user_gamma_alt contact user get-self --format json
helper assert-multi-profile-json "$OUT_DIR/contact-same-corp-multi.json" 2 corp_gamma,corp_gamma
expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma"'
expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma_alt"'
log "checking duplicate user names and missing organization default"
helper seed corp_gamma "Beta Org" user_gamma_dup "Grace Gamma" access-gamma-dup-v1
expect_fail "corp_gamma:user_gamma_alt" "$BIN" auth status --profile "corp_gamma:Grace Gamma" --format json
helper delete corp_gamma:user_gamma_dup
helper assert-profiles 4 _ corp_gamma:user_gamma _
helper assert-org-current corp_gamma _
expect_fail "corp_gamma:user_gamma" "$BIN" auth status --profile corp_gamma --format json
capture "$OUT_DIR/status-gamma-exact-after-clear.json" "$BIN" auth status --profile corp_gamma:user_gamma --format json
helper assert-status-json "$OUT_DIR/status-gamma-exact-after-clear.json" corp_gamma "Beta Org" user_gamma
log "checking profile list uses identity token truth without refresh"
helper seed-expired corp_expired "Expired Org" user_expired "Eve Expired" access-expired-v1
capture "$OUT_DIR/list-expired.json" "$BIN" profile list --format json
helper assert-list-account "$OUT_DIR/list-expired.json" corp_expired:user_expired expired true true
capture "$OUT_DIR/list-table.txt" "$BIN" profile list --format table
expect_not_contains "$OUT_DIR/list-table.txt" "PRI"
log "checking exact-account, organization, and all-account logout"
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma:user_gamma_alt
helper assert-token-missing corp_gamma:user_gamma_alt
helper assert-profiles 4 _ corp_expired:user_expired corp_gamma:user_gamma
helper assert-org-current corp_gamma _
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma
helper assert-token-missing corp_gamma:user_gamma
helper assert-token-missing corp_gamma
helper assert-profiles 3 _ corp_expired:user_expired _
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile "corp_expired:Eve Expired"
helper assert-token-missing corp_expired:user_expired
helper assert-token-missing default
helper assert-profiles 2 _ _ _
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_beta
helper assert-token-missing corp_beta:user_beta
helper assert-profiles 1 _ corp_alpha:user_alpha _
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout
helper assert-empty-auth
log "checking auth reset cleanup"
helper seed corp_reset "Reset Org" user_reset "Rita Reset" access-reset-v1
helper seed-orphan corp_orphan "Orphan Org" user_orphan "Oscar Orphan" access-orphan-v1
helper write-app-config client-reset secret-reset
helper assert-app-config exists
capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset
expect_contains "$OUT_DIR/auth-reset.txt" "[OK]"
helper assert-empty-auth
helper assert-token-missing corp_reset:user_reset
helper assert-token-missing corp_orphan:user_orphan
helper assert-app-config absent
log "checking legacy single-slot migration"
helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1
helper assert-profiles 0 _ _ _
capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 corp_legacy corp_legacy _
helper assert-profiles 1 corp_legacy corp_legacy _
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 _ corp_legacy:user_legacy _
helper assert-profiles 1 _ corp_legacy:user_legacy _
helper assert-org-current corp_legacy corp_legacy:user_legacy
helper assert-token default corp_legacy access-legacy-v1
helper assert-token corp_legacy corp_legacy access-legacy-v1
+8
View File
@@ -61,6 +61,14 @@ cli_version: ">=1.0.15"
| `wiki` | 1 | `dingtalk-wiki` | `dws shortcut list --service wiki --format json` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
## 多组织 / 多账号
- `dws profile list --format json` 默认返回全部账号。自动化只使用每项稳定的 `profile=corpId:userId`;`status/expiresAt/refreshExpAt` 来自真实身份 Token,列表不触发刷新。
- 输入支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`,也兼容单独的 corpId、唯一 corpName 和本地 profile 名。名称只用于输入;重名时必须按报错候选改用 `corpId:userId`。
- 只传组织时使用该组织明确记录的 `isOrgCurrent=true` 账号。多账号组织没有默认账号时必须让用户指定账号;禁止选择第一项、最近登录或最近使用账号。
- 不传 `--profile` 使用全局 `isCurrent=true` 账号。`primaryProfile/isPrimary` 仅兼容输出,不参与选择;`previousProfile` 只用于 `profile switch -`。
- 跨组织读 / 搜:按 `corpId` 去重;每个组织使用唯一 `isOrgCurrent=true` 的 `profile`。组织存在多个账号且没有默认账号时先询问用户。写 / 发 / 删 / 撤回及持久切换前先确认目标组织和账号。
## 产品总览
| 产品 | 用途 | 参考文件 |
@@ -22,6 +22,19 @@ dws auth reset
登录后自动管理 token 刷新,日常使用无需重复登录。
### 多账号 profile
```bash
dws profile list --format json
dws profile switch <corpId:userId>
dws --profile <corpId:userId> contact user get-self --format json
```
- 同一组织可保存多个账号,身份由 `corpId + userId` 唯一确定。
- 选择器支持 ID 或唯一名称组合;自动化使用 `profile list` 返回的 `profile=corpId:userId`。
- 多账号组织没有 `isOrgCurrent=true` 时,只传 corpId/corpName 会报错,必须指定账号。
- `profile list` 读取真实身份 Token 状态但不刷新。`auth status` 会尝试刷新;刷新失败时返回未认证和原始失败原因。
| Token | 有效期 | 说明 |
|-------|--------|------|
| Access Token | 2 小时 | 调用 API 的凭证,过期自动刷新 |
@@ -79,6 +92,7 @@ dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff -
| `--mock` | | Mock 数据 (开发用) | false |
| `--client-id` | | 覆盖 OAuth Client ID | 无 |
| `--client-secret` | | 覆盖 OAuth Client Secret | 无 |
| `--profile` | | 单次指定组织或账号;支持 corpId/corpName 与 userId/userName 组合,推荐稳定的 corpId:userId | 当前账号 |
## 输出格式
+16 -12
View File
@@ -18,31 +18,35 @@ metadata:
<!-- SAFETY_PREAMBLE_INJECT -->
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入)。
dws 可同时登录多个钉钉账号,同一组织也可保留多个账号。一个 profile = 一个 `corpId + userId` 身份;当前 profile 决定本次命令注入哪个身份。
## 触发条件(命中任一即用本 skill)
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 去重后显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
- 用户问认证状态 / 登录了哪些组织或账号 / 当前账号是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`。
## 命令
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
- `dws profile list --format json` — 默认列出全部账号;`profile` 是稳定选择器 `corpId:userId`,Token 状态现场读取且不刷新
- `dws profile switch <selector|->` — 持久切换账号;`-` 切回上一个
- 全局 `--profile <selector>` — 单次指定身份,不改当前 profile
- `dws auth login` — 新账号新增 profile;同一 `corpId + userId` 重登只刷新该账号
- `dws auth status [--profile <selector>]` — 查看并按需刷新指定身份;刷新失败返回未认证和真实原因
`selector` 支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`,也兼容单独的 corpId、唯一 corpName 和本地 profile 名。名称只用于输入;重名时必须按报错候选改用 `profile list` 返回的稳定 `corpId:userId`。
只传组织时必须存在唯一 `isOrgCurrent=true` 账号。多账号组织没有默认账号时先让用户指定账号;禁止选择第一项、最近登录或最近使用账号。`primaryProfile/isPrimary` 仅兼容输出,不参与选择。
## 跨组织铁律(必须执行,不得跳过)
「找群 / 找人 / 找数据」(chat search、aisearch / contact、doc / wiki 搜索等读 / 搜场景)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
「找群 / 找人 / 找数据」(chat search、aisearch / contact、doc / wiki 搜索等读 / 搜场景)在当前组织没命中、且 `dws profile list` 按 `corpId` 去重后显示 ≥2 个组织时,每个组织使用唯一 `isOrgCurrent=true` 项的 `profile` 各搜一遍;命中即用,全部组织都没有才追问用户。多账号组织没有默认账号时先询问用户。禁止把同一组织的多个账号重复当成多个组织。
## 跨组织聚合(agent 编排,无内置 --all-orgs)
① `dws profile list` 拿到所有已登录组织 → ② 对每个组织带 `--profile <corpId>` 各取一次数 → ③ 合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
① `dws profile list --format json` 按 `corpId` 分组 → ② 每组取唯一 `isOrgCurrent=true` 的稳定 `profile`;没有则询问用户 → ③ 对每个稳定 `profile` 各取一次数 → ④ 合并并标注来源组织和账号。
## 安全护栏(务必遵守)
- 只有 `dws profile list` 显示 ≥2 个组织才启用跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 只有 `dws profile list` 按 `corpId` 去重后显示 ≥2 个组织才启用跨组织逻辑;同组织多账号不算多组织。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
- `dws auth logout` 默认退出所有已登录组织;只退一个加 `--profile <名称|corpId>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。
- `dws auth logout` 默认退出全部账号;组织选择器退出该组织全部账号;精确选择器或本地 profile 名只退出一个账号。执行前必须确认目标范围。
+8 -6
View File
@@ -17,13 +17,13 @@ metadata:
每个 dingtalk-* 子 skill 都把本 skill 列为 PREREQUISITE:执行任何产品命令前先读这里的认证、全局参数、Schema 命令发现与多组织规则。`dws` 必须在 PATH 上。
## 认证
- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权
- `dws auth status [--profile <名称|corpId>]` 查看认证状态
- `dws auth login`(新账号新增 profile,同一 `corpId + userId` 重登只刷新该账号);`--device` 无头 / SSH 登录
- `dws auth status [--profile <selector>]` 查看组织当前账号或指定账号;会尝试刷新,失败时保留真实错误
- macOS 出现 `ciphertext_key_mismatch` 且普通终端仍可登录时:先运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json`,通过后加 `--yes` 迁移;不要直接 `auth reset`
## 全局参数
- 所有命令加 `--format json` 取可解析输出
- 全局 `--profile <名称|corpId>`:单次指定本命令用哪个组织,一次性、不改默认组织
- 全局 `--profile <selector>`:支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`,也兼容单独的 corpId、唯一 corpName 和本地 profile 名;均不改默认 profile
- 以 leaf Schema 的 `confirmation` 为准:`user_required` 才先确认并在同意后加 `--yes`;`not_required` 不得仅因 `effect=write` 自行升级确认要求
## 命令发现:Schema + --help
@@ -58,10 +58,12 @@ Schema 与 Help 冲突属于契约漂移:参数只用 Help 接受的 flags,
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
## 多组织 / --profile(关键规则)
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 自动注入)。
dws 可同时登录多个钉钉账号,同一组织也可保留多个账号。一个 profile 由 `corpId + userId` 唯一确定;`dws profile list --format json` 默认显示全部账号,稳定选择器读取每项 `profile`。
- **跨组织铁律**:任何「找群 / 找人 / 找数据」(如 chat / aisearch / contact / doc / wiki / aitable / sheet / minutes / mail / report / todo / calendar / oa 的搜索、列表、查询)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
- **单组织**:`dws profile list` 只有 1 个组织时,按当前组织正常处理,不带 `--profile`。
- **选择规则**:名称只用于输入,自动化使用 `profile=corpId:userId`。名称重名时按报错候选改用稳定选择器,不得猜测。
- **组织默认账号**:只传组织时使用唯一 `isOrgCurrent=true` 的账号。多账号组织没有默认账号时先让用户指定账号;禁止选择第一项、最近登录或最近使用账号。
- **跨组织铁律**:任何读 / 搜在当前组织没命中、且按 `corpId` 去重后有 ≥2 个组织时,每个组织使用唯一组织默认账号的 `profile` 各搜一遍。禁止把同组织多个账号重复执行。
- **单组织**:只有 1 个组织时按当前账号处理;用户明确指定账号时使用该账号的稳定 `profile`。
- **安全护栏**:自动跨组织只对「读 / 搜」;写 / 发 / 删 / 撤回等操作默认只在当前组织做,确需带 `--profile` 跨组织写时先与用户确认目标组织;持久切换 `dws profile switch`(改默认组织)属写操作,未经用户明确要求不得执行。
- 完整命令与跨组织聚合见 `dingtalk-profile` skill。