Compare commits

..
Author SHA1 Message Date
修雨 6e070a7e24 ci: tighten PR coverage gate 2026-07-20 17:14:35 +08:00
修雨 e867abd03c Merge pull request #687 from shangguanxuan633-lab/codex/auth-token-manager-complete
fix(auth): unify token resolution and recover rejected tokens
2026-07-20 15:07:07 +08:00
修雨 9d89965de9 Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:53:06 +08:00
修雨 41088bb965 fix(release): derive OSS_REGION for ossutil v2 V4 signing (#692)
ossutil 2.x signs requests with V4 and refuses to run without an
explicit region, so the OSS mirror sync would fail in CI even with
valid credentials. Derive OSS_REGION from the endpoint host
(including -internal variants) and fail fast when it cannot be
derived.
2026-07-20 14:51:41 +08:00
修雨 8259116f15 test(auth): isolate Windows keychain packages 2026-07-20 14:33:17 +08:00
上官玄 9ec1fa0638 test(auth): use synthetic log redaction sentinel 2026-07-20 14:22:18 +08:00
修雨 9afd3be79b Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:15:48 +08:00
修雨 67da5019e3 Merge pull request #689 from DingTalk-Real-AI/codex/fix-beta4-channel-repair
fix(release): recover immutable mirror channels safely
2026-07-20 14:07:54 +08:00
shangguanxuan.sgx b0ded7deb8 fix(auth): retry rejected access tokens safely 2026-07-20 13:37:18 +08:00
shangguanxuan.sgx 22905fc41e fix(auth): unify access token resolution 2026-07-20 12:17:04 +08:00
修雨 ec9ff653fc fix(release): recover immutable mirror channels safely 2026-07-20 11:35:32 +08:00
修雨 876cf8e958 Merge pull request #685 from shangguanxuan633-lab/codex/fix-oauth-coverage-fixture-isolation-20260720
test(auth): isolate OAuth coverage fixtures
2026-07-20 10:41:05 +08:00
修雨 1c5ed6646e Merge branch 'main' into codex/fix-oauth-coverage-fixture-isolation-20260720 2026-07-20 09:57:51 +08:00
修雨 80549a80e0 Merge pull request #665 from DingTalk-Real-AI/agent/changelog-fast-path
ci: align Code Admission gates and trusted changelog fast path
2026-07-20 09:34:43 +08:00
上官玄 883d416d83 test(auth): isolate OAuth coverage fixtures 2026-07-20 07:29:24 +08:00
修雨 25c70aeb24 ci: align admission gates and changelog fast path 2026-07-19 23:59:48 +08:00
修雨 6cfa9e3afb ci: fast-path changelog-only pull requests 2026-07-19 23:11:04 +08:00
修雨 544a91e994 Merge pull request #667 from DingTalk-Real-AI/codex/repair-gitee-dispatch
ci(release): add dispatch repair-gitee job to mirror an existing release
2026-07-19 23:01:37 +08:00
修雨 024d487a22 Merge pull request #682 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.53-beta.4
chore: update Homebrew beta formula for v1.0.53-beta.4
2026-07-19 22:52:31 +08:00
修雨 6b50cc41c5 ci(release): add dispatch repair-gitee job to mirror an existing release
The push-triggered mirror-gitee-release job consumes the same run's
finalized-release-dist artifact, so it cannot mirror a tag that was
already published — including one delivered by a recovery dispatch such
as v1.0.53-beta.3. Add a workflow_dispatch repair-gitee job (input
mirror_gitee_version) that re-derives the asset set from the immutable
GitHub Release, verifies it byte-for-byte via checksums, and runs
sync-to-gitee.sh. Guarded to the official repo + default branch and
gated by the existing Gitee secrets.
2026-07-19 21:51:35 +08:00
修雨 11e50662f9 Merge pull request #683 from DingTalk-Real-AI/codex/fix-event-bus-shutdown-race
fix(event): serialize bus shutdown with accept loop
2026-07-19 21:39:53 +08:00
修雨 29abdb6e79 fix(event): serialize bus shutdown with accept loop
Wait for the accept loop to stop before waiting for connection handlers, and track accepted connections before publishing handlers. This removes the WaitGroup Add/Wait race caught by PR #667 CI and follows up the event bus introduced in #589.
2026-07-19 21:21:16 +08:00
DWS Release Bot bc587ddd91 chore: update beta formula for v1.0.53-beta.4 2026-07-19 13:21:07 +00:00
修雨 6196e2565e Merge pull request #678 from DingTalk-Real-AI/fix/release-draft-asset-verify
fix(release): bind draft publication to release ID
2026-07-19 19:52:09 +08:00
修雨 609d56305e fix(release): bind draft publication to release ID 2026-07-19 12:22:44 +08:00
修雨 e69a1084a7 Merge pull request #675 from DingTalk-Real-AI/codex/fix-release-skip-propagation
fix(release): prevent skipped publication false greens
2026-07-18 12:11:37 +08:00
修雨 978ee6e636 fix(release): fail closed on skipped publication 2026-07-18 11:34:34 +08:00
修雨 987c63d99c Merge pull request #649 from PeterGuy326/codex/fast-quality-release
fix(release): add fast guarded release and recovery paths
2026-07-17 17:35:30 +08:00
修雨 e565746fb7 Merge remote-tracking branch 'origin/main' into codex/fast-quality-release
# Conflicts:
#	CHANGELOG.md
2026-07-17 17:19:02 +08:00
修雨 4f76d7cb4c fix(release): verify release token capabilities 2026-07-17 17:18:12 +08:00
修雨 e94f230236 Merge pull request #668 from DingTalk-Real-AI/release/changelog-v1.0.53-beta.4
docs(changelog): seal v1.0.53-beta.4
2026-07-17 17:08:20 +08:00
修雨 5242a0e1b1 docs(changelog): promote Unreleased into v1.0.53-beta.4
Seal the accumulated personal IM event subscription expansion and the
flattened event consume output (#651) into a dated beta.4 section.
2026-07-17 16:53:17 +08:00
修雨 c3e57b874b fix(ci): satisfy release workflow shellcheck 2026-07-17 16:13:16 +08:00
修雨 fa558372d2 fix(release): add fast guarded recovery path 2026-07-17 16:13:15 +08:00
修雨 ec9ae33a43 Merge pull request #651 from wxianfeng/feat/dws-event-im-2phase
feat(event): expand personal IM events and flatten output
2026-07-17 16:04:50 +08:00
修雨 2b49a2f365 Merge pull request #662 from LastdianXuan/agent/fix-eval-confirmed-bugs
fix: address confirmed CLI contract issues from v1.0.53 evaluation
2026-07-17 15:46:40 +08:00
修雨 ae9b14e536 Merge main into feat/dws-event-im-2phase 2026-07-17 15:46:38 +08:00
修雨 996c4ab250 fix: propagate structured output write failures 2026-07-17 15:04:13 +08:00
修雨 cf36ccb46e Merge remote-tracking branch 'origin/main' into codex/pr662-current 2026-07-17 14:56:36 +08:00
修雨 361115956f Merge pull request #648 from LastdianXuan/agent/fix-chat-update-icon-media-id
fix: accept uploaded media IDs for group icons
2026-07-17 14:50:51 +08:00
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
张卓澎 91bd7c7802 fix: emit structured audit verification output 2026-07-17 13:44:18 +08:00
张卓澎 5a8376ac0f fix: keep JSON command output machine-readable 2026-07-17 13:44:18 +08:00
张卓澎 31c984e18c fix: use MCP group ID key for message lists 2026-07-17 13:44:18 +08:00
修雨 69c0eb1a49 Merge remote-tracking branch 'origin/main' into codex/pr648-current 2026-07-17 12:25:43 +08:00
张卓澎 82e98d98a3 test: cover group icon validation on all platforms 2026-07-17 12:15:22 +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
张卓澎 8a7e1c7be7 fix: accept uploaded media IDs for group icons 2026-07-17 12:01:25 +08:00
wxianfeng f59be6c19a fix(event): address PR review gates 2026-07-17 11:57:27 +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
wxianfeng c7c9a6f926 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
2026-07-16 23:20:35 +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
wxianfeng a0224e1cbd fix(event): refine schema contracts and metadata 2026-07-16 17:45:03 +08:00
Dennis 0d81f061d8 fix shortcut IM AI message tag 2026-07-16 15:39:39 +08:00
wxianfeng cd22cfb530 chore(event): switch personal events to production 2026-07-16 15:04:04 +08:00
wxianfeng adc528c206 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	.github/badges/coverage.svg
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/event/consume/formatter.go
2026-07-16 14:27:39 +08:00
wxianfeng a2f1e79603 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	internal/cli/cobra_schema_test.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-15 20:36:56 +08:00
wxianfeng 1adb4bc681 feat(event): support openDingtalkId subscription targets 2026-07-14 20:58:13 +08:00
wxianfeng 723c577484 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	internal/app/event_personal_command.go
#	internal/event/consume/run.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-14 16:15:13 +08:00
wxianfeng 766930f6e7 feat(event): flatten personal event output 2026-07-14 15:39:21 +08:00
wxianfeng 2e3311c955 feat(event): expose sender message event 2026-07-13 15:47:27 +08:00
wxianfeng 1b4bb6b498 refactor(event): rename emotion events to reaction 2026-07-13 15:06:03 +08:00
wxianfeng 368e439280 chore(event): default personal events to pre-release 2026-07-13 11:36:41 +08:00
wxianfeng 8965fd2707 feat(event): add read recall and emotion events 2026-07-13 11:19:27 +08:00
wxianfeng c0a7ad88a4 Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli 2026-07-13 10:33:45 +08:00
wxianfeng b62b1848aa Merge remote-tracking branch 'upstream/main' 2026-07-13 10:30:52 +08:00
github-actions[bot] 5dd7f9abd3 chore: update coverage badge [skip ci] 2026-07-13 02:11:20 +00:00
wxianfeng eefee3c063 Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli 2026-07-13 10:08:11 +08:00
github-actions[bot] 6f5d17335b chore: update coverage badge [skip ci] 2026-06-04 10:04:00 +00:00
169 changed files with 19011 additions and 2319 deletions
+7
View File
@@ -5,6 +5,13 @@
## Verification
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
other pull request, mark the targeted check `N/A` and complete the applicable
full-suite checks.
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] `make build`
- [ ] `make lint`
- [ ] `make test`
+90 -55
View File
@@ -1,8 +1,11 @@
name: AI Behavior Check
name: Code Admission — AI Behavior
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches:
- main
permissions:
contents: read
@@ -11,7 +14,7 @@ permissions:
jobs:
ai-behavior-check:
name: AI Behavior Policy Evaluator
name: AI Behavior
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -21,75 +24,107 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const sha = context.payload.pull_request.head.sha;
const pullRequest = context.payload.pull_request;
const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha;
const setStatus = (state, description) =>
github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha,
state,
context: 'AI Behavior Check',
context: 'AI Behavior',
description,
});
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
const labels = context.payload.pull_request.labels.map(({ name }) => name);
if (!labels.includes('ai-generated')) {
await setStatus('success', 'Not labeled ai-generated');
core.notice('Not an ai-generated PR; no AI-only policy applied.');
if (context.eventName === 'push') {
await setStatus('success', 'Not applicable to the protected main push');
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
try {
const expectedHead = pullRequest.head.sha;
const expectedBase = pullRequest.base.sha;
const currentPull = async (phase) => {
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
throw new Error(
`Pull request revision changed during ${phase}: ` +
`expected base/head ${expectedBase}/${expectedHead}, ` +
`got ${pull.base.sha}/${pull.head.sha}`
);
}
return pull;
};
const before = await currentPull('pre-policy check');
const labels = before.labels.map(({ name }) => name);
if (!labels.includes('ai-generated')) {
await setStatus('success', 'Not labeled ai-generated');
core.notice('Not an ai-generated PR; no AI-only policy applied.');
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
await currentPull('post-policy check');
const maxChangedFiles = 30;
if (files.length > maxChangedFiles) {
await setStatus(
'failure',
`Changes ${files.length} files; limit is ${maxChangedFiles}`
);
core.setFailed(
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
);
return;
}
const isProtectedPath = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
filename === '.goreleaser.yaml' ||
filename === 'Makefile'
);
const protectedPaths = [...new Set(
files
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
.filter(isProtectedPath)
)];
if (protectedPaths.length > 0) {
await setStatus('failure', 'Modifies protected release/CI infrastructure');
core.setFailed(
'AI-generated PR modifies protected release/CI infrastructure:\n' +
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
'\nSplit these changes into a human-owned PR with explicit review.'
);
return;
}
const maxChangedFiles = 30;
if (files.length > maxChangedFiles) {
await setStatus(
'failure',
`Changes ${files.length} files; limit is ${maxChangedFiles}`
'success',
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
);
core.setFailed(
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
core.notice(
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
);
return;
} catch (error) {
await setStatus('error', 'Could not evaluate the exact pull request revision');
throw error;
}
const isProtectedPath = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
filename === '.goreleaser.yaml' ||
filename === 'Makefile'
);
const protectedPaths = [...new Set(
files
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
.filter(isProtectedPath)
)];
if (protectedPaths.length > 0) {
await setStatus('failure', 'Modifies protected release/CI infrastructure');
core.setFailed(
'AI-generated PR modifies protected release/CI infrastructure:\n' +
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
'\nSplit these changes into a human-owned PR with explicit review.'
);
return;
}
await setStatus(
'success',
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
);
core.notice(
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
);
+350 -113
View File
@@ -18,48 +18,136 @@ jobs:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
changelog_only: ${{ steps.classify.outputs.changelog_only }}
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
steps:
- name: Classify pull request scope
id: classify
uses: actions/github-script@v7
with:
script: |
let changelogOnly = false;
let changelogChanged = false;
let platformSensitive = context.eventName === 'push';
let files = [];
if (context.eventName === 'pull_request') {
const expectedHead = context.payload.pull_request.head.sha;
const expectedBase = context.payload.pull_request.base.sha;
const assertCurrentRevision = async (phase) => {
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
throw new Error(
`Pull request revision changed during ${phase}: ` +
`expected base/head ${expectedBase}/${expectedHead}, ` +
`got ${pull.base.sha}/${pull.head.sha}`
);
}
return pull;
};
const before = await assertCurrentRevision('pre-classification');
files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const after = await assertCurrentRevision('post-classification');
if (
before.changed_files !== files.length ||
after.changed_files !== files.length
) {
throw new Error(
`Pull request file list is incomplete: API reports ` +
`${after.changed_files} changed files, pagination returned ${files.length}`
);
}
changelogOnly =
files.length === 1 &&
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
changelogChanged = files.some(
({ filename, previous_filename }) =>
filename === 'CHANGELOG.md' ||
previous_filename === 'CHANGELOG.md'
);
const isPlatformSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('internal/auth/') ||
filename.startsWith('internal/keychain/') ||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
filename.startsWith('scripts/release/') ||
filename.startsWith('scripts/install') ||
filename.startsWith('Formula/') ||
filename.startsWith('build/npm/') ||
filename === '.goreleaser.yaml' ||
filename === '.github/workflows/release.yml'
);
platformSensitive = files.some(
({ filename, previous_filename }) =>
isPlatformSensitive(filename) ||
isPlatformSensitive(previous_filename)
);
}
core.setOutput('changelog_only', String(changelogOnly));
core.setOutput('changelog_changed', String(changelogChanged));
core.setOutput('platform_sensitive', String(platformSensitive));
await core.summary
.addHeading('Code Admission scope')
.addRaw(`- Event: \`${context.eventName}\`\n`)
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
.addRaw(`- Changed files: \`${files.length}\`\n`)
.write();
- name: Record CHANGELOG-only fast path
if: steps.classify.outputs.changelog_only == 'true'
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
- name: Check out repository
if: steps.classify.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: steps.classify.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Format Check
if: steps.classify.outputs.changelog_only != 'true'
run: |
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
- name: Go Vet
if: steps.classify.outputs.changelog_only != 'true'
run: go vet ./...
# golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25
# - name: golangci-lint
# uses: golangci/golangci-lint-action@v6
# with:
# version: v1.64.8
# args: ./...
actionlint:
name: Actionlint
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: Check GitHub Actions workflows
if: steps.classify.outputs.changelog_only != 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
test-race:
name: "Test (race: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
@@ -116,6 +204,8 @@ jobs:
test-release-scripts:
name: Test (release scripts)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -133,25 +223,85 @@ jobs:
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
test-cross-platform:
name: Test (cross-platform compile)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
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: Compile supported operating systems
shell: bash
run: |
set -eu
for target in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
goos="${target%/*}"
goarch="${target#*/}"
output="$RUNNER_TEMP/dws-${goos}-${goarch}"
if [ "$goos" = windows ]; then
output="${output}.exe"
fi
printf 'compile %s/%s\n' "$goos" "$goarch"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -o "$output" ./cmd
done
test:
name: Test
needs:
- lint
- test-race
- test-release-scripts
if: ${{ always() }}
- test-cross-platform
- test-darwin
- test-windows
if: ${{ always() && needs.lint.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify test shards
env:
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
RACE_RESULT: ${{ needs.test-race.result }}
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }}
DARWIN_RESULT: ${{ needs.test-darwin.result }}
WINDOWS_RESULT: ${{ needs.test-windows.result }}
run: |
failed=0
if [ "$CHANGELOG_ONLY" = true ]; then
for shard in \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
"macOS native:$DARWIN_RESULT" \
"Windows native:$WINDOWS_RESULT"
do
name="${shard%%:*}"
result="${shard#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != skipped ]; then
failed=1
fi
done
test "$failed" -eq 0
exit
fi
for shard in \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT"
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT"
do
name="${shard%%:*}"
result="${shard#*:}"
@@ -160,10 +310,28 @@ jobs:
failed=1
fi
done
native_expected=skipped
if [ "$PLATFORM_SENSITIVE" = true ]; then
native_expected=success
fi
for native in \
"macOS native:$DARWIN_RESULT" \
"Windows native:$WINDOWS_RESULT"
do
name="${native%%:*}"
result="${native#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "$native_expected" ]; then
failed=1
fi
done
test "$failed" -eq 0
test-darwin:
name: Test (macOS auth/keychain)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: macos-latest
timeout-minutes: 15
steps:
@@ -180,6 +348,8 @@ jobs:
test-windows:
name: Test (Windows)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: windows-latest
timeout-minutes: 15
steps:
@@ -202,6 +372,8 @@ jobs:
coverage-darwin:
name: Coverage (macOS)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: macos-latest
timeout-minutes: 20
steps:
@@ -244,6 +416,8 @@ jobs:
coverage-windows:
name: Coverage (Windows)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: windows-latest
timeout-minutes: 20
steps:
@@ -288,6 +462,8 @@ jobs:
coverage-current:
name: Coverage (current)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -321,6 +497,8 @@ jobs:
coverage-supporting:
name: Coverage (supporting)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -358,6 +536,8 @@ jobs:
coverage-baseline:
name: Coverage (baseline)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -421,20 +601,35 @@ jobs:
coverage:
name: Coverage
needs:
- lint
- coverage-current
- coverage-supporting
- coverage-baseline
if: ${{ always() }}
- coverage-darwin
- coverage-windows
if: ${{ always() && needs.lint.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Verify coverage profile jobs
env:
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
CURRENT_RESULT: ${{ needs.coverage-current.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
run: |
failed=0
expected=success
native_expected=skipped
if [ "$CHANGELOG_ONLY" = true ]; then
expected=skipped
elif [ "$PLATFORM_SENSITIVE" = true ]; then
native_expected=success
fi
for profile in \
"current:$CURRENT_RESULT" \
"supporting:$SUPPORTING_RESULT" \
@@ -443,24 +638,42 @@ jobs:
name="${profile%%:*}"
result="${profile#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
if [ "$result" != "$expected" ]; then
failed=1
fi
done
for native in \
"macOS native:$DARWIN_RESULT" \
"Windows native:$WINDOWS_RESULT"
do
name="${native%%:*}"
result="${native#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$CHANGELOG_ONLY" = true ]; then
native_expected=skipped
fi
if [ "$result" != "$native_expected" ]; then
failed=1
fi
done
test "$failed" -eq 0
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true'
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
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
if: needs.lint.outputs.changelog_only != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -478,33 +691,40 @@ jobs:
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
path: .
- name: Download supporting coverage profiles
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-supporting-profiles
path: .
- name: Download baseline coverage profile
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-baseline-profile
path: .
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true'
env:
COVERAGE_TARGET: "80"
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
COVERAGE_OVERALL_TOLERANCE: "0"
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true'
run: go tool cover -html=coverage.txt -o coverage.html
- name: Upload coverage artifact
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/upload-artifact@v4
with:
name: coverage-report
@@ -516,44 +736,114 @@ jobs:
coverage.html
policy:
name: Policy Check
name: Policy
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify pull request merge revision
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
echo "checked-out merge first parent does not match event base" >&2
exit 1
}
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
echo "checked-out merge second parent does not match event head" >&2
exit 1
}
- name: Validate changed CHANGELOG content
if: github.event_name == 'pull_request'
env:
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
merge_changelog_changed=false
if git diff --no-ext-diff --find-renames --name-status \
"$PR_BASE_SHA" HEAD |
awk -F '\t' '
{
for (field = 2; field <= NF; field++) {
if ($field == "CHANGELOG.md") found = 1
}
}
END { exit !found }
'
then
merge_changelog_changed=true
fi
test "$merge_changelog_changed" = "$CLASSIFIED_CHANGELOG_CHANGED" || {
echo "Files API and synthetic merge tree disagree on CHANGELOG scope" >&2
exit 1
}
if [ "$merge_changelog_changed" != true ]; then
exit 0
fi
mode=--content-only
if [ "$CHANGELOG_ONLY" = true ]; then
mode=--fast-path
fi
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: |
echo "Only the base-equivalent CHANGELOG validator ran; full Policy resumes on main." \
>> "$GITHUB_STEP_SUMMARY"
- name: Build
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Policy
if: needs.lint.outputs.changelog_only != 'true'
run: make policy
interface-integrity:
name: Interface Integrity
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true'
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
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Resolve authoritative compatibility merge-base
if: needs.lint.outputs.changelog_only != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -578,6 +868,7 @@ jobs:
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
if: needs.lint.outputs.changelog_only != 'true'
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
@@ -587,143 +878,89 @@ jobs:
fi
- name: Check complete Schema compatibility
if: needs.lint.outputs.changelog_only != 'true'
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
- name: Check skill command references
if: needs.lint.outputs.changelog_only != 'true'
run: make skill-command-integrity
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
cli-smoke:
name: CLI Smoke
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Check public top-level commands
if: needs.lint.outputs.changelog_only != 'true'
run: make cli-smoke
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
mock-mcp-smoke:
name: Mock MCP Smoke
name: Mock MCP
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check HTTP and stdio MCP transport
if: needs.lint.outputs.changelog_only != 'true'
run: make mock-mcp-smoke
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
edition-tests:
name: Edition Contract Tests
name: Edition
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run edition contract tests
if: needs.lint.outputs.changelog_only != 'true'
run: go test -v -count=1 ./pkg/editiontest/...
ci-gate:
name: CI Gate
needs:
- lint
- actionlint
- test
- test-darwin
- test-windows
- coverage
- coverage-darwin
- coverage-windows
- policy
- interface-integrity
- cli-smoke
- mock-mcp-smoke
- edition-tests
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required checks
env:
LINT_RESULT: ${{ needs.lint.result }}
ACTIONLINT_RESULT: ${{ needs.actionlint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
POLICY_RESULT: ${{ needs.policy.result }}
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
run: |
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Actionlint:$ACTIONLINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
"Coverage:$COVERAGE_RESULT" \
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
"Policy Check:$POLICY_RESULT" \
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
"CLI Smoke:$CLI_SMOKE_RESULT" \
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
"Edition Contract Tests:$EDITION_TESTS_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
notify-downstream:
name: Notify Wukong Overlay
needs: [ci-gate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |
# Trigger internal GitLab CI pipeline via webhook.
# WUKONG_TRIGGER_TOKEN is a repository secret.
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
curl --fail --silent --show-error \
-X POST \
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
-F "ref=main" \
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
"${{ secrets.WUKONG_TRIGGER_URL }}"
echo "Downstream CI triggered."
else
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
fi
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
+5 -4
View File
@@ -1,8 +1,9 @@
name: Multi Profile E2E
name: Main Integration — 主干集成
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
@@ -14,7 +15,7 @@ concurrency:
jobs:
multi-profile-e2e:
name: Multi Profile E2E
name: Multi-profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -36,7 +37,7 @@ jobs:
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi Profile E2E"
echo "### Multi-profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
+38
View File
@@ -0,0 +1,38 @@
name: Main Integration — Wukong Overlay
on:
workflow_run:
workflows:
- CI
types:
- completed
permissions: {}
jobs:
notify-downstream:
name: Notify Wukong Overlay
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Trigger downstream CI
env:
UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }}
WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }}
WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }}
run: |
if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then
curl --fail --silent --show-error \
-X POST \
-F "token=$WUKONG_TRIGGER_TOKEN" \
-F "ref=main" \
-F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \
"$WUKONG_TRIGGER_URL"
echo "Downstream CI triggered."
else
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
fi
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -6,6 +6,47 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Changed
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
- **Protected existing-tag recovery** — `dws-release recover <version>` can resume a failed, unpublished annotated tag through the normal contract, build, Developer ID signing, immutable GitHub Release, Homebrew, npm, and OSS jobs. Recovery requires the exact tag object, peeled commit, failed tag-push run, typed version confirmation, and the protected `release-recovery` environment; successful runs are accepted as future beta/stable delivery evidence.
### Fixed
- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.”
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
## [1.0.53-beta.4] - 2026-07-17
This beta validates the expanded personal IM event subscriptions and the flattened `event consume` structured output introduced after v1.0.53-beta.3.
### Added
- **Expanded personal IM event subscriptions** (#651) — adds one-to-one and group events for message read receipts, recalls, and reactions; publishes the specified-sender receive event; and lets one-to-one/sender subscriptions target either a staff `--user` or an `--open-dingtalk-id`. Event Schema now exposes these alternatives through machine-readable parameter constraints.
### Changed
- **Personal event structured output is now flat** (#651) — `event consume` projects NDJSON/JSON/pretty/compact output into event-specific top-level DTOs, so consumers read fields such as `content`, `sender`, and `conversation_id` directly instead of parsing `.data | fromjson`. This is a breaking change for scripts using the former transport envelope; the original server payload remains available through `-f raw`, while `--debug-raw-events` preserves the full diagnostic envelope.
## [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.
@@ -26,7 +67,6 @@ This beta validates the accumulated post-v1.0.52 command surface, release automa
- **PAT organization-policy denials stop immediately** — `PAT_ORG_POLICY_DENIED` now remains terminal even if a backend also returns `flowId`, authorization URLs, or client credentials; the CLI does not mutate process credentials, open a browser, poll, or retry until an organization administrator changes the policy.
- **Sheet and task invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while task completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
- **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.
- **Concurrent credential writes and reentrant CLI execution** — secure-token writers now use isolated, exclusive temporary files before atomic replacement so concurrent processes cannot remove each other's in-flight data, and repeated in-process CLI runs close the previous file logger before replacing it instead of retaining the prior log-file handle.
## [1.0.52] - 2026-07-14
+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.4"
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.4/dws-darwin-arm64.tar.gz"
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
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.4/dws-darwin-amd64.tar.gz"
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
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.4/dws-linux-arm64.tar.gz"
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
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.4/dws-linux-amd64.tar.gz"
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
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.4/dws-skills.zip"
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
end
def install
+3 -3
View File
@@ -19,8 +19,8 @@ help:
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@@ -45,7 +45,7 @@ rebuild:
@./scripts/dev/build.sh
test:
@./test/scripts/run_all_tests.sh
@./test/scripts/run_all_tests.sh --timeout 5m
lint:
@./scripts/dev/lint.sh
+18 -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>
@@ -489,6 +495,9 @@ dws event consume user_im_message_receive_at -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
@@ -497,6 +506,8 @@ dws event status
dws event stop <subscribe_id>
```
For one-to-one and specified-sender events, use exactly one target identity: `--user` for an internal `userId`, or `--open-dingtalk-id` for an `openDingtalkId`. The CLI does not infer or convert between these identity types.
| Feature | Details |
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
+18 -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>
@@ -483,6 +489,9 @@ dws event consume user_im_message_receive_at -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
@@ -491,6 +500,8 @@ dws event status
dws event stop <subscribe_id>
```
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
| 特性 | 说明 |
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
+35
View File
@@ -43,3 +43,38 @@
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
## Quality Pipeline
Quality enforcement is layered so a pull request receives fast, deterministic
admission feedback without pretending that downstream integration has already
run.
```mermaid
flowchart TB
PR["Pull request"] --> CA["CI"]
subgraph CA_CHECKS["Nine required contexts"]
L["Lint"]
T["Test"]
C["Coverage"]
P["Policy"]
E["Edition"]
I["Interface Integrity"]
A["AI Behavior"]
S["CLI Smoke"]
M["Mock MCP"]
end
CA --> CA_CHECKS
CA_CHECKS --> MAIN["Protected main"]
MAIN --> MP["Main Integration — 主干集成<br/>Multi-profile E2E"]
MAIN --> PLATFORM["Risk-selected / release native platform validation"]
MP --> RELEASE["Release delivery"]
PLATFORM --> RELEASE
```
Complete Multi-profile E2E and the ordinary full native-platform matrix are
downstream of PR admission. PRs still run primary-environment assurance and
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
release changes additionally select native platform tests before merge. See
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
contract.
+50 -6
View File
@@ -65,21 +65,65 @@ git diff --check
## Homebrew Formula PR Automation
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
allow fine-grained personal access tokens to target this repository, so use a
classic personal access token owned by a maintainer or release-bot account with
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
maintainer or release-bot account, limited to this repository with
`Contents: write` and `Pull requests: write`. If organization policy prevents
that account from targeting the repository, use a dedicated classic token with
only the `public_repo` scope. Do not reuse a broad developer token.
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
secret. Replace it immediately if it is exposed, its owner loses repository
access, or the release-bot ownership changes. The Release workflow uses this
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
and rotate it before its configured expiration. Replace it immediately if it is
exposed, its owner loses repository access, or the release-bot ownership
changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
The default-branch governance preflight and every tag contract authenticate the
token before publication, reject over-scoped classic tokens, confirm its
identity, and run a controlled write canary. The canary pushes a unique
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
draft PR, closes it, and deletes the branch with the same token. This proves both
Contents and Pull requests write access before publication without merging
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
## Release Governance and Recovery
Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only
repository `Administration: read`. The immutable-releases REST endpoint is an
administration setting and cannot be read by the workflow's built-in
`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag
contract use this same credential so a missing or expired identity is detected
before an irreversible tag is created.
Create a protected `release-recovery` environment limited to protected
branches, with a required reviewer, self-review disabled, and administrator
bypass disabled. The workflow reads the environment through the GitHub API and
fails closed unless the required-reviewer, prevent-self-review, and protected-
branch rules are present.
Recovery is restricted to an existing annotated tag whose exact tag object,
commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
If the immutable GitHub Release and npm package were delivered but a downstream
China mirror failed, dispatch the normal `Release` workflow from the protected
default branch with exactly one of `repair_gitee_version` or
`repair_oss_version`. Channel repair accepts a failed exact-tag run only when
its latest attempt completed the release contract, build, Apple signature,
immutable GitHub publication, and npm delivery checks for the exact tagged
commit. It then downloads and re-verifies the immutable assets before invoking
only the selected mirror. An OSS repair requires the OSS step itself to be the
recorded failure. A Gitee repair accepts either a failed Gitee job or a Gitee
job that was skipped behind that OSS failure; the latter is an explicit Gitee
backfill and does not claim that OSS has been repaired. Gitee repair requires
`GITEE_TOKEN`, `GITEE_USER`, and `GITEE_REPO`; OSS repair requires
`OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET`
(with optional `OSS_PREFIX`) as Actions secrets. Missing credentials fail the
selected repair closed.
## Handoff Checklist
Before handoff, include:
+133 -108
View File
@@ -1,133 +1,158 @@
# Pull request quality gates
# CI — PR 合入门禁
The repository defines five focused checks in addition to its existing CI:
The pull-request admission layer has exactly nine required external contexts:
- **Interface Integrity** enforces backwards compatibility. Every historical
command path and alias must still resolve, every historical command must
still render `-h`, and historical flags must keep their type and shorthand.
New commands, aliases, and flags are allowed. The same job compares the full
complete `dws schema --all` contract with the PR merge-base, blocking removed
products/tools/parameters, incompatible parameter or interface mappings,
constraint drift, and safety-semantic drift. It also checks that executable
`dws ...` references in `skills/**/*.md` resolve to real commands.
Help compatibility covers command/alias/flag spelling, flag type and
shorthand; descriptive prose may evolve without breaking the gate.
- **Coverage** runs unit tests on every pull request and prints both overall and
changed-code statement coverage. During the migration to the 80% repository
target, overall coverage may not regress from a profile generated from the
merge-base with the same test command, while changed production Go
statements must meet 80%. Linux, Windows, and macOS each generate a native
coverage profile for changed packages and enforce the threshold against
changed files buildable on that platform, so build-tagged source cannot be
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
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
fails when the checked-in development fixture is stale.
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
(`Initialize -> ListTools -> CallTool`).
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
limits the change to 30 files and blocks release/CI infrastructure changes,
including policy implementations and the checked-in Interface fixture.
It uses `pull_request_target` without checking out PR code, so the policy
cannot be bypassed by changing the workflow in the same pull request. The
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
GitHub rulesets can require it.
| Required context | Contract |
|---|---|
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
| `Coverage` | Overall non-regression and 100% changed-code coverage |
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
| `Edition` | Edition contract tests |
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` |
| `CLI Smoke` | Offline help for every public top-level command |
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
## Running the compatibility gates
The workflow display name is `CI`. Parallel helper
jobs may implement `Test` and `Coverage`, but they are not ruleset contexts.
Do not require an aggregate alias or a downstream integration check in place of
the nine contracts above.
Run:
`AI Behavior` is evaluated by a `pull_request_target` workflow that never
checks out or executes PR code. It writes the exact `AI Behavior` status to the
current PR head. Its Files API read is bracketed by base/head revision checks,
so a synchronize race fails closed. The same workflow supplies a successful
`AI Behavior` check run on protected `main` pushes for release governance.
## Exact CHANGELOG-only fast path
A pull request qualifies only when GitHub reports exactly one changed file,
that file is an in-place modification of `CHANGELOG.md`, and the base and head
both retain it as a regular non-executable `100644` blob. Add, delete, rename,
symlink, executable-mode, and second-file changes do not qualify.
`Lint` classifies the Files API result only after verifying that the API's base
and head equal the event revision both before and after pagination. `Policy`
checks out GitHub's PR merge ref and verifies its parents:
```text
HEAD^1 = pull_request.base.sha
HEAD^2 = pull_request.head.sha
```
It then runs:
```sh
./scripts/policy/check-changelog-pr.sh \
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
All nine admission contexts are still emitted and must succeed. Expensive
implementation helpers are skipped; the named contexts record that their code
surface is unaffected. After merge, the protected `main` push executes the
full admission suite.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
## Platform and downstream boundaries
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
checks. Full native macOS/Windows tests and platform coverage run on a PR only
when its diff touches auth, keychain, OS-specific Go files, installers,
packaging, Formulae, or release automation. Protected `main` pushes run the
complete native matrix.
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
an explicit manual dispatch). A failing downstream run remains a real
regression and must be repaired, but it must not be represented by a synthetic
successful PR check.
```mermaid
flowchart TB
PR["Pull request"] --> ADMISSION["CI"]
ADMISSION --> L["Lint"]
ADMISSION --> T["Test"]
ADMISSION --> C["Coverage"]
ADMISSION --> P["Policy"]
ADMISSION --> E["Edition"]
ADMISSION --> I["Interface Integrity"]
ADMISSION --> A["AI Behavior"]
ADMISSION --> S["CLI Smoke"]
ADMISSION --> M["Mock MCP"]
ADMISSION --> MAIN["Protected main"]
MAIN --> NATIVE["Full native platform matrix"]
MAIN --> E2E["Multi-profile E2E"]
MAIN --> RELEASE["Release delivery"]
```
## Running focused gates locally
Run the contracts relevant to the change:
```sh
make build
make policy
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make skill-command-integrity
make cli-smoke
# Run on the corresponding native runner with its generated profile:
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
`make coverage-gate` is the enforcement step, not a profile generator. It
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:
For an exact CHANGELOG-only branch:
```sh
base_ref=$(git merge-base HEAD origin/main)
root=$(pwd)
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
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 -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 -p 1 \
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
make coverage-gate BASE_REF="$base_ref"
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
```
The native-platform target likewise expects `PROFILE` to have already been
generated on that operating system. CI owns those generation steps; copying
only either enforcement command into a clean checkout is intentionally an
incomplete invocation.
`make coverage-gate` is an enforcement step, not a profile generator. CI
generates the candidate, supporting, merge-base, and (when risk-selected)
native profiles before the aggregate `Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
non-regression check. Candidate and baseline profiles are evaluated by the
same block-deduplicating checker; supporting policy and shortcut profiles
contribute to changed-code coverage only. The checked-in badge is presentation
only and is never read as a gate input.
CI derives the authoritative Interface snapshots from both the PR merge-base
and the latest reachable stable release tag. The complete Schema snapshot comes
from the PR merge-base, which contains the registry-first Schema introduced on
`main`. The candidate branch cannot bless a breaking change by editing a
fixture. Schema additions are allowed; historical products, tools, parameters,
parameter mappings, positional execution fields, constraints, and safety
semantics remain protected. Positional descriptions are documentation and may
change without breaking compatibility.
`make update-interface-baseline` still extends the local checked-in Interface
fixture used by `make interface-integrity`. Updates are monotonic: they add new
commands and flags without removing history.
For an intentional compatibility reset at a major-version boundary, run
`make reset-interface-baseline`. This replaces all CLI compatibility history
with the current command tree and must receive explicit human review.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
## Required GitHub repository settings
Create a ruleset for `main` that requires pull requests and code-owner review,
then mark these aggregate status checks as required:
The `main` quality ruleset must enable strict required-status-check policy
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
`main` advances. It must require these exact contexts and no legacy aliases:
- `CI Gate`
- `Multi Profile E2E`
- `AI Behavior Check`
- `Lint`
- `Test`
- `Coverage`
- `Policy`
- `Edition`
- `Interface Integrity`
- `AI Behavior`
- `CLI Smoke`
- `Mock MCP`
`CI Gate` fails closed unless every first-layer CI job succeeds, including
lint, tests, native Linux/Windows/macOS coverage, policy,
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
check keeps repository rules stable when an internal job is renamed or split.
The `ai-generated` label must be applied by the PR-creation automation or by a
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
AI-generated code.
Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
alias. Update ruleset contexts only after the new names have appeared on the
protected branch, so a rename cannot silently remove enforcement or leave an
unproducible required context.
+5 -8
View File
@@ -1,10 +1,8 @@
# Event consume — AI subprocess contract
Aligns `dws event consume` with the "AI subprocess contract" that
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
exited.
Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the four **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
@@ -16,7 +14,6 @@ tracked separately and intentionally out of scope here.
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
aliased to `--timeout`: the global `--timeout` is the HTTP request
timeout (int seconds) and would collide (different type and meaning).
Docs note the lark-cli name difference.
- Bus idle-shutdown fires only with **zero** consumers, so a connected
consumer is never idle-killed.
- SIGINT/SIGTERM already cancel the run context and return cleanly.
@@ -28,7 +25,7 @@ tracked separately and intentionally out of scope here.
On connect, emit a fixed stderr line **before** any stdout event:
```
[event] ready event_key=<key> bus_pid=<pid>
[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>
```
Parents block on stderr until this line, then read stdout. Suppressed
@@ -75,7 +72,7 @@ or runtime failure (permissions, network, params) = non-zero, with no
### 4. Cleanup on exit (no `kill -9`)
Ownership-based, matching lark-cli:
Ownership-based cleanup:
- If this run **created** the subscription (no `--subscribe-id`), a clean
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
it server-side and sends Bye.
+37 -7
View File
@@ -2,7 +2,7 @@
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
## 日常只用一个入口
@@ -45,13 +45,13 @@ dws-release v1.2.3-beta.1
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
```bash
dws-release v1.2.3-beta.1 --publish
```
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
## 正式发布
@@ -97,7 +97,34 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
OSS/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或
stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧
attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release
workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
## 既有 tag 的紧急恢复
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
```bash
dws-release recover v1.2.3-beta.1
```
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
@@ -105,9 +132,12 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+223
View File
@@ -0,0 +1,223 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type tokenManagerSnapshotProvider struct {
load func() (*authpkg.TokenData, error)
}
func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) {
data, err := p.load()
if err != nil || data == nil {
return "", err
}
return data.AccessToken, nil
}
func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) {
return p.load()
}
type tokenManagerLegacyGetter struct {
token string
err error
}
func (g tokenManagerLegacyGetter) GetToken() (string, string, error) {
return g.token, "file", g.err
}
func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) {
t.Helper()
oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager
oldEdition := edition.Get()
edition.Override(&edition.Hooks{})
newAccessTokenProvider = func(string) accessTokenGetter {
return tokenManagerSnapshotProvider{load: load}
}
newLegacyTokenManager = func(string) legacyTokenGetter {
return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound}
}
t.Cleanup(func() {
newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy
edition.Override(oldEdition)
})
}
func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
token := "token-a"
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
first, err := manager.Get(context.Background(), configDir, "")
if err != nil || first.AccessToken != "token-a" {
t.Fatalf("first token = %#v, %v", first, err)
}
second, err := manager.Get(context.Background(), configDir, "")
if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 {
t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load())
}
token = "token-b"
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
rotated, err := manager.Get(context.Background(), configDir, "")
if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 {
t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) {
configDir := t.TempDir()
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token"}, nil
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
for range 2 {
if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" {
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
edition.Override(&edition.Hooks{
LoadToken: func(string) ([]byte, error) { return nil, nil },
TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
return fallback()
},
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
release := make(chan struct{})
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
<-release
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
const workers = 8
var wg sync.WaitGroup
wg.Add(workers)
errs := make(chan error, workers)
for range workers {
go func() {
defer wg.Done()
_, err := manager.Get(context.Background(), configDir, "")
errs <- err
}()
}
for calls.Load() == 0 {
time.Sleep(time.Millisecond)
}
close(release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) {
configDir := t.TempDir()
want := errors.New("keychain permission denied")
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want })
_, err := NewTokenManager().Get(context.Background(), configDir, "")
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("provider failure was misclassified as missing credentials: %v", err)
}
}
func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) {
missing := tokenResolutionError(authpkg.ErrTokenDataNotFound)
var typed interface{ Unwrap() error }
if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) {
t.Fatalf("missing error = %v", missing)
}
want := errors.New("decrypt failed")
if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) {
t.Fatalf("storage error = %v", got)
}
if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) {
t.Fatalf("cancellation = %v", got)
}
}
+244 -39
View File
@@ -21,19 +21,61 @@ import (
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const accessTokenRefreshWindow = 5 * time.Minute
type legacyTokenGetter interface {
GetToken() (string, string, error)
}
type accessTokenSnapshotGetter interface {
GetTokenSnapshot(context.Context) (*authpkg.TokenData, error)
}
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
// Refresh-token material never leaves the auth package.
type AccessTokenSnapshot struct {
AccessToken string
ExpiresAt time.Time
Source string
}
type tokenManagerKey struct {
configDir string
profile string
}
type tokenManagerEntry struct {
mu sync.Mutex
snapshot AccessTokenSnapshot
revision string
}
// TokenManager is the only process cache for user access tokens. Cache entries
// are isolated by config directory and profile, expiry-aware, and invalidated
// by the credential publication marker written by auth storage.
type TokenManager struct {
mu sync.Mutex
entries map[tokenManagerKey]*tokenManagerEntry
now func() time.Time
}
func NewTokenManager() *TokenManager {
return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now}
}
var runtimeTokenManager = NewTokenManager()
var (
newAccessTokenProvider = func(configDir string) accessTokenGetter {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, discard)
configureOAuthProviderCompatibility(provider, configDir)
return provider
}
@@ -44,55 +86,218 @@ var (
}
)
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
// the same host compatibility hooks as MCP. It mirrors the former body of
// getCachedRuntimeToken (excluding process-level cache and timing).
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
provider := newAccessTokenProvider(configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
}
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
return "", tokenErr
}
manager := newLegacyTokenManager(configDir)
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
return strings.TrimSpace(leg), nil
}
return "", nil
}
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
// the active edition config directory, the same process-cached path as MCP is used.
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
if t := strings.TrimSpace(explicitToken); t != "" {
return t, nil
// Get resolves an access token for the active runtime profile.
func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) {
if token := strings.TrimSpace(explicitToken); token != "" {
return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil
}
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty")
}
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
return tok, nil
key := tokenManagerKey{
configDir: canonicalTokenConfigDir(configDir),
profile: strings.TrimSpace(authpkg.RuntimeProfile()),
}
entry := m.entry(key)
entry.mu.Lock()
defer entry.mu.Unlock()
now := time.Now()
if m != nil && m.now != nil {
now = m.now()
}
revision, present, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision {
return entry.snapshot, nil
}
// Treat the marker and credential as one optimistic snapshot. A concurrent
// login/refresh between the reads causes a retry instead of caching stale A
// under the publication marker for B.
for attempt := 0; attempt < 4; attempt++ {
beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
return "", noCredentialsError()
snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if beforePresent != afterPresent || beforeRevision != afterRevision {
continue
}
if strings.TrimSpace(snapshot.AccessToken) == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" {
entry.snapshot = snapshot
entry.revision = afterRevision
} else {
entry.snapshot = AccessTokenSnapshot{}
entry.revision = ""
}
return snapshot, nil
}
tok, err := resolveAccessTokenFromDir(ctx, configDir)
return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials")
}
func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry {
m.mu.Lock()
defer m.mu.Unlock()
if m.entries == nil {
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
}
entry := m.entries[key]
if entry == nil {
entry = &tokenManagerEntry{}
m.entries[key] = entry
}
return entry
}
func (m *TokenManager) Invalidate() {
if m == nil {
return
}
m.mu.Lock()
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
m.mu.Unlock()
}
func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
hooks := edition.Get()
opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil
provider := hooks.TokenProvider
if provider == nil {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
// Opaque edition storage hooks have no publication-revision contract.
// Resolve them on every logical request instead of caching a token that
// may be replaced outside the default auth store.
if opaqueStorage {
snapshot.ExpiresAt = time.Time{}
}
return snapshot, nil
}
var fallbackSnapshot AccessTokenSnapshot
var fallbackCalled bool
token, err := provider(ctx, func() (string, error) {
fallbackCalled = true
var fallbackErr error
fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if fallbackErr != nil {
return "", fallbackErr
}
return fallbackSnapshot.AccessToken, nil
})
if err != nil {
return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if fallbackCalled && token == fallbackSnapshot.AccessToken {
if opaqueStorage {
fallbackSnapshot.ExpiresAt = time.Time{}
}
return fallbackSnapshot, nil
}
// Edition providers expose no lifetime metadata, so resolve them on every
// logical request instead of recreating a process-lifetime string cache.
return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil
}
func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
provider := newAccessTokenProvider(configDir)
if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok {
data, err := snapshotProvider.GetTokenSnapshot(ctx)
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
return AccessTokenSnapshot{
AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt,
Source: "oauth",
}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
token, err := provider.GetAccessToken(ctx)
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) {
token, source, err := newLegacyTokenManager(configDir).GetToken()
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if oauthErr != nil {
return AccessTokenSnapshot{}, oauthErr
}
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile())
if err != nil {
return "", err
}
if tok != "" {
return tok, nil
return snapshot.AccessToken, nil
}
// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through
// the same TokenManager used by MCP tool calls.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken)
if err != nil {
return "", err
}
return "", noCredentialsError()
return snapshot.AccessToken, nil
}
func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool {
return strings.TrimSpace(snapshot.AccessToken) != "" &&
!snapshot.ExpiresAt.IsZero() &&
now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow))
}
func canonicalTokenConfigDir(configDir string) string {
if absolute, err := filepath.Abs(configDir); err == nil {
return filepath.Clean(absolute)
}
return filepath.Clean(configDir)
}
func noCredentialsError() error {
if edition.Get().IsEmbedded {
return fmt.Errorf("认证信息已失效,请重新认证")
return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound)
}
return fmt.Errorf("no credentials found, run: dws auth login")
return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound)
}
+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)
}
+21 -7
View File
@@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
return g.token, g.err
}
func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) {
return g.token, g.err
}
type fakeLegacyTokenGetter struct {
token string
err error
@@ -212,11 +216,19 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
t.Fatalf("decryption error = %v", err)
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
newAccessTokenProvider = func(string) accessTokenGetter {
return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound}
}
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
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 != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
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{})
@@ -240,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider
oldStop := stopStdio
t.Cleanup(func() {
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory
stopStdio = oldStop
stdioMu.Lock()
stdioClients = make(map[string]*transport.StdioClient)
@@ -251,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
})
fail := errors.New("failure")
_ = oldFactory(t.TempDir())
markAccessTokenStale = func(string) error { return fail }
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail }
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
t.Fatalf("mark stale error = %v", err)
t.Fatalf("load rejected token error = %v", err)
}
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "rejected"}, nil
}
markAccessTokenStale = func(string) error { return nil }
for _, tc := range []struct {
getter fakeAccessTokenGetter
want string
@@ -264,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
{getter: fakeAccessTokenGetter{token: " refreshed "}},
} {
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter }
got, err := ForceRefreshAccessToken(context.Background(), "config")
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatalf("refresh error = %v, want %q", err, tc.want)
+24 -3
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -117,9 +118,29 @@ func newAuditVerifyCommand() *cobra.Command {
}
}
valid, brokenAt, err := auditVerify(target)
if err != nil {
return fmt.Errorf("校验失败: %w", err)
valid, brokenAt, verifyErr := auditVerify(target)
if output.ResolveFormat(cmd, output.FormatTable) == output.FormatJSON {
if verifyErr != nil && brokenAt == 0 {
return fmt.Errorf("校验失败: %w", verifyErr)
}
payload := map[string]any{
"valid": valid,
"file": target,
"brokenAt": brokenAt,
}
if verifyErr != nil {
payload["reason"] = verifyErr.Error()
}
if err := output.WriteCommandPayload(cmd, payload, output.FormatTable); err != nil {
return err
}
if !valid {
auditExit(1)
}
return nil
}
if verifyErr != nil {
return fmt.Errorf("校验失败: %w", verifyErr)
}
if valid {
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
@@ -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.
package app
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
)
func executeAuditVerifyJSON(t *testing.T, verify func(string) (bool, int, error)) (map[string]any, int, error) {
t.Helper()
previousVerify, previousExit := auditVerify, auditExit
t.Cleanup(func() {
auditVerify, auditExit = previousVerify, previousExit
})
auditVerify = verify
exitCode := 0
auditExit = func(code int) { exitCode = code }
root := &cobra.Command{Use: "dws"}
root.SilenceErrors = true
root.SilenceUsage = true
root.PersistentFlags().String("format", "json", "output format")
root.AddCommand(newAuditVerifyCommand())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"verify", "--file", "/tmp/audit.jsonl"})
err := root.Execute()
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("audit verify stdout must be one JSON document: %v\n%s", decodeErr, stdout.String())
}
return payload, exitCode, err
}
func TestCrossPlatformCoverageAuditVerifyJSONOutputIsSingleDocument(t *testing.T) {
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
return true, 0, nil
})
if err != nil || exitCode != 0 {
t.Fatalf("audit verify returned err=%v exit=%d", err, exitCode)
}
if payload["valid"] != true || payload["file"] != "/tmp/audit.jsonl" || payload["brokenAt"] != float64(0) {
t.Fatalf("unexpected audit payload: %#v", payload)
}
}
func TestCrossPlatformCoverageAuditVerifyBrokenJSONIncludesReasonBeforeExit(t *testing.T) {
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
return false, 3, errors.New("prev_hash mismatch")
})
if err != nil || exitCode != 1 {
t.Fatalf("broken audit verify returned err=%v exit=%d", err, exitCode)
}
if payload["valid"] != false || payload["brokenAt"] != float64(3) || payload["reason"] != "prev_hash mismatch" {
t.Fatalf("unexpected broken audit payload: %#v", payload)
}
}
+168 -34
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,6 +710,14 @@ 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)
}
@@ -810,7 +863,7 @@ func newAuthImportCommandWithSupport(supportError func() error) *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",
@@ -832,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()
@@ -1154,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,
@@ -1189,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
}
@@ -1197,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)
}
@@ -1230,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
}
@@ -1262,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"`
@@ -1277,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 != ""
@@ -1364,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,
+66 -13
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 {
@@ -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 {
+280 -16
View File
@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
@@ -498,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())
@@ -517,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)
@@ -534,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())
@@ -545,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)
}
}
@@ -594,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)
}
}
}
@@ -769,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)
@@ -790,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()
@@ -875,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, "")
@@ -901,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) {
@@ -951,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)
@@ -969,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) {
@@ -1193,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()
@@ -1208,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)
@@ -1224,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 {
@@ -1235,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)
}
}
}
@@ -1250,6 +1508,7 @@ type authLoginRecommendSequenceCaller struct {
responses []string
tools []string
args []map[string]any
tokens []string
beforeCall func(toolName string)
}
@@ -1271,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 }
+136
View File
@@ -15,6 +15,15 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
)
// authRetryingKey marks a context that has already attempted one
@@ -23,8 +32,35 @@ import (
// to the user instead.
type authRetryingKeyType struct{}
type authRefreshFailureError struct {
rejection error
refresh error
}
func (e *authRefreshFailureError) Error() string {
return "automatic access token refresh failed"
}
func (e *authRefreshFailureError) Unwrap() []error {
if e == nil {
return nil
}
return []error{e.rejection, e.refresh}
}
var authRetryingKey = authRetryingKeyType{}
var (
runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken
runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error)
)
func init() {
runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
return r.executeInvocation(ctx, endpoint, invocation)
}
}
// IsAuthRetrying reports whether the current context is already inside an
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
func IsAuthRetrying(ctx context.Context) bool {
@@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool {
v, _ := ctx.Value(authRetryingKey).(bool)
return v
}
func withAuthRetrying(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, authRetryingKey, true)
}
func authRefreshLogger() *slog.Logger {
if logger := FileLoggerInstance(); logger != nil {
return logger
}
return slog.Default()
}
func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool {
if r == nil || hasPluginAuth {
return false
}
return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == ""
}
// retryAuthRefreshRequired consumes only the explicit edition marker. It does
// not infer retryability from free text, generic auth categories, HTTP 403, or
// ordinary business errors.
func (r *runtimeRunner) retryAuthRefreshRequired(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
rejectedAccessToken string,
markerErr error,
hasPluginAuth bool,
) (executor.Result, error, bool) {
marker, marked := authretry.As(markerErr)
if !marked {
return executor.Result{}, nil, false
}
cause := marker.Cause
if cause == nil {
cause = markerErr
}
// Explicit --token and plugin credentials are not backed by the default
// OAuth refresh store. Preserve the overlay cause without mutating an
// unrelated persisted login.
if !r.managesRuntimeOAuth(hasPluginAuth) {
return executor.Result{}, cause, true
}
if IsAuthRetrying(ctx) {
authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
)
return executor.Result{}, cause, true
}
if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil {
// Keep every log credential-safe. The returned error chain retains the
// complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not
// serialize an OAuth response body or other attacker-controlled text.
authRefreshLogger().Warn("auth.runtime.refresh.failed",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
"stage", "force_refresh_rejected_token",
"error_type", fmt.Sprintf("%T", err),
)
logging.AuthDebug("auth.runtime.refresh.failed.detail",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
"stage", "force_refresh_rejected_token",
"error_type", fmt.Sprintf("%T", err),
)
combined := &authRefreshFailureError{rejection: cause, refresh: err}
return executor.Result{}, apperrors.NewAuth(
"automatic access token refresh failed",
apperrors.WithOperation("auth/token/refresh"),
apperrors.WithReason("auth_refresh_failed"),
apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"),
apperrors.WithCause(combined),
), true
}
logging.AuthDebug("auth.runtime.refresh.succeeded",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
)
result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation)
return result, err, true
}
// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and
// generic CategoryAuth values. OnAuthError may request a refresh only for an
// exact transport-level unauthorized signal.
func isRefreshableTransportAuthError(err error) bool {
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
return false
}
return typed.Reason == "http_401" || typed.RPCCode == 401
}
@@ -0,0 +1,354 @@
// 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 (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func installAuthRefreshRunnerSeams(t *testing.T) {
t.Helper()
previousHooks := edition.Get()
previousCall := runnerCallTool
previousPreflight := runnerPreflightDocDownload
previousRefresh := runnerForceRefreshRejectedAccessToken
previousRetry := runnerExecuteAuthRetry
previousCapture := runnerCaptureRuntimeFailure
previousProfile := authpkg.RuntimeProfile()
pluginAuthMu.Lock()
previousPlugins := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
}
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
authpkg.SetRuntimeProfile("")
runtimeTokenManager.Invalidate()
t.Setenv("DWS_CONFIG_DIR", "")
t.Setenv("DWS_DEBUG_AUTH", "0")
t.Cleanup(func() {
edition.Override(previousHooks)
runnerCallTool = previousCall
runnerPreflightDocDownload = previousPreflight
runnerForceRefreshRejectedAccessToken = previousRefresh
runnerExecuteAuthRetry = previousRetry
runnerCaptureRuntimeFailure = previousCapture
authpkg.SetRuntimeProfile(previousProfile)
runtimeTokenManager.Invalidate()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPlugins
pluginAuthMu.Unlock()
})
}
func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner {
return &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: flags,
auditSink: audit.NopSink{},
}
}
func authRefreshTestInvocation() executor.Invocation {
return executor.Invocation{
CanonicalProduct: "auth-retry-test-product",
Tool: "test_tool",
Params: map[string]any{"value": "safe"},
}
}
func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks {
return &edition.Hooks{
ConfigDir: func() string { return configDir },
TokenProvider: func(context.Context, func() (string, error)) (string, error) {
return *token, nil
},
ClassifyToolResult: classify,
}
}
func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
if expired, _ := content["expired"].(bool); expired {
return &authretry.AuthRefreshRequired{Cause: rejection}
}
return nil
}))
var callTokens []string
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
callTokens = append(callTokens, client.AuthToken)
if len(callTokens) == 1 {
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) {
refreshCalls++
if gotDir != configDir || rejected != "old-access" {
t.Fatalf("refresh input = dir %q token %q", gotDir, rejected)
}
token = "new-access"
return token, nil
}
result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if err != nil {
t.Fatal(err)
}
if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" {
t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens)
}
content, _ := result.Response["content"].(map[string]any)
if content["value"] != "ok" || content["success"] != true {
t.Fatalf("result content = %#v", content)
}
}
func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) {
installAuthRefreshRunnerSeams(t)
t.Setenv("DWS_DEBUG_AUTH", "1")
configDir := t.TempDir()
token := "old-access"
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error {
return &authretry.AuthRefreshRequired{Cause: rejection}
}))
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`)
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
return "", refreshErr
}
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) })
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) {
t.Fatalf("error = %v, want rejection and refresh causes", err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" {
t.Fatalf("refresh envelope = %#v", typed)
}
var rendered bytes.Buffer
if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil {
t.Fatal(printErr)
}
for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} {
if !strings.Contains(rendered.String(), want) {
t.Fatalf("structured stderr missing %s: %s", want, rendered.String())
}
}
for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} {
if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) {
t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String())
}
}
for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} {
if !strings.Contains(logs.String(), want) {
t.Fatalf("safe refresh log missing %q: %s", want, logs.String())
}
}
}
func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
firstCause := errors.New("first rejection")
secondCause := errors.New("second rejection")
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
attempt, _ := content["attempt"].(int)
if attempt == 1 {
return &authretry.AuthRefreshRequired{Cause: firstCause}
}
return &authretry.AuthRefreshRequired{Cause: secondCause}
}))
calls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
calls++
return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, secondCause) || errors.Is(err, firstCause) {
t.Fatalf("error = %v, want only second rejection cause", err)
}
if calls != 2 || refreshCalls != 1 {
t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls)
}
}
func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) {
t.Run("http 401 marker retries once", func(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := errors.New("transport rejected token")
hookCalls := 0
hooks := authRefreshTokenHooks(configDir, &token, nil)
hooks.OnAuthError = func(string, error) error {
hookCalls++
return &authretry.AuthRefreshRequired{Cause: rejection}
}
edition.Override(hooks)
calls := 0
var callTokens []string
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
calls++
callTokens = append(callTokens, client.AuthToken)
if calls == 1 {
return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401"))
}
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
t.Fatal(err)
}
if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" {
t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens)
}
})
for _, tc := range []struct {
name string
err error
}{
{name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))},
{name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))},
} {
t.Run(tc.name+" does not enter hook", func(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
hookCalls := 0
hooks := authRefreshTokenHooks(configDir, &token, nil)
hooks.OnAuthError = func(string, error) error {
hookCalls++
return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")}
}
edition.Override(hooks)
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, tc.err
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
return "", nil
}
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 {
t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls)
}
})
}
}
func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) {
installAuthRefreshRunnerSeams(t)
rejection := errors.New("explicit token rejected")
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
return &authretry.AuthRefreshRequired{Cause: rejection}
}})
calls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
calls++
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
return "", nil
}
_, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 {
t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls)
}
}
func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := errors.New("preflight token rejected")
edition.Override(authRefreshTokenHooks(configDir, &token, nil))
preflightCalls := 0
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
preflightCalls++
if preflightCalls == 1 {
return &authretry.AuthRefreshRequired{Cause: rejection}
}
return nil
}
toolCalls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
t.Fatal(err)
}
if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 {
t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls)
}
}
+50 -17
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) {
@@ -541,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())}
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
@@ -1462,14 +1475,11 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
t.Fatal("unsupported schema format succeeded")
}
for _, key := range []string{"", "unknown", personal.EventMention} {
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
if err := ensurePublicPersonalEvent(key); err != nil {
t.Fatalf("public event %q: %v", key, err)
}
}
if err := ensurePublicPersonalEvent(personal.EventFromUser); err == nil {
t.Fatal("private event accepted")
}
var cfg consume.Config
applyPersonalConsumeFilters(nil, personalConsumeOptions{}, "", "")
@@ -1604,7 +1614,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
{"lookup", personalConsumeOptions{SubscribeID: "existing"}, true},
{"create", personalConsumeOptions{EventKey: personal.EventMention, Name: "name", FilterJSON: `{"field":"content","op":"eq","value":"x"}`, QueryCSV: "a,b", TTL: time.Minute}, true},
{"missing", personalConsumeOptions{}, false},
{"private", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, false},
{"sender", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, true},
{"bad-rule", personalConsumeOptions{EventKey: personal.EventMention, Rule: "other"}, false},
{"bad-filter", personalConsumeOptions{EventKey: personal.EventMention, FilterJSON: "{"}, false},
} {
@@ -1618,7 +1628,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
}
})
}
if createCount != 1 {
if createCount != 2 {
t.Fatalf("create count = %d", createCount)
}
@@ -1640,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
}
func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) {
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
CorpID: "corp", UserID: "user", ClientID: "client",
@@ -2000,6 +2012,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 +2131,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 +2149,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 +2159,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)
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
missing := t.TempDir()
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("missing access token = %q, %v", got, err)
}
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
+9 -12
View File
@@ -174,6 +174,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"ttl",
"ephemeral",
"user",
"open-dingtalk-id",
"group",
"personal-event-base-url",
); err != nil {
@@ -312,7 +313,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"或从外部先用 dws event stop <subscribe_id> --dry-run 预览、确认后加 --yes(会一并退订);"+
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
f.StringVar(&personalOpts.UserID, "user", "",
"个人单聊对端 userId")
"单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)")
f.StringVar(&personalOpts.OpenDingTalkID, "open-dingtalk-id", "",
"单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)")
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
@@ -410,7 +413,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
return "portal-ticket-normal:" + sourceID
}
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
if !streamOpts.enabled() {
return eventNewDingtalkSource(source.Config{
ClientID: clientID,
@@ -418,14 +421,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
})
}
token, err := eventResolveAccessToken(ctx, configDir, "")
if err != nil {
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("event stream ticket: empty user token")
}
portalClientID := clientID
portalClientSecret := clientSecret
if streamOpts.usesPortalNormalMode() {
@@ -437,8 +432,10 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
ClientID: portalClientID,
ClientSecret: portalClientSecret,
PortalTicket: &source.PortalTicketConfig{
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
AccessToken: token,
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
AccessTokenProvider: func(ctx context.Context) (string, error) {
return eventResolveAccessToken(ctx, configDir, "")
},
SourceID: eventStreamSourceID(streamOpts.SourceID),
Mode: streamOpts.Mode,
ClientID: portalClientID,
@@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
t.Fatal(err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
stream := eventStreamTicketOptions{Mode: "custom"}
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
t.Fatalf("stream token error = %v", err)
var captured source.Config
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
captured = cfg
return &source.DingtalkSource{}, nil
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
t.Fatal("empty stream token succeeded")
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil {
t.Fatalf("stream source construction = %v", err)
}
if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) {
t.Fatalf("stream token provider error = %v", err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
for _, mode := range []string{"custom", "normal"} {
+79 -34
View File
@@ -37,6 +37,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -69,6 +70,7 @@ type personalConsumeOptions struct {
TTL time.Duration
Ephemeral bool
UserID string
OpenDingTalkID string
GroupID string
ControlBaseURL string
StreamTicketMode string
@@ -226,8 +228,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
projector := personalEventProjector(opts.DebugRawEvents)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
@@ -240,6 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
@@ -250,7 +259,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return personalConsumeRun(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
@@ -272,8 +281,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
// Ownership-based cleanup (AI-subprocess contract, aligned with
// lark-cli): a subscription this run CREATED is unsubscribed on exit
// Ownership-based cleanup: a subscription this run CREATED is
// unsubscribed on exit
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
@@ -284,22 +293,24 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
ReadySubscribeID: sub.SubscribeID,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
@@ -354,6 +365,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
func personalEventProjector(debugRawEvents bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
}
return personal.ProjectOutput
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
if cfg == nil {
return
@@ -369,6 +387,19 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
cfg.SubscribeID = strings.TrimSpace(subscribeID)
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
}); err != nil {
return err
}
_, _, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
return err
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
@@ -398,9 +429,10 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
GroupID: opts.GroupID,
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
if err != nil {
return nil, "", "", err
@@ -466,7 +498,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -581,7 +613,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
@@ -691,7 +723,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := personalLoadTokenData(configDir)
tokenData, err := personalLoadTokenData(configDir)
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
}
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
@@ -737,6 +772,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
}, nil
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
return client
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
@@ -785,7 +829,9 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessToken: opts.Identity.AccessToken,
AccessTokenProvider: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
},
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
@@ -800,15 +846,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)
+32 -3
View File
@@ -19,10 +19,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
cfg := consume.Config{}
cfg := consume.Config{EventKey: personal.EventSingleChat, ReadySubscribeID: "sub-1"}
opts := personalConsumeOptions{
DebugRawEvents: true,
Common: commonConsumeOptions{
@@ -34,6 +35,9 @@ func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
}
if cfg.EventKey != personal.EventSingleChat || cfg.ReadySubscribeID != "sub-1" {
t.Fatalf("raw debug cleared ready identity: eventKey=%q subscribeID=%q", cfg.EventKey, cfg.ReadySubscribeID)
}
}
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
@@ -48,6 +52,28 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
}
}
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
if personalEventProjector(false) == nil {
t.Fatal("normal personal consume projector = nil")
}
projector := personalEventProjector(true)
if projector == nil {
t.Fatal("debug raw personal consume projector = nil")
}
ev := transport.Event{
EventID: "raw-event",
Data: `{"payload":{"uid":100001,"bizid":"internal-bizid"}}`,
Headers: map[string]string{"TOPIC": "raw"},
}
projected, err := projector(ev)
if err != nil {
t.Fatal(err)
}
if got, ok := projected.(transport.Event); !ok || got.EventID != ev.EventID || got.Data != ev.Data || got.Headers["TOPIC"] != "raw" {
t.Fatalf("debug raw projection = %#v", projected)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
@@ -72,7 +98,7 @@ func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
cmd := newEventConsumeCommand()
for _, name := range []string{"user", "group", "query"} {
for _, name := range []string{"user", "open-dingtalk-id", "group", "query"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("flag --%s is not registered", name)
}
@@ -84,6 +110,7 @@ func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
"sender-union-id",
"open-conversation-id",
"keyword",
"odid",
} {
if cmd.Flags().Lookup(name) != nil {
t.Fatalf("retired flag --%s is still registered", name)
@@ -99,6 +126,7 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
"sender-union-id",
"open-conversation-id",
"keyword",
"odid",
} {
t.Run(name, func(t *testing.T) {
cmd := newEventConsumeCommand()
@@ -115,7 +143,8 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
for _, args := range [][]string{
{"--as", "app", "--user", "507971"},
{"--as", "app", "--user", "test-user-001"},
{"--as", "app", "--open-dingtalk-id", "open-user-1"},
{"--as", "app", "--group", "cid"},
{"--as", "app", "--query", "报警"},
} {
@@ -57,8 +57,8 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventFromUser}, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
t.Fatal("private subscription event succeeded")
if _, key, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || key != personal.EventFromUser || rule != "sender" {
t.Fatalf("sender subscription = %q %q, %v", key, rule, err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventMention}, nil
+229 -37
View File
@@ -18,7 +18,9 @@ import (
"encoding/json"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
@@ -43,8 +45,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
}
got := out.String()
assertPersonalOutputHidesSchemaIDs(t, got)
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
for _, eventKey := range []string{
personal.EventFromUser,
personal.EventReadO2O,
personal.EventReadGroup,
personal.EventRecallO2O,
personal.EventRecallGroup,
personal.EventReactionO2O,
personal.EventReactionGroup,
} {
if !strings.Contains(got, eventKey) {
t.Fatalf("list output missing %s: %s", eventKey, got)
}
}
})
}
@@ -64,8 +76,8 @@ func TestEventListDefaultsToUser(t *testing.T) {
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
t.Fatalf("list output = %s, want personal event catalog", got)
}
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
if !strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output missing public event %s: %s", personal.EventFromUser, got)
}
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
t.Fatalf("list default appears to use legacy application output: %s", got)
@@ -246,8 +258,8 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
}
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
if doc["jq_root_path"] != "." {
t.Fatalf("jq_root_path = %#v, want .", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
@@ -264,6 +276,80 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
}
}
func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
tests := []struct {
eventKeys []string
properties []string
}{
{
eventKeys: []string{personal.EventReadO2O, personal.EventReadGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "read_time", "event_time",
},
},
{
eventKeys: []string{personal.EventRecallO2O, personal.EventRecallGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "recall_time", "event_time",
},
},
{
eventKeys: []string{personal.EventReactionO2O, personal.EventReactionGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
"reaction_text", "operation_type", "operation_time", "sender",
"sender_open_dingtalk_id", "event_time",
},
},
}
for _, tt := range tests {
for _, eventKey := range tt.eventKeys {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v", doc["schema"])
}
properties, ok := schema["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", schema["properties"], len(tt.properties))
}
for _, field := range tt.properties {
if _, ok := properties[field]; !ok {
t.Fatalf("schema missing %q: %#v", field, properties)
}
}
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if _, ok := properties[internal]; ok {
t.Fatalf("schema exposed internal property %q", internal)
}
}
})
}
}
}
func TestEventSchemaDefaultsToUser(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
@@ -283,38 +369,144 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
}
}
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
args []string
}{
{
name: "schema",
cmd: newEventSchemaCommand(),
args: []string{personal.EventFromUser},
},
{
name: "consume",
cmd: newEventConsumeCommand(),
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
},
{
name: "status",
cmd: newEventStatusCommand(),
args: []string{"--event", personal.EventFromUser},
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.cmd.SilenceUsage = true
tc.cmd.SilenceErrors = true
tc.cmd.SetArgs(tc.args)
err := tc.cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
t.Fatalf("Execute() error = %v, want not publicly available", err)
}
})
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
if err := ensurePublicPersonalEvent(personal.EventFromUser); err != nil {
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
}
schemaCmd := newEventSchemaCommand()
schemaCmd.SilenceUsage = true
schemaCmd.SilenceErrors = true
var schemaOut bytes.Buffer
schemaCmd.SetOut(&schemaOut)
schemaCmd.SetArgs([]string{personal.EventFromUser})
if err := schemaCmd.Execute(); err != nil {
t.Fatalf("schema Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, schemaOut.String())
}
if doc["event_key"] != personal.EventFromUser || doc["rule_type"] != "sender" {
t.Fatalf("schema document = %#v", doc)
}
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
consumeCmd := newEventConsumeCommand()
consumeCmd.SilenceUsage = true
consumeCmd.SilenceErrors = true
consumeCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--dry-run"})
if err := consumeCmd.Execute(); err != nil {
t.Fatalf("consume dry-run Execute() error = %v", err)
}
openIDConsumeCmd := newEventConsumeCommand()
openIDConsumeCmd.SilenceUsage = true
openIDConsumeCmd.SilenceErrors = true
openIDConsumeCmd.SetArgs([]string{personal.EventFromUser, "--open-dingtalk-id", "open-user-1", "--dry-run"})
if err := openIDConsumeCmd.Execute(); err != nil {
t.Fatalf("consume openDingtalkId dry-run Execute() error = %v", err)
}
conflictingTargetCmd := newEventConsumeCommand()
conflictingTargetCmd.SilenceUsage = true
conflictingTargetCmd.SilenceErrors = true
conflictingTargetCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--open-dingtalk-id", "open-user-1", "--dry-run"})
err := conflictingTargetCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+personal.EventFromUser) {
t.Fatalf("conflicting target identity error = %v", err)
}
groupOpenIDCmd := newEventConsumeCommand()
groupOpenIDCmd.SilenceUsage = true
groupOpenIDCmd.SilenceErrors = true
groupOpenIDCmd.SetArgs([]string{personal.EventInChat, "--group", "cid-1", "--open-dingtalk-id", "open-user-1", "--dry-run"})
err = groupOpenIDCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+personal.EventInChat+"; use --group") {
t.Fatalf("group openDingtalkId error = %v", err)
}
missingUserCmd := newEventConsumeCommand()
missingUserCmd.SilenceUsage = true
missingUserCmd.SilenceErrors = true
missingUserCmd.SetArgs([]string{personal.EventFromUser})
err = missingUserCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+personal.EventFromUser) {
t.Fatalf("missing target identity error = %v", err)
}
}
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
root := NewRootCommand()
root.SilenceUsage = true
root.SilenceErrors = true
var out bytes.Buffer
root.SetOut(&out)
root.SetArgs([]string{"schema", "event consume"})
if err := root.Execute(); err != nil {
t.Fatalf("schema event consume Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
params, ok := doc["parameters"].(map[string]any)
if !ok {
t.Fatalf("schema parameters = %#v", doc["parameters"])
}
if _, ok := params["open-dingtalk-id"]; !ok {
t.Fatalf("schema parameters missing open-dingtalk-id: %#v", params)
}
if _, ok := params["odid"]; ok {
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
param, ok := params[name].(map[string]any)
if !ok {
t.Fatalf("schema parameter %s = %#v", name, params[name])
}
if got, exists := param["required_when"]; exists {
t.Fatalf("schema parameter %s unexpectedly declares required_when = %#v", name, got)
}
}
constraints, ok := doc["constraints"].(map[string]any)
if !ok {
t.Fatalf("schema constraints = %#v", doc["constraints"])
}
assertJSONConstraintGroup := func(field string, want []string) {
t.Helper()
groups, ok := constraints[field].([]any)
if !ok {
t.Fatalf("schema constraint %s = %#v", field, constraints[field])
}
for _, rawGroup := range groups {
group, ok := rawGroup.([]any)
if !ok || len(group) != len(want) {
continue
}
matched := true
for i := range want {
if group[i] != want[i] {
matched = false
break
}
}
if matched {
return
}
}
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
}
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
+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")
+27 -16
View File
@@ -27,9 +27,13 @@ type accessTokenGetter interface {
GetAccessToken(context.Context) (string, error)
}
type rejectedAccessTokenRefresher interface {
ForceRefreshRejectedToken(context.Context, string) (string, error)
}
var (
markAccessTokenStale = authpkg.MarkAccessTokenStale
newRefreshProvider = func(configDir string) accessTokenGetter {
loadRefreshTokenData = authpkg.LoadTokenData
newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
@@ -42,26 +46,33 @@ var (
// server-side rejection (HTTP 401 or business code such as
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
//
// Steps:
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
// OAuthProvider.GetAccessToken's fast-path will miss.
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
// existing dual-layer lock (process + file) to serialize concurrent
// refresh attempts across goroutines and processes.
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
// next resolveAuthToken call re-reads from disk.
//
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
// is the only entry point that orchestrates "force refresh" semantics.
// It snapshots the current access token, then delegates to the OAuth
// provider's dual-locked compare-and-refresh operation. If another caller has
// already rotated the token, that newer token is reused without another
// refresh request.
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if err := markAccessTokenStale(configDir); err != nil {
return "", fmt.Errorf("mark access token stale: %w", err)
data, err := loadRefreshTokenData(configDir)
if err != nil {
return "", err
}
if data == nil || strings.TrimSpace(data.AccessToken) == "" {
return "", fmt.Errorf("stored access token is empty")
}
return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken)
}
func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if strings.TrimSpace(rejectedAccessToken) == "" {
return "", fmt.Errorf("rejected access token is empty")
}
provider := newRefreshProvider(configDir)
tok, err := provider.GetAccessToken(ctx)
tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken)
if err != nil {
return "", err
}
+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)
+20 -20
View File
@@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser
var (
patAuthorizationTimeout = PatAuthRetryTimeout
patAuthorizationPollInterval = PatAuthPollInterval
patLoadTokenData = authpkg.LoadTokenData
patResolveAccessToken = ResolveAuxiliaryAccessToken
patWaitForAuthorization = WaitForPatAuthorization
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
patSaveAppConfig = authpkg.SaveAppConfig
@@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string {
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) {
timeout := patAuthorizationTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(patAuthorizationPollInterval)
@@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
select {
case <-ctx.Done():
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
return false
return false, ctx.Err()
case <-time.After(time.Until(deadline)):
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
return false
return false, nil
case <-pollTicker.C:
pollCount++
elapsed := time.Since(start).Truncate(time.Second)
remaining := time.Until(deadline).Truncate(time.Second)
// Check if token is now valid
tokenData, err := patLoadTokenData(configDir)
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true
}
// Check the same resolver used by every outbound bearer request.
if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true, nil
} else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) {
return false, fmt.Errorf("check authorization token: %w", err)
}
// Show polling status
@@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
PrintPatAuthError(output, scopeErr)
// Wait for user to complete authorization
authorized := patWaitForAuthorization(ctx, configDir, output)
authorized, waitErr := patWaitForAuthorization(ctx, configDir, output)
if waitErr != nil {
return executor.Result{}, waitErr
}
if !authorized {
return executor.Result{}, apperrors.NewAuth(
"等待用户授权超时",
@@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
pollURL := fmt.Sprintf("%s%s?flowId=%s",
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
// Load user access token for the poll request header.
var accessToken string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
accessToken = tokenData.AccessToken
}
// Use a client that does NOT follow redirects, so we can detect SSO 302.
noRedirectClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
slog.Debug("PAT poll: failed to create request", "error", err)
continue
}
accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "")
if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) {
return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr)
}
if accessToken != "" {
req.Header.Set("x-user-access-token", accessToken)
}
@@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
oldTimeout := patAuthorizationTimeout
oldInterval := patAuthorizationPollInterval
oldLoad := patLoadTokenData
oldResolve := patResolveAccessToken
t.Cleanup(func() {
patAuthorizationTimeout = oldTimeout
patAuthorizationPollInterval = oldInterval
patLoadTokenData = oldLoad
patResolveAccessToken = oldResolve
})
patAuthorizationTimeout = 50 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "token", nil
}
out.Reset()
if !WaitForPatAuthorization(context.Background(), "", &out) {
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok {
t.Fatal("valid token did not authorize")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
out.Reset()
if WaitForPatAuthorization(ctx, "", &out) {
t.Fatal("cancelled authorization succeeded")
if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = time.Millisecond
patAuthorizationPollInterval = time.Hour
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) {
t.Fatal("timed out authorization succeeded")
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling output = %q", out.String())
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
@@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
t.Fatal("PAT retry timeout succeeded")
}
wantErr := errors.New("runner failed")
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
t.Fatalf("authorized retry = %v", err)
}
@@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string {
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
oldDo := patPollHTTPDo
oldRequest := patPollNewRequest
oldLoad := patLoadTokenData
oldResolve := patResolveAccessToken
oldBrowser := patBrowserOpenCommand
t.Cleanup(func() {
patPollHTTPDo = oldDo
patPollNewRequest = oldRequest
patLoadTokenData = oldLoad
patResolveAccessToken = oldResolve
patBrowserOpenCommand = oldBrowser
})
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
cancelled, cancelNow := context.WithCancel(context.Background())
cancelNow()
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
+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)
}
+5 -1
View File
@@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
if err != nil {
return nil, err
}
tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders())
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
+85 -58
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)
}
@@ -223,7 +235,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
if prefetchToken {
go runnerGetCachedRuntimeToken(ctx)
go func() {
_, _ = runnerGetCachedRuntimeToken(ctx)
}()
}
if shouldUseDirectRuntime(invocation) {
@@ -313,10 +327,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 +349,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 {
@@ -517,8 +536,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
authToken := ""
if hasPluginAuth {
authToken = pluginAuth.Token
} else {
authToken = r.resolveAuthToken(ctx)
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error
authToken, tokenErr = r.resolveAuthToken(ctx)
if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr)
}
}
var timeoutSec int
@@ -600,6 +623,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, err, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -608,9 +637,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
RecordTiming(ctx, "mcp_call", time.Since(callStart))
if err != nil {
if isAuthError(err) {
if isRefreshableTransportAuthError(err) {
if fn := edition.Get().OnAuthError; fn != nil {
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, err, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
return executor.Result{}, overrideErr
}
@@ -635,6 +670,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, editionErr, retryErr)
}
return result, retryErr
}
return executor.Result{}, editionErr
}
}
@@ -655,6 +696,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// patterns (PAT permission, gateway-auth) before generic handling.
if classify := edition.Get().ClassifyToolResult; classify != nil {
if hookErr := classify(callResult.Content); hookErr != nil {
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, hookErr, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
return executor.Result{}, hookErr
}
@@ -779,67 +826,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
explicitToken := ""
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
if tp := edition.Get().TokenProvider; tp != nil {
token, _ := tp(ctx, func() (string, error) {
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
})
return token
}
return getCachedRuntimeToken(ctx)
return resolveRuntimeAuthToken(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
if err != nil {
return "", err
}
// Use cached token to avoid repeated Keychain access (~70ms per call)
return getCachedRuntimeToken(ctx)
return snapshot.AccessToken, nil
}
// Cached token state for process lifetime
var (
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
// cache itself lives exclusively in TokenManager.
func getCachedRuntimeToken(ctx context.Context) (string, error) {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
return resolveRuntimeAuthToken(ctx, "")
}
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
func tokenResolutionError(err error) error {
if err == nil {
return nil
}
if token == "" {
return ""
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
return apperrors.NewAuth(
"未登录,请先执行 dws auth login",
apperrors.WithReason("not_authenticated"),
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
apperrors.WithActions("dws auth login"),
apperrors.WithCause(err),
)
}
// Keychain, parse, permission, lock, and refresh failures are real local or
// network errors. Preserve their cause instead of disguising them as logout.
return fmt.Errorf("resolve access token: %w", err)
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -854,9 +883,7 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
runtimeTokenManager.Invalidate()
}
func newRuntimeContentScanner() safety.Scanner {
+9 -9
View File
@@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
prefetched := make(chan struct{}, 1)
runnerGetCachedRuntimeToken = func(context.Context) string {
runnerGetCachedRuntimeToken = func(context.Context) (string, error) {
prefetched <- struct{}{}
return ""
return "", nil
}
r := &runtimeRunner{
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
@@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
return nil
}
authErr := apperrors.NewAuth("expired")
authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401"))
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, authErr
}
@@ -329,19 +329,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
}
r.globalFlags.Token = " explicit "
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
t.Fatalf("explicit auth token = %q", got)
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
t.Fatalf("explicit auth token = %q, %v", got, err)
}
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
_, _ = fallback()
return "provided", nil
}})
r.globalFlags.Token = ""
if got := r.resolveAuthToken(context.Background()); got != "provided" {
t.Fatalf("provided auth token = %q", got)
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" {
t.Fatalf("provided auth token = %q, %v", got, err)
}
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
t.Fatalf("runtime explicit token = %q", got)
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
t.Fatalf("runtime explicit token = %q, %v", got, err)
}
t.Setenv(envDWSChannel, "channel")
@@ -35,6 +35,7 @@ import (
var (
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
)
// TestManualAgentExamplesContract is the always-on gate. It validates every
@@ -391,6 +392,9 @@ func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
normalized := strings.ToLower(capture.Output)
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
return cli.DryRunPreviewPlan, true
}
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
return cli.DryRunPreviewRequest, true
}
@@ -428,6 +432,22 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing
}
}
func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{
Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`,
DryRunChecks: 1,
})
if !observed || kind != cli.DryRunPreviewPlan {
t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed)
}
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{
Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`,
DryRunChecks: 1,
}) {
t.Fatal("non-dry-run structured plan was accepted as evidence")
}
}
func manualAgentExamplePromptObserved(output string) bool {
normalized := strings.ToLower(output)
for _, marker := range []string{
@@ -61,15 +61,24 @@ func TestEventRegistryDeliversOneTypedSchemaPath(t *testing.T) {
}
}
for flag, wantType := range map[string]string{
"dry-run": "boolean",
"duration": "string",
"event-types": "array",
"max-events": "integer",
"dry-run": "boolean",
"duration": "string",
"event-types": "array",
"max-events": "integer",
"open-dingtalk-id": "string",
} {
if got := schemaContractString(consumeParams[flag]["type"]); got != wantType {
t.Errorf("event.consume --%s type = %q, want %q", flag, got, wantType)
}
}
if _, exists := consumeParams["odid"]; exists {
t.Error("event.consume exposes unsupported --odid alias")
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
if _, exists := consumeParams[name]["required_when"]; exists {
t.Errorf("event.consume --%s unexpectedly declares required_when", name)
}
}
if _, exists := consumeParams["duration"]["default"]; exists {
t.Error("event.consume --duration leaked zero default 0s")
}
+30 -26
View File
@@ -17,6 +17,7 @@ import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@@ -36,25 +37,25 @@ import (
)
var (
skillLoadAccessToken = loadSkillAccessToken
skillDownloadToTmp = downloadSkillToTmpDir
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
skillNewRequest = http.NewRequestWithContext
skillLoadTokenData = authpkg.LoadTokenData
skillResolveTargetPath = resolveSkillTargetPath
skillFetchDownloadInfo = fetchSkillDownloadInfo
skillDownloadFile = downloadSkillFile
skillExtractZip = extractSkillZip
skillUserHomeDir = os.UserHomeDir
skillMkdirTemp = os.MkdirTemp
skillCreate = os.Create
skillCreateTemp = os.CreateTemp
skillRemoveAll = os.RemoveAll
skillRemove = os.Remove
skillMkdirAll = os.MkdirAll
skillOpenFile = os.OpenFile
skillCopy = io.Copy
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
skillLoadAccessToken = loadSkillAccessToken
skillDownloadToTmp = downloadSkillToTmpDir
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
skillNewRequest = http.NewRequestWithContext
skillResolveAccessToken = ResolveAuxiliaryAccessToken
skillResolveTargetPath = resolveSkillTargetPath
skillFetchDownloadInfo = fetchSkillDownloadInfo
skillDownloadFile = downloadSkillFile
skillExtractZip = extractSkillZip
skillUserHomeDir = os.UserHomeDir
skillMkdirTemp = os.MkdirTemp
skillCreate = os.Create
skillCreateTemp = os.CreateTemp
skillRemoveAll = os.RemoveAll
skillRemove = os.Remove
skillMkdirAll = os.MkdirAll
skillOpenFile = os.OpenFile
skillCopy = io.Copy
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
)
func init() {
@@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command {
func runSkillGet(cmd *cobra.Command, args []string) error {
skillID, _ := cmd.Flags().GetString("skill-id")
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
if source == "" {
source, _ = cmd.Flags().GetString("scopes")
}
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
return nil
}
func loadSkillAccessToken() (string, error) {
func loadSkillAccessToken(ctx context.Context) (string, error) {
configDir := defaultConfigDir()
tokenData, err := skillLoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
token, err := skillResolveAccessToken(ctx, configDir, "")
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
return "", skillAuthError()
}
return tokenData.AccessToken, nil
if err != nil {
return "", fmt.Errorf("resolve skill access token: %w", err)
}
return token, nil
}
func skillAuthError() error {
@@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
})
fail := errors.New("failure")
cmd := skillCoverageCommand()
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill get auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find request failure should propagate")
@@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
t.Fatalf("skill get download error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill find auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find network failure should propagate")
@@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
t.Fatal("invalid skill target should fail")
}
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill add auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill info error = %v", err)
@@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
oldHTTP := skillHTTPDo
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken
oldHome := skillUserHomeDir
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
t.Cleanup(func() {
skillHTTPDo = oldHTTP
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken
skillUserHomeDir = oldHome
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
})
fail := errors.New("failure")
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
if _, err := loadSkillAccessToken(); err == nil {
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
if _, err := loadSkillAccessToken(context.Background()); err == nil {
t.Fatal("invalid skill access token succeeded")
}
skillLoadTokenData = oldLoadToken
canceled, cancel := context.WithCancel(context.Background())
cancel()
skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) {
return "", ctx.Err()
}
if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("skill token cancellation = %v", err)
}
skillResolveAccessToken = oldResolveToken
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("download-info request failure should propagate")
+9 -13
View File
@@ -18,7 +18,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) {
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
originalResolveToken := skillResolveAccessToken
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
// Ensure the config directory exists but has no token
if err := os.MkdirAll(configDir, 0755); err != nil {
@@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
}, nil
originalResolveToken := skillResolveAccessToken
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "test-token", nil
}
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
var gotSources []string
var gotScopes []string
+10
View File
@@ -52,6 +52,10 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.TestNamespaceEnv + ": " + 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())
@@ -79,6 +83,12 @@ func TestMain(m *testing.M) {
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err)
if code == 0 {
code = 1
}
}
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
if code == 0 {
+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)
}
}
@@ -0,0 +1,70 @@
package auth
import (
"context"
"errors"
"testing"
"time"
)
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) {
oldLoad := oauthLoadToken
want := errors.New("keychain permission denied")
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
t.Cleanup(func() { oauthLoadToken = oldLoad })
_, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
if errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("load failure was misclassified as missing credentials: %v", err)
}
}
func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) {
oldLoad := oauthLoadToken
want := errors.New("keychain permission denied")
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
t.Cleanup(func() { oauthLoadToken = oldLoad })
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
}
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) {
oldLoad := oauthLoadToken
expiresAt := time.Now().Add(time.Hour)
oauthLoadToken = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil
}
t.Cleanup(func() { oauthLoadToken = oldLoad })
snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) {
configDir := t.TempDir()
if err := WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
first, present, err := ReadTokenMarkerRevision(configDir)
if err != nil || !present || first == "" {
t.Fatalf("first marker = %q, %v, %v", first, present, err)
}
if err := WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
second, present, err := ReadTokenMarkerRevision(configDir)
if err != nil || !present || second == "" || second == first {
t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first)
}
}
+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)
+290 -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")
}
}
@@ -1449,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
@@ -1483,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
@@ -1572,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 {
@@ -1630,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 {
@@ -1653,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)
@@ -1682,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) {
@@ -1710,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 }
@@ -1745,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)
@@ -1765,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)
@@ -1797,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)
@@ -2207,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 {
@@ -2222,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 }
@@ -2242,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)
@@ -2269,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)
}
@@ -2911,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)
}
+233 -1
View File
@@ -13,7 +13,50 @@
package auth
import "time"
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const rejectedTokenRefreshFailureCooldown = 3 * time.Second
type rejectedTokenRefreshKey struct {
configDir string
profile string
tokenDigest [sha256.Size]byte
}
type rejectedTokenRefreshCall struct {
done chan struct{}
participants int
token string
err error
}
type rejectedTokenRefreshFailure struct {
at time.Time
err error
}
var rejectedTokenRefreshCoordinator = struct {
sync.Mutex
inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall
failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure
now func() time.Time
}{
inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall),
failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure),
now: time.Now,
}
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
// instant (preserving access_token and refresh_token), and writes it back. The
@@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error {
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
return SaveTokenData(configDir, data)
}
// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is
// still the credential stored for the active profile. The compare and refresh
// run under the same process + file lock used by ordinary expiry refresh, so a
// late rejection cannot invalidate or refresh over a token another caller has
// already rotated.
//
// When the stored token no longer matches, the newer token is returned without
// calling the refresh endpoint. Refresh failures leave the stored credential in
// place; login/logout remain the only owners of credential deletion.
func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) {
if p == nil || strings.TrimSpace(p.configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
rejectedAccessToken = strings.TrimSpace(rejectedAccessToken)
if rejectedAccessToken == "" {
return "", fmt.Errorf("rejected access token is empty")
}
if ctx == nil {
ctx = context.Background()
}
profile := strings.TrimSpace(RuntimeProfile())
key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken)
call, leader := beginRejectedTokenRefresh(key)
if !leader {
select {
case <-call.done:
return call.token, call.err
case <-ctx.Done():
return "", ctx.Err()
}
}
token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key)
finishRejectedTokenRefresh(key, call, token, err, recordFailure)
return token, err
}
func (p *OAuthProvider) forceRefreshRejectedTokenOnce(
ctx context.Context,
profile string,
rejectedAccessToken string,
key rejectedTokenRefreshKey,
) (string, error, bool) {
lock, err := oauthAcquireLock(ctx, p.configDir)
if err != nil {
return "", fmt.Errorf("acquiring dual lock: %w", err), false
}
defer lock.Release()
data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile)
if err != nil {
return "", fmt.Errorf("reload rejected token: %w", err), false
}
current := strings.TrimSpace(data.AccessToken)
if current == "" {
clearRejectedTokenRefreshFailure(key)
return "", fmt.Errorf("stored access token is empty"), false
}
if current != rejectedAccessToken {
clearRejectedTokenRefreshFailure(key)
return current, nil, false
}
if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil {
return "", cachedErr, false
}
if !data.IsRefreshTokenValid() {
return "", fmt.Errorf("refresh_token 已过期"), true
}
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true
}
refreshed, err := oauthRefreshToken(p, ctx, data)
if err != nil {
return "", err, true
}
if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" {
return "", fmt.Errorf("force refresh returned empty access token"), true
}
return strings.TrimSpace(refreshed.AccessToken), nil, false
}
func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey {
canonicalDir := filepath.Clean(configDir)
if absolute, err := filepath.Abs(configDir); err == nil {
canonicalDir = filepath.Clean(absolute)
}
return rejectedTokenRefreshKey{
configDir: canonicalDir,
profile: strings.TrimSpace(profile),
tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))),
}
}
func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil {
call.participants++
return call, false
}
call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1}
rejectedTokenRefreshCoordinator.inFlight[key] = call
return call, true
}
func finishRejectedTokenRefresh(
key rejectedTokenRefreshKey,
call *rejectedTokenRefreshCall,
token string,
err error,
recordFailure bool,
) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
call.token = token
call.err = err
delete(rejectedTokenRefreshCoordinator.inFlight, key)
if err == nil {
delete(rejectedTokenRefreshCoordinator.failures, key)
} else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err}
}
close(call.done)
}
func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
for failureKey, failure := range rejectedTokenRefreshCoordinator.failures {
age := now.Sub(failure.at)
if age < 0 || age >= rejectedTokenRefreshFailureCooldown {
delete(rejectedTokenRefreshCoordinator.failures, failureKey)
}
}
if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok {
return failure.err
}
return nil
}
func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) {
rejectedTokenRefreshCoordinator.Lock()
delete(rejectedTokenRefreshCoordinator.failures, key)
rejectedTokenRefreshCoordinator.Unlock()
}
// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a
// second, non-reentrant auth lock. Opaque edition storage hooks (for example
// Wukong's encrypted .data file) are read inside the caller's dual lock so the
// compare-and-refresh decision covers both Core and embedded storage.
func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) {
hooks := edition.Get()
if hooks.LoadToken == nil {
data, err := oauthLoadTokenLocked(configDir, profile)
if err != nil {
return nil, err
}
if data == nil {
return nil, fmt.Errorf("stored token data is empty")
}
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
blob, err := hooks.LoadToken(configDir)
if err != nil {
return nil, err
}
var data TokenData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, fmt.Errorf("parsing token data from hook: %w", err)
}
return &data, nil
}
+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 -2
View File
@@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) {
}
return token, "file", nil
}
return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login"))
if err != nil && !os.IsNotExist(err) {
return "", "", fmt.Errorf("load legacy token: %w", err)
}
return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound)
}
func (m *Manager) GetMCPURL() (string, error) {
+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
+154 -15
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.
@@ -110,6 +115,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Smart degradation: try silent refresh before opening browser.
if !force {
data, err := oauthLoadToken(p.configDir)
if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) {
if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr)
}
return nil, fmt.Errorf("load existing access token: %w", err)
}
if err == nil {
// Case 1: access_token still valid — no action needed.
if data.IsAccessTokenValid() {
@@ -175,6 +186,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 +224,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 +288,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 +308,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 +341,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 +601,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.
@@ -575,35 +642,50 @@ continueLogin:
return tokenData, nil
}
// GetAccessToken returns a valid access token, auto-refreshing if needed.
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
// GetTokenSnapshot returns a valid token together with its expiry metadata.
// Storage and refresh failures retain their original cause; only a confirmed
// missing credential is reported as ErrTokenDataNotFound.
func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) {
data, err := oauthLoadToken(p.configDir)
if err != nil {
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) {
return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound)
}
return nil, fmt.Errorf("load access token: %w", err)
}
// Fast path: access_token still valid — no lock needed.
if data.IsAccessTokenValid() {
return data.AccessToken, nil
return data, nil
}
// Slow path: token expired — try locked refresh.
if data.IsRefreshTokenValid() {
refreshed, rErr := p.lockedRefresh(ctx)
if rErr == nil {
return refreshed.AccessToken, nil
return refreshed, 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 nil, 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 重新登录"))
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
}
// GetAccessToken returns a valid access token, auto-refreshing if needed.
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
data, err := p.GetTokenSnapshot(ctx)
if err != nil {
return "", err
}
return strings.TrimSpace(data.AccessToken), nil
}
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
@@ -635,7 +717,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 := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile())
if err != nil {
return nil, err
}
@@ -654,7 +736,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 +754,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)
+173 -116
View File
@@ -18,6 +18,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
type oauthLoginFixture struct {
@@ -33,8 +35,79 @@ type oauthLoginFixture struct {
exchangeError atomic.Bool
}
type oauthLoginResult struct {
token *TokenData
err error
}
type oauthHTTPResult struct {
status int
body string
err error
}
const oauthTestWaitTimeout = 5 * time.Second
func isolateOAuthPersistence(t *testing.T) {
t.Helper()
t.Setenv(keychain.DisableKeychainEnv, "1")
cleanupKeychain(t)
}
func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult {
t.Helper()
ctx, cancel := context.WithCancel(parent)
done := make(chan oauthLoginResult, 1)
finished := make(chan struct{})
go func() {
defer close(finished)
token, err := f.provider.Login(ctx, true)
done <- oauthLoginResult{token: token, err: err}
}()
t.Cleanup(func() {
cancel()
select {
case <-finished:
case <-time.After(oauthTestWaitTimeout):
t.Errorf("OAuth Login goroutine did not stop after cancellation")
}
})
return done
}
func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth Login")
return oauthLoginResult{}
}
}
func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) {
t.Helper()
select {
case <-signal:
case result := <-done:
t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err)
case <-time.After(oauthTestWaitTimeout):
t.Fatalf("timed out waiting for OAuth %s", name)
}
}
func closeOAuthRelease(ch chan struct{}) {
select {
case <-ch:
default:
close(ch)
}
}
func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
t.Helper()
isolateOAuthPersistence(t)
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
@@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
}
}))
t.Cleanup(f.server.Close)
t.Cleanup(func() {
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
})
f.configDir = setupMCPConfigDir(t, f.server.URL)
oldClient := oauthHTTPClient
@@ -118,17 +195,25 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
func httpGetBody(t *testing.T, rawURL string) (int, string) {
t.Helper()
result := getHTTPBody(rawURL)
if result.err != nil {
t.Fatalf("GET %s: %v", rawURL, result.err)
}
return result.status, result.body
}
func getHTTPBody(rawURL string) oauthHTTPResult {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(rawURL)
if err != nil {
t.Fatalf("GET %s: %v", rawURL, err)
return oauthHTTPResult{err: err}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
return oauthHTTPResult{err: err}
}
return resp.StatusCode, string(data)
return oauthHTTPResult{status: resp.StatusCode, body: string(data)}
}
func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
@@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
loginDone := make(chan struct {
token *TokenData
err error
}, 1)
go func() {
token, err := f.provider.Login(context.Background(), true)
loginDone <- struct {
token *TokenData
err error
}{token, err}
}()
loginDone := startOAuthLogin(t, context.Background(), f)
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
_, body := httpGetBody(t, f.callbackBase+path)
@@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
t.Fatalf("success page = %q", body)
}
callbackDone := make(chan string, 1)
callbackDone := make(chan oauthHTTPResult, 1)
go func() {
_, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
callbackDone <- callbackBody
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good")
}()
<-f.exchangeEntered
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
if !strings.Contains(body, "正在处理授权") {
t.Fatalf("concurrent callback = %q", body)
}
close(f.exchangeRelease)
<-f.statusEntered
closeOAuthRelease(f.exchangeRelease)
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
if !strings.Contains(body, "<html") {
@@ -204,11 +278,16 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
t.Fatalf("auth enabled API = %q", body)
}
close(f.statusRelease)
if callbackBody := <-callbackDone; !strings.Contains(callbackBody, "<html") {
t.Fatalf("callback body = %q", callbackBody)
closeOAuthRelease(f.statusRelease)
select {
case callback := <-callbackDone:
if callback.err != nil || !strings.Contains(callback.body, "<html") {
t.Fatalf("callback body = %q, %v", callback.body, callback.err)
}
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
}
result := <-loginDone
result := awaitOAuthLogin(t, loginDone)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("Login = %#v, %v", result.token, result.err)
}
@@ -228,23 +307,20 @@ func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
t.Fatalf("missing callback = %d %q", status, body)
}
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("missing callback code did not fail login")
}
}
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
isolateOAuthPersistence(t)
var buf bytes.Buffer
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
@@ -311,6 +387,7 @@ func TestCrossPlatformCoverageOAuthLoginTimeoutAndServerError(t *testing.T) {
}
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
isolateOAuthPersistence(t)
dir := t.TempDir()
_ = DeleteTokenDataKeychain()
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
@@ -363,6 +440,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
}
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
isolateOAuthPersistence(t)
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
SetClientID("")
SetClientSecret("")
@@ -425,16 +503,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
for _, tt := range terminal {
t.Run(tt.name, func(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
close(f.exchangeRelease)
close(f.statusRelease)
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
f.provider.NoBrowser = false
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("denied login succeeded")
}
})
@@ -445,15 +519,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("channel-denied login succeeded")
}
})
@@ -463,15 +533,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("approval timeout login succeeded")
}
oauthApprovalTimeout = oldApproval
@@ -481,16 +547,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
if err := <-done; err != nil {
t.Fatalf("poll-enabled login failed: %v", err)
if result := awaitOAuthLogin(t, done); result.err != nil {
t.Fatalf("poll-enabled login failed: %v", result.err)
}
})
@@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
})
close(f.exchangeRelease)
close(f.statusRelease)
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(ctx, true)
done <- err
}()
done := startOAuthLogin(t, ctx, f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
cancel()
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("canceled pending login succeeded")
}
})
@@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
f.exchangeError.Store(true)
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
if !strings.Contains(body, "failed") {
t.Fatalf("exchange failure page = %q", body)
}
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("exchange failure login succeeded")
}
}
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
dir := t.TempDir()
@@ -640,7 +695,9 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
}
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
isolateOAuthPersistence(t)
oldLoad := oauthLoadToken
oldLoadLocked := oauthLoadTokenLocked
oldAcquire := oauthAcquireLock
oldMark := oauthMarkProfile
oldFetch := oauthFetchClientID
@@ -651,6 +708,7 @@ func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
oldSaveLocked := oauthSaveTokenLocked
t.Cleanup(func() {
oauthLoadToken = oldLoad
oauthLoadTokenLocked = oldLoadLocked
oauthAcquireLock = oldAcquire
oauthMarkProfile = oldMark
oauthFetchClientID = oldFetch
@@ -665,6 +723,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 }
@@ -795,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
oauthSuccessPause = 0
oauthSleep = func(time.Duration) {}
type loginResult struct {
token *TokenData
err error
}
startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult {
finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string {
t.Helper()
done := make(chan loginResult, 1)
bodyCh := make(chan oauthHTTPResult, 1)
go func() {
token, err := f.provider.Login(ctx, true)
done <- loginResult{token: token, err: err}
bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code))
}()
return done
}
finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string {
t.Helper()
bodyCh := make(chan string, 1)
go func() {
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code))
bodyCh <- body
}()
<-f.exchangeEntered
close(f.exchangeRelease)
return <-bodyCh
waitOAuthSignal(t, f.exchangeEntered, done, "token exchange")
closeOAuthRelease(f.exchangeRelease)
select {
case result := <-bodyCh:
if result.err != nil {
t.Fatalf("OAuth callback failed: %v", result.err)
}
return result.body
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
return ""
}
}
t.Run("switch organization and cached disabled pages", func(t *testing.T) {
@@ -830,8 +886,8 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
}
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
if body := finishExchange(t, f, "first"); !strings.Contains(body, "<html") {
done := startOAuthLogin(t, context.Background(), f)
if body := finishExchange(t, f, done, "first"); !strings.Contains(body, "<html") {
t.Fatalf("disabled callback body = %q", body)
}
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=first"); !strings.Contains(body, "<html") {
@@ -843,7 +899,7 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=second"); !strings.Contains(body, "<html") {
t.Fatalf("switched callback = %q", body)
}
result := <-done
result := awaitOAuthLogin(t, done)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("switched login = %#v %v", result.token, result.err)
}
@@ -857,15 +913,15 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) { return nil, fail }
ctx, cancel := context.WithCancel(context.Background())
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(ctx, f)
finishExchange(t, f, "errors")
done := startOAuthLogin(t, ctx, f)
finishExchange(t, f, done, "errors")
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
t.Fatalf("API error %s = %q", path, body)
}
}
cancel()
if result := <-done; !errors.Is(result.err, context.Canceled) {
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
t.Fatalf("canceled error login = %v", result.err)
}
})
@@ -882,14 +938,14 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
var output bytes.Buffer
f.provider.Output = &output
done := startLogin(ctx, f)
finishExchange(t, f, "apply")
done := startOAuthLogin(t, ctx, f)
finishExchange(t, f, done, "apply")
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
t.Fatalf("apply response = %q", body)
}
time.Sleep(20 * time.Millisecond)
cancel()
if result := <-done; !errors.Is(result.err, context.Canceled) {
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
t.Fatalf("canceled apply login = %v", result.err)
}
if !strings.Contains(output.String(), "Waiting for admin approval") && !strings.Contains(output.String(), "等待管理员审批中") {
@@ -903,9 +959,9 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
return &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}, nil
}
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
finishExchange(t, f, "enterprise")
if result := <-done; result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
done := startOAuthLogin(t, context.Background(), f)
finishExchange(t, f, done, "enterprise")
if result := awaitOAuthLogin(t, done); result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
t.Fatalf("enterprise denial = %v", result.err)
}
})
@@ -928,15 +984,16 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
fail := errors.New("save failure")
oauthSaveToken = func(string, *TokenData) error { return fail }
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
finishExchange(t, f, "save")
if result := <-done; !errors.Is(result.err, fail) {
done := startOAuthLogin(t, context.Background(), f)
finishExchange(t, f, done, "save")
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, fail) {
t.Fatalf("save failure login = %v", result.err)
}
})
}
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
isolateOAuthPersistence(t)
oldClient := oauthHTTPClient
oldRequest := oauthNewRequest
oldRetry := oauthRetryAfter
+60 -1
View File
@@ -288,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 {
@@ -311,6 +311,65 @@ 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" {
+1011 -208
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,510 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type rejectedTokenHookStore struct {
mu sync.Mutex
data TokenData
deletes int
}
func (s *rejectedTokenHookStore) load(string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return json.Marshal(s.data)
}
func (s *rejectedTokenHookStore) save(_ string, blob []byte) error {
var data TokenData
if err := json.Unmarshal(blob, &data); err != nil {
return err
}
s.mu.Lock()
s.data = data
s.mu.Unlock()
return nil
}
func (s *rejectedTokenHookStore) delete(string) error {
s.mu.Lock()
s.data = TokenData{}
s.deletes++
s.mu.Unlock()
return nil
}
func (s *rejectedTokenHookStore) snapshot() (TokenData, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data, s.deletes
}
func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore {
t.Helper()
store := &rejectedTokenHookStore{data: data}
previousHooks := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: store.load,
SaveToken: store.save,
DeleteToken: store.delete,
})
t.Cleanup(func() { edition.Override(previousHooks) })
return store
}
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
t.Helper()
resetRejectedTokenRefreshCoordinator(t)
previous := oauthRefreshToken
oauthRefreshToken = fn
t.Cleanup(func() { oauthRefreshToken = previous })
}
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
t.Helper()
reset := func() {
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall)
rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure)
rejectedTokenRefreshCoordinator.now = time.Now
rejectedTokenRefreshCoordinator.Unlock()
}
reset()
t.Cleanup(reset)
}
func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
rejectedTokenRefreshCoordinator.Lock()
call := rejectedTokenRefreshCoordinator.inFlight[key]
got := 0
if call != nil {
got = call.participants
}
rejectedTokenRefreshCoordinator.Unlock()
if got >= want {
return
}
if time.Now().After(deadline) {
t.Fatalf("refresh participants = %d, want %d", got, want)
}
time.Sleep(time.Millisecond)
}
}
func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
t.Helper()
previous := profilesAcquireDualLock
attempted := make(chan struct{}, 1)
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
attempted <- struct{}{}
return previous(ctx, configDir)
}
t.Cleanup(func() { profilesAcquireDualLock = previous })
return attempted
}
func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) {
t.Helper()
select {
case <-attempted:
case <-time.After(2 * time.Second):
t.Fatal("public opaque token mutation did not enter the Core dual lock")
}
}
func validRejectedTokenData(accessToken string) TokenData {
return TokenData{
AccessToken: accessToken,
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
Source: "mcp",
ClientID: "client-id",
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
var refreshCalls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
if refreshCalls.Add(1) == 1 {
close(started)
}
<-release
updated := *data
updated.AccessToken = "new-access"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
provider := NewOAuthProvider(t.TempDir(), nil)
const workers = 8
results := make(chan string, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
results <- token
errs <- err
}()
}
<-started
close(release)
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for token := range results {
if token != "new-access" {
t.Fatalf("token = %q, want new-access", token)
}
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls = %d, want 1", got)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "new-access" || deletes != 0 {
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
refreshErr := errors.New("temporary refresh failure")
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, refreshErr
})
_, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access")
if !errors.Is(err, refreshErr) {
t.Fatalf("error = %v, want refresh cause", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
previousProfile := RuntimeProfile()
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
refreshErr := errors.New("temporary refresh failure")
var refreshCalls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
baseNow := time.Now()
var nowNanos atomic.Int64
nowNanos.Store(baseNow.UnixNano())
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
call := refreshCalls.Add(1)
if call == 1 {
close(started)
<-release
return nil, refreshErr
}
updated := *data
updated.AccessToken = "recovered-access"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.now = func() time.Time {
return time.Unix(0, nowNanos.Load())
}
rejectedTokenRefreshCoordinator.Unlock()
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
const workers = 8
start := make(chan struct{})
ready := make(chan struct{}, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
ready <- struct{}{}
<-start
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
errs <- err
}()
}
for range workers {
<-ready
}
close(start)
<-started
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
waitForRejectedTokenRefreshParticipants(t, key, workers)
close(release)
wg.Wait()
close(errs)
for err := range errs {
if !errors.Is(err, refreshErr) {
t.Fatalf("shared refresh error = %v, want %v", err, refreshErr)
}
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls after concurrent failure = %d, want 1", got)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes)
}
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
t.Fatalf("cooldown error = %v, want %v", err, refreshErr)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls inside cooldown = %d, want 1", got)
}
nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano())
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
if err != nil || token != "recovered-access" {
t.Fatalf("refresh after cooldown = %q, %v", token, err)
}
if got := refreshCalls.Load(); got != 2 {
t.Fatalf("refresh calls after cooldown = %d, want 2", got)
}
stored, deletes = store.snapshot()
if stored.AccessToken != "recovered-access" || deletes != 0 {
t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
previousProfile := RuntimeProfile()
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
refreshErr := errors.New("temporary refresh failure")
var refreshCalls atomic.Int32
baseNow := time.Now()
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls.Add(1)
return nil, refreshErr
})
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow }
rejectedTokenRefreshCoordinator.Unlock()
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
t.Fatalf("initial refresh error = %v, want %v", err, refreshErr)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("initial refresh calls = %d, want 1", got)
}
store.mu.Lock()
store.data = validRejectedTokenData("externally-refreshed")
store.mu.Unlock()
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
if err != nil || token != "externally-refreshed" {
t.Fatalf("refresh after external publication = %q, %v", token, err)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("external publication triggered another exchange: calls=%d", got)
}
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
rejectedTokenRefreshCoordinator.Lock()
_, failurePresent := rejectedTokenRefreshCoordinator.failures[key]
rejectedTokenRefreshCoordinator.Unlock()
if failurePresent {
t.Fatal("old-token failure cache was not cleared after external publication")
}
}
func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
close(started)
<-release
updated := *data
updated.AccessToken = "refreshed-from-old"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
refreshResult := make(chan struct {
token string
err error
}, 1)
go func() {
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
refreshResult <- struct {
token string
err error
}{token: token, err: err}
}()
<-started
acquireAttempted := installProfilesAcquireProbe(t)
publishResult := make(chan error, 1)
go func() {
publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published")))
}()
waitForProfilesAcquire(t, acquireAttempted)
releaseOnce.Do(func() { close(release) })
refresh := <-refreshResult
if refresh.err != nil || refresh.token != "refreshed-from-old" {
t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err)
}
if err := <-publishResult; err != nil {
t.Fatalf("publish token: %v", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "login-published" || deletes != 0 {
t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) {
for _, tc := range []struct {
name string
logout func(string) error
}{
{name: "current profile", logout: func(configDir string) error {
return DeleteTokenDataForProfile(configDir, "")
}},
{name: "all profiles", logout: DeleteAllTokenData},
} {
t.Run(tc.name, func(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
close(started)
<-release
updated := *data
updated.AccessToken = "refreshed-before-logout"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
refreshResult := make(chan error, 1)
go func() {
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
refreshResult <- err
}()
<-started
acquireAttempted := installProfilesAcquireProbe(t)
logoutResult := make(chan error, 1)
go func() { logoutResult <- tc.logout(configDir) }()
waitForProfilesAcquire(t, acquireAttempted)
releaseOnce.Do(func() { close(release) })
if err := <-refreshResult; err != nil {
t.Fatalf("refresh: %v", err)
}
if err := <-logoutResult; err != nil {
t.Fatalf("logout: %v", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 {
t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes)
}
})
}
}
func ptrTokenData(data TokenData) *TokenData {
return &data
}
func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) {
data := validRejectedTokenData("expired-access")
data.ExpiresAt = time.Now().Add(-time.Hour)
store := installRejectedTokenHookStore(t, data)
var refreshCalls atomic.Int32
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) {
refreshCalls.Add(1)
updated := *current
updated.AccessToken = "proactively-refreshed"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background())
if err != nil || token != "proactively-refreshed" {
t.Fatalf("GetAccessToken() = %q, %v", token, err)
}
if refreshCalls.Load() != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load())
}
stored, deletes := store.snapshot()
if stored.AccessToken != "proactively-refreshed" || deletes != 0 {
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
}
}
+2 -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)
}
+11
View File
@@ -14,6 +14,7 @@
package auth
import (
"fmt"
"os"
"testing"
@@ -34,7 +35,17 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
}
code := m.Run()
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err)
if code == 0 {
code = 1
}
}
_ = os.RemoveAll(tmpDir)
os.Exit(code)
}
+722 -80
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,34 @@ 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"`
// Revision changes on every credential publication. Runtime token caches
// use it as a cheap cross-process invalidation signal without reading the
// platform keychain on every request.
Revision string `json:"revision,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,
Revision: uuid.NewString(),
}
data, _ := tokenJSONMarshalIndent(marker, "", " ")
if err := tokenMkdirAll(configDir, 0o700); err != nil {
return err
@@ -128,6 +164,44 @@ func WriteTokenMarker(configDir string) error {
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
}
// ReadTokenMarkerRevision returns the current credential publication revision.
// Existing markers without a revision remain readable, but callers must avoid
// caching them because they cannot prove that the credential is unchanged.
func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) {
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("read token marker: %w", err)
}
var marker TokenMarker
if err := json.Unmarshal(data, &marker); err != nil {
// The marker is only a cache-coherency hint. A malformed historical or
// externally modified marker must disable caching, not make an otherwise
// valid credential unusable.
return "", true, nil
}
return strings.TrimSpace(marker.Revision), true, nil
}
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) {
@@ -136,13 +210,10 @@ func DeleteTokenMarker(configDir string) error {
return nil
}
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to the default keychain-based storage.
// SaveTokenData persists TokenData under the auth dual lock. When an edition
// hook (SaveToken) is registered, the locked write delegates to that hook;
// otherwise it falls back to the default keychain-based storage.
func SaveTokenData(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return saveTokenViaHook(h, configDir, data)
}
return withProfilesLock(configDir, func() error {
return saveTokenDataLocked(configDir, data)
})
@@ -158,26 +229,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 +384,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 +427,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,9 +457,38 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
return data, 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.
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. Edition hooks and the default keychain
// path are both serialized with refresh through the auth dual lock.
func DeleteTokenData(configDir string) error {
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
}
@@ -267,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
if strings.TrimSpace(profile) != "" {
return fmt.Errorf("profile selection is not supported by the current auth backend")
}
return h.DeleteToken(configDir)
return withProfilesLock(configDir, func() error {
return h.DeleteToken(configDir)
})
}
return withProfilesLock(configDir, func() error {
return deleteTokenDataForProfileLocked(configDir, profile)
@@ -275,25 +510,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,21 +632,321 @@ 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 {
return h.DeleteToken(configDir)
return withProfilesLock(configDir, func() error {
return h.DeleteToken(configDir)
})
}
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 +959,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 +981,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 +1040,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
+18 -9
View File
@@ -13,7 +13,8 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -104,7 +105,8 @@
},
"examples": {
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -113,7 +115,8 @@
"candidates": [
{
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -227,7 +230,9 @@
},
"use_when": {
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -236,7 +241,9 @@
"candidates": [
{
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -262,7 +269,9 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
"event list": {
@@ -754,7 +763,7 @@
},
"use_when": {
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -763,7 +772,7 @@
"candidates": [
{
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -788,7 +797,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
"event status": {
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"coverage": {
"surface_products": 22,
@@ -12,7 +12,7 @@
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"unreviewed_skill_tools": 7
},
"products": {
@@ -870,20 +870,20 @@
]
},
"event": {
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
],
"field_provenance": {
"agent_summary": {
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true
@@ -910,7 +910,7 @@
},
"use_when": {
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -918,7 +918,7 @@
"candidates": [
{
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -938,7 +938,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
},
"live": {
+3 -59
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_files": 150,
"hint_files": 46,
@@ -36,7 +36,7 @@
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"unreviewed_skill_tools": 7
},
"source_products": [
@@ -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",
@@ -2866,62 +2866,6 @@
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "event schema user_im_message_receive_o2o",
"source": "skills/mono/references/products/event.md",
"line": 60,
"candidates": [
"event schema",
"event consume",
"event list"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_at -f ndjson",
"source": "skills/mono/references/products/event.md",
"line": 62,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_o2o",
"source": "skills/mono/references/products/event.md",
"line": 63,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_group",
"source": "skills/mono/references/products/event.md",
"line": 64,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "mail mailbox profile",
"source": "skills/mono/references/products/mail.md",
+119 -24
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:e4e7ed2cfc099d6e3893e9b059c0041c4ce0b8e103d52743e21b096d71564864",
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"catalog": {
"agent_metadata": {
"products_with_metadata": 22,
"source": "embedded-skill-metadata",
"source_hash": "sha256:13836c65abf8ff72941830fbef2bd55bd6874f65e4aa064aa04ba816d458b2ea",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"surface_products": 22,
"surface_tools": 572,
"tools_with_agent_summary": 572,
"tools_with_metadata": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"version": 1
},
"count": 22,
@@ -12113,7 +12113,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
@@ -12126,13 +12126,13 @@
"precedence": "reviewed_explicit",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
},
"avoid_when": {
"candidates": [
@@ -12159,7 +12159,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
}
],
@@ -12167,7 +12167,7 @@
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
}
},
@@ -12200,7 +12200,9 @@
"risk": "medium",
"title": "订阅事件流并输出到 stdout",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
{
@@ -12254,7 +12256,7 @@
"risk": "low",
"title": "显示事件 schema",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
{
@@ -12315,7 +12317,7 @@
}
],
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
},
{
@@ -290152,7 +290154,8 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -290279,7 +290282,8 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
]
}
],
@@ -290288,7 +290292,8 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
]
},
"idempotency": {
@@ -290416,7 +290421,9 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
}
],
@@ -290425,7 +290432,9 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
}
},
@@ -290435,7 +290444,7 @@
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
"is_alias": false,
"name": "consume",
"parameter_count": 26,
"parameter_count": 27,
"parameters": {
"compact": {
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
@@ -291626,6 +291635,90 @@
"required": false,
"type": "string"
},
"open-dingtalk-id": {
"description": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "openDingtalkId"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "openDingtalkId"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"property": "openDingtalkId",
"required": false,
"type": "string"
},
"output-dir": {
"description": "每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥",
"field_provenance": {
@@ -292587,7 +292680,7 @@
"type": "string"
},
"user": {
"description": "个人单聊对端 userId",
"description": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)",
"field_provenance": {
"description": {
"candidates": [
@@ -292595,7 +292688,7 @@
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "个人单聊对端 userId"
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
},
{
"precedence": "default",
@@ -292607,7 +292700,7 @@
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "个人单聊对端 userId"
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
},
"property": {
"candidates": [
@@ -292688,7 +292781,9 @@
"source": "reviewed_command_registry",
"title": "订阅事件流并输出到 stdout",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
"event.list": {
@@ -293649,7 +293744,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
}
],
@@ -293658,7 +293753,7 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
}
},
@@ -293779,7 +293874,7 @@
"source": "reviewed_command_registry",
"title": "显示事件 schema",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
"event.status": {
+86 -18
View File
@@ -630,36 +630,104 @@
"reason": "已审查的旧路径或跨产品文档前缀,固定映射到当前公开命令"
},
"event consume user_im_message_receive_at -f ndjson": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_o2o": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_user": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_group": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_o2o": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event status": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"event schema user_im_message_receive_user": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event stop": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
@@ -595,6 +595,41 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_user": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "alias",
"target": "event schema",
@@ -610,6 +645,41 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_user": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
"target": "aisearch person",
@@ -11,14 +11,17 @@
"event.consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"avoid_when": [
"只查历史聊天记录时用 chat 查询命令",
"只看事件目录/字段时用 event list / event schema"
],
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -56,7 +59,7 @@
"event.schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"avoid_when": [
"查询 CLI 命令参数契约时用顶层 dws schema",
@@ -122,9 +125,9 @@
},
"products": {
"event": {
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
+4 -2
View File
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
d.conns.Store("not-a-conn", struct{}{})
d.conns.Store(&queryConn{}, struct{}{})
d.consumerWG.Add(1)
d.shutdown()
acceptDone := make(chan struct{})
close(acceptDone)
d.shutdown(acceptDone)
d.consumerWG.Done()
d.shutdown()
d.shutdown(acceptDone)
pr, pw, err := os.Pipe()
if err != nil {
+13 -7
View File
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then close listener / drain consumers.
// goroutines wake up, then stop accepting connections before draining
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown()
<-acceptDone
d.shutdown(acceptDone)
<-idleDone
<-dropWarnDone
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
d.log.Warn("bus: accept error", "err", err)
continue
}
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
d.conns.Store(conn, struct{}{})
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
d.conns.Store(conn, struct{}{})
defer func() {
d.conns.Delete(conn)
conn.Close()
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for all per-connection goroutines to drain
// 5. lock + meta cleanup via Run's defers
func (d *daemon) shutdown() {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
+101 -29
View File
@@ -16,6 +16,8 @@ package consume
import (
"encoding/json"
"fmt"
"io"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
@@ -49,8 +51,8 @@ const (
// event, newline-terminated). Useful when piping into jq / a tool
// that wants the cloud payload verbatim without our envelope.
FormatRaw Format = "raw"
// FormatCompact runs the per-event-type compact processor (see
// registry.LookupProcessor) and emits one flattened JSON line.
// FormatCompact emits one compact JSON line. Personal streams use their
// configured business projection; other streams use the registry processor.
FormatCompact Format = "compact"
)
@@ -90,27 +92,103 @@ type Formatter interface {
Render(ev transport.Event) ([]byte, error)
}
// NewFormatter returns a Formatter for the given Format. Compact wraps
// registry.LookupProcessor so adding a new specialised compactor is just
// a registry-side change. Returns an error only if format is an internally
// unsupported value (defensive — NormalizeFormat guarantees the input is
// one of the constants).
func NewFormatter(format Format) (Formatter, error) {
// Projector maps a transport envelope to the public value rendered by
// structured formats. Returning a value together with an error means the
// value is a safe fallback and should still be emitted after a warning.
type Projector func(ev transport.Event) (any, error)
type formatterConfig struct {
projector Projector
warnings io.Writer
warnOnce sync.Once
}
// FormatterOption configures structured event rendering. Raw output always
// bypasses these options and preserves the source Data string.
type FormatterOption func(*formatterConfig)
func WithProjector(projector Projector) FormatterOption {
return func(cfg *formatterConfig) { cfg.projector = projector }
}
func WithProjectionWarnings(w io.Writer) FormatterOption {
return func(cfg *formatterConfig) {
if w != nil {
cfg.warnings = w
}
}
}
// NewFormatter returns a Formatter for the given Format. When no projector
// is configured, compact dispatches through registry.LookupProcessor. Returns
// an error only if format is internally unsupported.
func NewFormatter(format Format, opts ...FormatterOption) (Formatter, error) {
cfg := &formatterConfig{warnings: io.Discard}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
switch format {
case FormatNDJSON:
return &ndjsonFormatter{}, nil
return &structuredFormatter{config: cfg}, nil
case FormatJSON, FormatPretty:
return &prettyFormatter{}, nil
return &structuredFormatter{config: cfg, pretty: true}, nil
case FormatRaw:
return &rawFormatter{}, nil
case FormatCompact:
return &compactFormatter{}, nil
return &structuredFormatter{config: cfg, compact: true}, nil
default:
return nil, fmt.Errorf("consume: unsupported format %q", format)
}
}
// ndjsonFormatter encodes each Event as one compact JSON line + '\n'.
// structuredFormatter renders either the transport envelope or a projected
// business value. The same projector is used by ndjson/json/pretty/compact.
type structuredFormatter struct {
config *formatterConfig
pretty bool
compact bool
}
func (f *structuredFormatter) Render(ev transport.Event) ([]byte, error) {
value := any(ev)
if f.config.projector != nil {
projected, err := f.config.projector(ev)
if projected != nil {
value = projected
}
if err != nil {
f.config.warnOnce.Do(func() {
fmt.Fprintf(f.config.warnings,
"WARN: personal event output projection failed; using raw envelope event_id=%q event_type=%q: %v\n",
ev.EventID, ev.EventType, err)
})
}
} else if f.compact {
value = registry.LookupProcessor(ev.EventType)(ev)
}
var (
b []byte
err error
)
switch {
case f.pretty:
b, err = marshalEventIndent(value, "", " ")
case f.compact:
b, err = marshalCompact(value)
default:
b, err = marshalEvent(value)
}
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// The basic formatters remain available for focused encoder tests. The public
// construction path uses structuredFormatter so personal event projections are
// applied consistently across all structured formats.
type ndjsonFormatter struct{}
func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
@@ -121,9 +199,6 @@ func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
return append(b, '\n'), nil
}
// prettyFormatter encodes each Event as multi-line indented JSON + '\n'.
// json.MarshalIndent does not append a trailing newline; we add one so
// successive events are visually separated in the output.
type prettyFormatter struct{}
func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
@@ -134,6 +209,17 @@ func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
return append(b, '\n'), nil
}
type compactFormatter struct{}
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
value := registry.LookupProcessor(ev.EventType)(ev)
b, err := marshalCompact(value)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// rawFormatter writes ev.Data verbatim. If Data is already JSON it stays
// JSON; if it's some other string format it stays that. A trailing newline
// is appended so successive raw events are separable.
@@ -147,17 +233,3 @@ func (rawFormatter) Render(ev transport.Event) ([]byte, error) {
}
return out, nil
}
// compactFormatter dispatches to the registry per event_type and writes
// the flattened map as one compact JSON line + '\n'.
type compactFormatter struct{}
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
p := registry.LookupProcessor(ev.EventType)
v := p(ev)
b, err := marshalCompact(v)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
+80
View File
@@ -14,7 +14,9 @@
package consume
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
@@ -149,6 +151,84 @@ func TestCompactFormatter_DispatchesPerEventType(t *testing.T) {
}
}
func TestCrossPlatformCoverageStructuredFormatsUseProjector(t *testing.T) {
projector := Projector(func(ev transport.Event) (any, error) {
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
})
for _, format := range []Format{FormatNDJSON, FormatJSON, FormatPretty, FormatCompact} {
t.Run(string(format), func(t *testing.T) {
f, err := NewFormatter(format, WithProjector(projector))
if err != nil {
t.Fatal(err)
}
out, err := f.Render(transport.Event{EventType: "personal.test", Data: `{"raw":true}`})
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil {
t.Fatalf("output is not JSON: %v", err)
}
if got["type"] != "personal.test" || got["content"] != "hello" {
t.Fatalf("projected output = %#v", got)
}
if _, ok := got["data"]; ok {
t.Fatalf("projected output leaked transport envelope: %#v", got)
}
})
}
}
func TestCrossPlatformCoverageProjectionFailureWarnsAndEmitsFallback(t *testing.T) {
var warnings bytes.Buffer
f, err := NewFormatter(
FormatNDJSON,
WithProjector(func(ev transport.Event) (any, error) { return ev, errors.New("bad payload") }),
WithProjectionWarnings(&warnings),
)
if err != nil {
t.Fatal(err)
}
ev := transport.Event{EventID: "outer", EventType: "user_im_message_receive_o2o", Data: "not-json"}
for i := 0; i < 2; i++ {
out, err := f.Render(ev)
if err != nil {
t.Fatal(err)
}
var got transport.Event
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil || got.EventID != "outer" {
t.Fatalf("fallback output = %s, err = %v", out, err)
}
}
if count := strings.Count(warnings.String(), "WARN: personal event output projection failed"); count != 1 {
t.Fatalf("warning count = %d, output = %q", count, warnings.String())
}
for _, want := range []string{`event_id="outer"`, `event_type="user_im_message_receive_o2o"`, "bad payload"} {
if !strings.Contains(warnings.String(), want) {
t.Fatalf("warning missing %q: %q", want, warnings.String())
}
}
}
func TestCrossPlatformCoverageRawFormatBypassesProjector(t *testing.T) {
called := false
raw := `{"payload":{"uid":100001,"clientId":"internal-client","filterSubId":"internal-filter","bizid":"internal-bizid"}}`
f, err := NewFormatter(FormatRaw, WithProjector(func(transport.Event) (any, error) {
called = true
return map[string]any{"projected": true}, nil
}))
if err != nil {
t.Fatal(err)
}
out, err := f.Render(transport.Event{Data: raw})
if err != nil {
t.Fatal(err)
}
if called || string(out) != raw+"\n" {
t.Fatalf("raw output = %q, projector called = %v", out, called)
}
}
func TestNewFormatter_RejectsUnknown(t *testing.T) {
if _, err := NewFormatter(Format("nope")); err == nil {
t.Fatal("expected error for unknown format")
+2 -2
View File
@@ -65,8 +65,8 @@ func (p *Pipeline) Close() error { return p.sink.Close() }
// stdoutW is injected for tests (os.Stdout in production). When nil it
// defaults to io.Discard so a misconfigured pipeline never writes to
// the host process's actual stdout.
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer) (*Pipeline, error) {
fmter, err := NewFormatter(format)
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer, formatterOpts ...FormatterOption) (*Pipeline, error) {
fmter, err := NewFormatter(format, formatterOpts...)
if err != nil {
return nil, err
}
+42
View File
@@ -62,6 +62,48 @@ func TestPipeline_OutputDirFallback(t *testing.T) {
}
}
func TestCrossPlatformCoveragePipelineStdoutAndOutputDirUseSameProjection(t *testing.T) {
ev := transport.Event{
EventType: "personal.message",
EventID: "ev1",
ReceivedAtUnixMS: 100,
}
projector := WithProjector(func(transport.Event) (any, error) {
return map[string]any{"type": "personal.message", "content": "hello"}, nil
})
var stdout bytes.Buffer
stdoutPipeline, err := BuildPipeline(FormatNDJSON, "", nil, &stdout, projector)
if err != nil {
t.Fatal(err)
}
defer stdoutPipeline.Close()
if err := stdoutPipeline.Deliver(ev); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
filePipeline, err := BuildPipeline(FormatNDJSON, dir, nil, nil, projector)
if err != nil {
t.Fatal(err)
}
defer filePipeline.Close()
if err := filePipeline.Deliver(ev); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("ReadDir() entries = %d, err = %v", len(entries), err)
}
fileBody, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(stdout.Bytes(), fileBody) {
t.Fatalf("stdout = %q, file = %q", stdout.Bytes(), fileBody)
}
}
func TestPipeline_RouteWithStdoutFallback(t *testing.T) {
root := t.TempDir()
imDir := filepath.Join(root, "im")
+21 -2
View File
@@ -95,6 +95,14 @@ type Config struct {
OutputDir string
// Routes are pre-parsed --route specs. Empty = no routing.
Routes []Route
// Projector, when set, maps transport envelopes to the public value used
// by all structured formats. Raw format always bypasses it.
Projector Projector
// ReadySubscribeID identifies the personal subscription in the stable
// ready marker emitted after HelloAck. It is separate from SubscribeID
// because debug raw mode deliberately clears the latter's local filter.
ReadySubscribeID string
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
// graceful shutdown (reason: signal). This wires the AI-subprocess
@@ -175,7 +183,14 @@ func Run(ctx context.Context, cfg Config) error {
defer cancelRun()
ctx = runCtx
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
pipeline, err := BuildPipeline(
cfg.Format,
cfg.OutputDir,
cfg.Routes,
cfg.Stdout,
WithProjector(cfg.Projector),
WithProjectionWarnings(cfg.Stderr),
)
if err != nil {
return fmt.Errorf("consume: build pipeline: %w", err)
}
@@ -221,7 +236,11 @@ func Run(ctx context.Context, cfg Config) error {
// Contract: a fixed ready line on stderr BEFORE any stdout event.
// Parents block on stderr until this appears, then read stdout.
if cfg.EventKey != "" {
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d", cfg.EventKey, ack.BusPID)
if cfg.ReadySubscribeID != "" {
fmt.Fprintf(cfg.Stderr, " subscribe_id=%s", cfg.ReadySubscribeID)
}
fmt.Fprintln(cfg.Stderr)
} else {
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
}
+95 -14
View File
@@ -14,6 +14,7 @@
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -139,13 +140,15 @@ func TestRun_StdoutNDJSON(t *testing.T) {
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
EventTypes: []string{"im.*"},
MaxEvents: 2,
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
EventTypes: []string{"im.*"},
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -170,6 +173,10 @@ func TestRun_StdoutNDJSON(t *testing.T) {
if !strings.Contains(stderr.String(), "[event] ready ") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1 bus_pid=") ||
!strings.Contains(stderr.String(), " subscribe_id=sub-1") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
}
func TestRun_QuietSuppressesStderr(t *testing.T) {
@@ -184,13 +191,15 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
Quiet: true,
MaxEvents: 1,
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
Quiet: true,
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 1,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -203,6 +212,78 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
}
}
func TestRun_WritesFirstProjectedEventBeforeConsumeExits(t *testing.T) {
skipOnWindows(t)
dir, sock, cancelBus, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "personal.message", Data: `{"content":"hello"}`},
})
defer func() { cancelBus(); <-runDone }()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
ctx, cancelConsume := context.WithCancel(context.Background())
consumeDone := make(chan error, 1)
go func() {
consumeDone <- Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: writer,
Stderr: io.Discard,
Projector: func(ev transport.Event) (any, error) {
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
},
})
}()
go func() {
time.Sleep(150 * time.Millisecond)
close(trigger)
}()
lineCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
line, err := bufio.NewReader(reader).ReadString('\n')
if err != nil {
errCh <- err
return
}
lineCh <- line
}()
select {
case line := <-lineCh:
var got map[string]any
if err := json.Unmarshal([]byte(line), &got); err != nil || got["content"] != "hello" {
t.Fatalf("first line = %q, err = %v", line, err)
}
case err := <-errCh:
t.Fatalf("read first line: %v", err)
case <-time.After(2 * time.Second):
t.Fatal("first event was not readable while consume was running")
}
select {
case err := <-consumeDone:
t.Fatalf("consume exited before cancellation: %v", err)
default:
}
cancelConsume()
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("Run() after cancellation = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("consume did not exit after cancellation")
}
}
func TestRun_CtxCancelReturnsCleanly(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
+28 -9
View File
@@ -63,9 +63,10 @@ func (i Identity) Key() string {
}
type Client struct {
BaseURL string
HTTPClient *http.Client
Identity Identity
BaseURL string
HTTPClient *http.Client
Identity Identity
AccessTokenProvider func(context.Context) (string, error)
}
type CreateSubscriptionRequest struct {
@@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
if c == nil {
return errors.New("personal event: nil client")
}
if c.Identity.AccessToken == "" {
return errors.New("personal event: access token is required")
accessToken, err := c.resolveAccessToken(ctx)
if err != nil {
return err
}
u := strings.TrimRight(c.BaseURL, "/") + path
if len(q) > 0 {
@@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
if err != nil {
return fmt.Errorf("personal event: create request: %w", err)
}
c.decorate(req)
c.decorate(req, accessToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
@@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return json.Unmarshal(data, out)
}
func (c *Client) decorate(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken)
req.Header.Set("x-user-access-token", c.Identity.AccessToken)
func (c *Client) resolveAccessToken(ctx context.Context) (string, error) {
if c.AccessTokenProvider != nil {
token, err := c.AccessTokenProvider(ctx)
if err != nil {
return "", fmt.Errorf("personal event: resolve access token: %w", err)
}
if token = strings.TrimSpace(token); token != "" {
return token, nil
}
return "", errors.New("personal event: access token provider returned empty token")
}
if token := strings.TrimSpace(c.Identity.AccessToken); token != "" {
return token, nil
}
return "", errors.New("personal event: access token is required")
}
func (c *Client) decorate(req *http.Request, accessToken string) {
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-user-access-token", accessToken)
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
if c.Identity.CorpID != "" {
+71 -4
View File
@@ -21,6 +21,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
@@ -71,7 +72,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
RuleType: "singleChat",
Name: "test-o2o",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"},
@@ -93,7 +94,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil {
t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err)
}
if filterRule["targetUid"] != "507971" || filterRule["targetUidType"] != "staffId" {
if filterRule["targetUid"] != "test-user-001" || filterRule["targetUidType"] != "staffId" {
t.Fatalf("filterRule = %#v", filterRule)
}
if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" {
@@ -107,6 +108,72 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
}
}
func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {
tests := []struct {
name string
eventKey string
opts RuleOptions
wantRule map[string]any
}{
{"receive_o2o/staffId", EventSingleChat, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_o2o/openDingtalkId", EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"read_o2o/staffId", EventReadO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"read_o2o/openDingtalkId", EventReadO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"recall_o2o/staffId", EventRecallO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"recall_o2o/openDingtalkId", EventRecallO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"reaction_o2o/staffId", EventReactionO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"reaction_o2o/openDingtalkId", EventReactionO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_user/staffId", EventFromUser, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
var gotReq dwsCreateSubscriptionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Errorf("decode request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"sub-rule"}})
}))
defer srv.Close()
ruleType, ruleParam, err := BuildRuleParam(tt.eventKey, tt.opts)
if err != nil {
t.Fatal(err)
}
client := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if _, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: tt.eventKey,
RuleType: ruleType,
RuleParam: ruleParam,
}); err != nil {
t.Fatal(err)
}
if gotPath != "/subscription/user" || gotReq.EventKey != tt.eventKey {
t.Fatalf("path = %q, eventKey = %q", gotPath, gotReq.EventKey)
}
var gotRule map[string]any
if err := json.Unmarshal([]byte(gotReq.FilterRule), &gotRule); err != nil {
t.Fatalf("filterRule = %q: %v", gotReq.FilterRule, err)
}
if !reflect.DeepEqual(gotRule, tt.wantRule) {
t.Fatalf("filterRule = %#v, want %#v", gotRule, tt.wantRule)
}
if gotReq.Ext["ruleType"] != ruleType {
t.Fatalf("ext.ruleType = %#v, want %q", gotReq.Ext["ruleType"], ruleType)
}
})
}
}
func TestClientCreateSubscriptionObjectResponses(t *testing.T) {
cases := []map[string]any{
{"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1},
@@ -151,7 +218,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
EventKey: EventSingleChat,
RuleType: "singleChat",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
IdempotencyKey: "idem-1",
@@ -167,7 +234,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
EventSingleChat,
"filterRule",
"targetUid",
"507971",
"test-user-001",
"sub-1",
"req-ok",
} {
+449
View File
@@ -0,0 +1,449 @@
// 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 personal
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// MessageEventOutput is the stable business-facing output for personal
// message receive events. Schema output is generated from these tags so the
// documented fields cannot drift from the values written by consume.
type MessageEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Sender string `json:"sender" description:"发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"发送人开放 ID" format:"open_dingtalk_id"`
Content string `json:"content" description:"消息正文"`
CreateTime string `json:"create_time" description:"消息创建时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type ReadEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"被读取消息的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Reader string `json:"reader" description:"读取消息的用户展示名"`
ReaderOpenDingTalkID string `json:"reader_open_dingtalk_id" description:"读取消息的用户开放 ID" format:"open_dingtalk_id"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
ReadTime string `json:"read_time" description:"消息读取时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type RecallEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"被撤回消息的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Recaller string `json:"recaller" description:"撤回消息的用户展示名"`
RecallerOpenDingTalkID string `json:"recaller_open_dingtalk_id" description:"撤回消息的用户开放 ID" format:"open_dingtalk_id"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
RecallTime string `json:"recall_time" description:"消息撤回时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type ReactionEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"收到表情回应的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Operator string `json:"operator" description:"执行表情回应操作的用户展示名"`
OperatorOpenDingTalkID string `json:"operator_open_dingtalk_id" description:"执行表情回应操作的用户开放 ID" format:"open_dingtalk_id"`
ReactionName string `json:"reaction_name" description:"表情回应名称"`
ReactionText string `json:"reaction_text" description:"表情回应文本"`
OperationType string `json:"operation_type" description:"表情回应操作类型"`
OperationTime string `json:"operation_time" description:"表情回应操作时间"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type baseEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
}
type personalEventData struct {
EventID string `json:"eventId"`
EventKey string `json:"eventKey"`
OccurredAtMS int64 `json:"occurredAtMs"`
SubID string `json:"subId"`
Payload json.RawMessage `json:"payload"`
}
type personalMessagePayload struct {
EventTime int64 `json:"event_time"`
Body struct {
CreateTime string `json:"createTime"`
Sender string `json:"sender"`
OpenMessageID string `json:"openMessageId"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
OpenConversationID string `json:"openConversationId"`
Content string `json:"content"`
} `json:"body"`
}
type personalReadPayload struct {
EventTime int64 `json:"event_time"`
Body struct {
MessageID string `json:"openMessageId"`
ConversationID string `json:"openConversationId"`
Reader string `json:"reader"`
ReaderOpenDingTalkID string `json:"readerOpenDingTalkId"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
ReadTime string `json:"msgReadTime"`
} `json:"body"`
}
type personalRecallPayload struct {
EventTime int64 `json:"event_time"`
Body struct {
MessageID string `json:"openMessageId"`
ConversationID string `json:"openConversationId"`
Recaller string `json:"recaller"`
RecallerOpenDingTalkID string `json:"recallerOpenDingTalkId"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
RecallTime string `json:"msgRecallTime"`
} `json:"body"`
}
type personalReactionPayload struct {
EventTime int64 `json:"event_time"`
Body personalReactionBody `json:"body"`
}
type personalReactionBody struct {
MessageID string `json:"openSourceMessageId"`
ConversationID string `json:"openConversationId"`
Operator string `json:"oper"`
OperatorOpenDingTalkID string `json:"-"`
ReactionName string `json:"emotionName"`
ReactionText string `json:"emotionText"`
OperationType string `json:"operateType"`
OperationTime string `json:"operateTime"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
type bodyAlias personalReactionBody
var decoded bodyAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
if raw, ok := fields["operOpenDingtalkId"]; ok {
if err := json.Unmarshal(raw, &decoded.OperatorOpenDingTalkID); err != nil {
return fmt.Errorf("decode operOpenDingtalkId: %w", err)
}
}
*b = personalReactionBody(decoded)
return nil
}
// ProjectOutput converts the transport envelope into the stable personal
// event output. On malformed Data it returns the original envelope together
// with an error; the formatter logs the warning and still emits that envelope.
func ProjectOutput(ev transport.Event) (any, error) {
data, err := decodePersonalEventData(ev.Data)
if err != nil {
return ev, fmt.Errorf("decode personal event data: %w", err)
}
eventType := firstNonEmptyOutput(ev.EventType, data.EventKey)
eventID := firstNonEmptyOutput(data.EventID, ev.EventID)
timestamp := data.OccurredAtMS
if timestamp == 0 {
timestamp = ev.EventBornTime
}
subscribeID := firstNonEmptyOutput(ev.SubscribeID, data.SubID)
if isMessageReceiveEvent(eventType) {
var payload personalMessagePayload
if err := decodeRequiredPayload(data.Payload, &payload); err != nil {
return ev, fmt.Errorf("decode personal message payload: %w", err)
}
return MessageEventOutput{
Type: eventType,
EventID: eventID,
Timestamp: timestamp,
SubscribeID: subscribeID,
MessageID: payload.Body.OpenMessageID,
ConversationID: payload.Body.OpenConversationID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
Content: payload.Body.Content,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
}
base := baseEventOutput{
Type: eventType,
EventID: eventID,
Timestamp: timestamp,
SubscribeID: subscribeID,
}
switch {
case isReadEvent(eventType):
return projectReadEvent(ev, base, data.Payload)
case isRecallEvent(eventType):
return projectRecallEvent(ev, base, data.Payload)
case isReactionEvent(eventType):
return projectReactionEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
}
func projectReadEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalReadPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal read payload: %w", err)
}
return ReadEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Reader: payload.Body.Reader,
ReaderOpenDingTalkID: payload.Body.ReaderOpenDingTalkID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
ReadTime: payload.Body.ReadTime,
EventTime: payload.EventTime,
}, nil
}
func projectRecallEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalRecallPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal recall payload: %w", err)
}
return RecallEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Recaller: payload.Body.Recaller,
RecallerOpenDingTalkID: payload.Body.RecallerOpenDingTalkID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
RecallTime: payload.Body.RecallTime,
EventTime: payload.EventTime,
}, nil
}
func projectReactionEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalReactionPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal reaction payload: %w", err)
}
return ReactionEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Operator: payload.Body.Operator,
OperatorOpenDingTalkID: payload.Body.OperatorOpenDingTalkID,
ReactionName: payload.Body.ReactionName,
ReactionText: payload.Body.ReactionText,
OperationType: payload.Body.OperationType,
OperationTime: payload.Body.OperationTime,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
EventTime: payload.EventTime,
}, nil
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return fmt.Errorf("payload is missing")
}
var payloadObject map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &payloadObject); err != nil {
return err
}
if len(payloadObject) == 0 {
return fmt.Errorf("payload is empty")
}
body, ok := payloadObject["body"]
if !ok {
return fmt.Errorf("payload body is missing")
}
body = bytes.TrimSpace(body)
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
return fmt.Errorf("payload body is missing")
}
var bodyObject map[string]json.RawMessage
if err := json.Unmarshal(body, &bodyObject); err != nil {
return fmt.Errorf("decode payload body: %w", err)
}
if len(bodyObject) == 0 {
return fmt.Errorf("payload body is empty")
}
return json.Unmarshal(trimmed, target)
}
func decodePersonalEventData(raw string) (personalEventData, error) {
encoded := []byte(strings.TrimSpace(raw))
if len(encoded) == 0 {
return personalEventData{}, fmt.Errorf("empty data")
}
// Some gateways wrap the JSON object in one or more JSON strings. Peel
// those wrappers without changing the raw transport envelope.
for depth := 0; depth < 2; depth++ {
var quoted string
if err := json.Unmarshal(encoded, &quoted); err != nil {
break
}
encoded = []byte(strings.TrimSpace(quoted))
}
var data personalEventData
if err := json.Unmarshal(encoded, &data); err != nil {
return personalEventData{}, err
}
if data.EventKey == "" && data.EventID == "" && data.OccurredAtMS == 0 && data.SubID == "" && len(data.Payload) == 0 {
return personalEventData{}, fmt.Errorf("data is not a personal event object")
}
return data, nil
}
func outputSchema(eventKey string) map[string]any {
outputType := outputTypeForEvent(eventKey)
properties := make(map[string]any, outputType.NumField())
for i := 0; i < outputType.NumField(); i++ {
field := outputType.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
property := map[string]any{
"type": schemaType(field.Type),
}
if description := field.Tag.Get("description"); description != "" {
property["description"] = description
}
if format := field.Tag.Get("format"); format != "" {
property["format"] = format
}
if field.Tag.Get("additional_properties") == "true" {
property["additionalProperties"] = true
}
if name == "type" {
property["enum"] = []string{eventKey}
}
properties[name] = property
}
return map[string]any{
"type": "object",
"properties": properties,
}
}
func outputTypeForEvent(eventKey string) reflect.Type {
switch {
case isMessageReceiveEvent(eventKey):
return reflect.TypeOf(MessageEventOutput{})
case isReadEvent(eventKey):
return reflect.TypeOf(ReadEventOutput{})
case isRecallEvent(eventKey):
return reflect.TypeOf(RecallEventOutput{})
case isReactionEvent(eventKey):
return reflect.TypeOf(ReactionEventOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
}
func isReadEvent(eventKey string) bool {
return eventKey == EventReadO2O || eventKey == EventReadGroup
}
func isRecallEvent(eventKey string) bool {
return eventKey == EventRecallO2O || eventKey == EventRecallGroup
}
func isReactionEvent(eventKey string) bool {
return eventKey == EventReactionO2O || eventKey == EventReactionGroup
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Map, reflect.Struct:
return "object"
case reflect.Slice, reflect.Array:
return "array"
case reflect.Bool:
return "boolean"
default:
return "object"
}
}
func firstNonEmptyOutput(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+386
View File
@@ -0,0 +1,386 @@
// 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 personal
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func personalMessageData(eventKey string) string {
return fmt.Sprintf(`{
"eventId":"data-event",
"eventKey":%q,
"occurredAtMs":1783483236995,
"subId":"data-sub",
"payload":{
"body":{
"createTime":"2026-07-08 12:00:35",
"sender":"测试用户甲",
"openMessageId":"msg-1",
"senderOpenDingTalkId":"open-user-1",
"openConversationId":"cid-1",
"content":"在吗"
},
"event_time":1783483235983
}
}`, eventKey)
}
func personalReadData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"read-event",
"eventKey":%q,
"occurredAtMs":1784008412182,
"subId":"read-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgReadTime":"2026-07-14 13:53:31",
"openConversationId":%q,
"openMessageId":%q,
"reader":"测试用户乙",
"readerOpenDingTalkId":"reader-open-id",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008411652,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalRecallData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"recall-event",
"eventKey":%q,
"occurredAtMs":1784008592969,
"subId":"recall-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgRecallTime":"2026-07-14 13:56:32",
"openConversationId":%q,
"openMessageId":%q,
"recaller":"测试用户乙",
"recallerOpenDingTalkId":"recaller-open-id",
"sender":"测试用户乙",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008592766,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalReactionData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"reaction-event",
"eventKey":%q,
"occurredAtMs":1784008680072,
"subId":"reaction-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"emotionName":"微笑",
"emotionText":"微笑",
"openConversationId":%q,
"openSourceMessageId":%q,
"oper":"测试用户乙",
"operOpenDingtalkId":"operator-open-id",
"operateTime":"2026-07-14 13:57:59",
"operateType":"add",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008679217,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
Type: transport.FrameTypeEvent,
EventID: "outer-event",
EventBornTime: 11,
EventType: eventKey,
SubscribeID: "outer-sub",
Data: personalMessageData(eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(MessageEventOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T", projected)
}
want := MessageEventOutput{
Type: eventKey,
EventID: "data-event",
Timestamp: 1783483236995,
SubscribeID: "outer-sub",
MessageID: "msg-1",
ConversationID: "cid-1",
Sender: "测试用户甲",
SenderOpenDingTalkID: "open-user-1",
Content: "在吗",
CreateTime: "2026-07-08 12:00:35",
EventTime: 1783483235983,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", got, want)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(wrapped)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.Content != "在吗" || got.Type != EventSingleChat || got.SubscribeID != "data-sub" || got.EventID != "data-event" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputFallsBackToTransportFields(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 123,
EventType: EventSingleChat,
SubscribeID: "outer-sub",
Data: `{"payload":{"body":{"content":"hello"}}}`,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.EventID != "outer-event" || got.Timestamp != 123 || got.SubscribeID != "outer-sub" || got.Type != EventSingleChat {
t.Fatalf("fallback fields = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputReadEvents(t *testing.T) {
for _, eventKey := range []string{EventReadO2O, EventReadGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReadData(eventKey, "read-message", "read-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReadEventOutput{
Type: eventKey,
EventID: "read-event",
Timestamp: 1784008412182,
SubscribeID: "read-sub",
MessageID: "read-message",
ConversationID: "read-conversation",
Reader: "测试用户乙",
ReaderOpenDingTalkID: "reader-open-id",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
ReadTime: "2026-07-14 13:53:31",
EventTime: 1784008411652,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRecallEvents(t *testing.T) {
for _, eventKey := range []string{EventRecallO2O, EventRecallGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalRecallData(eventKey, "recall-message", "recall-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := RecallEventOutput{
Type: eventKey,
EventID: "recall-event",
Timestamp: 1784008592969,
SubscribeID: "recall-sub",
MessageID: "recall-message",
ConversationID: "recall-conversation",
Recaller: "测试用户乙",
RecallerOpenDingTalkID: "recaller-open-id",
Sender: "测试用户乙",
SenderOpenDingTalkID: "sender-open-id",
RecallTime: "2026-07-14 13:56:32",
EventTime: 1784008592766,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionEvents(t *testing.T) {
for _, eventKey := range []string{EventReactionO2O, EventReactionGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReactionData(eventKey, "reaction-message", "reaction-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReactionEventOutput{
Type: eventKey,
EventID: "reaction-event",
Timestamp: 1784008680072,
SubscribeID: "reaction-sub",
MessageID: "reaction-message",
ConversationID: "reaction-conversation",
Operator: "测试用户乙",
OperatorOpenDingTalkID: "operator-open-id",
ReactionName: "微笑",
ReactionText: "微笑",
OperationType: "add",
OperationTime: "2026-07-14 13:57:59",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
EventTime: 1784008679217,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
t.Run(legacyField, func(t *testing.T) {
data := strings.Replace(
personalReactionData(EventReactionO2O, "reaction-message", "reaction-conversation"),
"operOpenDingtalkId",
legacyField,
1,
)
projected, err := ProjectOutput(transport.Event{EventType: EventReactionO2O, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(ReactionEventOutput)
if got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
}
})
}
}
func assertNoInternalActionFields(t *testing.T, projected any) {
t.Helper()
raw, err := json.Marshal(projected)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if strings.Contains(string(raw), `"`+field+`"`) {
t.Fatalf("projected output leaked internal field %q: %s", field, raw)
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsEmptyPayloads(t *testing.T) {
eventKeys := []string{
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
}
payloads := []struct {
name string
json string
}{
{name: "missing", json: ""},
{name: "null", json: `,"payload":null`},
{name: "empty object", json: `,"payload":{}`},
{name: "missing body", json: `,"payload":{"event_time":1}`},
{name: "null body", json: `,"payload":{"body":null}`},
{name: "empty body", json: `,"payload":{"body":{}}`},
}
for _, eventKey := range eventKeys {
for _, payload := range payloads {
t.Run(eventKey+"/"+payload.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, payload.json),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want payload validation error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputMalformedDataReturnsRawEnvelope(t *testing.T) {
ev := transport.Event{EventID: "outer-event", Data: "not-json"}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want decode error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v", projected)
}
}
+204 -112
View File
@@ -23,10 +23,16 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
)
const (
@@ -35,32 +41,40 @@ const (
)
type Definition struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
Status string `json:"status"`
RequiredParams []string `json:"required_params"`
Auth map[string]any `json:"auth,omitempty"`
Public bool `json:"-"`
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
Status string `json:"status"`
RequiredParams []string `json:"required_params"`
Constraints *ParameterConstraints `json:"constraints,omitempty"`
Auth map[string]any `json:"auth,omitempty"`
Public bool `json:"-"`
}
type ParameterConstraints struct {
RequireOneOf [][]string `json:"require_one_of,omitempty"`
MutuallyExclusive [][]string `json:"mutually_exclusive,omitempty"`
}
type SchemaDocument struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
RequiredParams []string `json:"required_params"`
JQRootPath string `json:"jq_root_path"`
Schema map[string]any `json:"schema"`
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
RequiredParams []string `json:"required_params"`
Constraints *ParameterConstraints `json:"constraints,omitempty"`
JQRootPath string `json:"jq_root_path"`
Schema map[string]any `json:"schema"`
}
type RuleOptions struct {
RuleType string
UserID string
GroupID string
RuleType string
UserID string
OpenDingTalkID string
GroupID string
}
type SchemaPendingError struct {
@@ -90,7 +104,8 @@ var definitions = []Definition{
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: []string{"user"},
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
@@ -108,25 +123,105 @@ var definitions = []Definition{
{
EventKey: EventFromUser,
DisplayName: "指定发送人消息",
Description: "当前用户收到的特别关注用户的消息",
Description: "当前用户收到的指定用户发送的消息",
Category: "im",
RuleType: "sender",
Status: StatusEnabled,
RequiredParams: []string{"user"},
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: false,
Public: true,
},
{
EventKey: EventReadO2O,
DisplayName: "指定单聊消息已读",
Description: "当前用户在指定单聊中发送的消息被对方已读",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReadGroup,
DisplayName: "指定群消息已读",
Description: "当前用户在指定群聊中发送的消息被已读",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventRecallO2O,
DisplayName: "指定单聊消息撤回",
Description: "指定单聊中的消息被撤回",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventRecallGroup,
DisplayName: "指定群消息撤回",
Description: "指定群聊中的消息被撤回",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReactionO2O,
DisplayName: "指定单聊消息表情回应",
Description: "指定单聊中的消息收到表情回应(贴表情)",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReactionGroup,
DisplayName: "指定群消息表情回应",
Description: "指定群聊中的消息收到表情回应(贴表情)",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
return &ParameterConstraints{
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
}
}
func Definitions() []Definition {
out := append([]Definition(nil), definitions...)
out := make([]Definition, 0, len(definitions))
for _, def := range definitions {
out = append(out, cloneDefinition(def))
}
return out
}
func Lookup(eventKey string) (Definition, bool) {
for _, def := range definitions {
if def.EventKey == eventKey {
return def, true
return cloneDefinition(def), true
}
}
return Definition{}, false
@@ -157,7 +252,7 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
if !includePending && def.Status == StatusPending {
continue
}
out = append(out, def)
out = append(out, cloneDefinition(def))
}
return out
}
@@ -172,11 +267,46 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
Category: def.Category,
RuleType: def.RuleType,
RequiredParams: requiredParams,
JQRootPath: ".data | fromjson",
Schema: personalMessageSchema(def.EventKey),
Constraints: cloneParameterConstraints(def.Constraints),
JQRootPath: ".",
Schema: outputSchema(def.EventKey),
}
}
func cloneDefinition(def Definition) Definition {
def.RequiredParams = append([]string(nil), def.RequiredParams...)
def.Constraints = cloneParameterConstraints(def.Constraints)
if def.Auth != nil {
def.Auth = cloneMap(def.Auth)
}
return def
}
func cloneParameterConstraints(in *ParameterConstraints) *ParameterConstraints {
if in == nil {
return nil
}
cloneGroups := func(groups [][]string) [][]string {
out := make([][]string, len(groups))
for i, group := range groups {
out[i] = append([]string(nil), group...)
}
return out
}
return &ParameterConstraints{
RequireOneOf: cloneGroups(in.RequireOneOf),
MutuallyExclusive: cloneGroups(in.MutuallyExclusive),
}
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, ruleParam map[string]any, err error) {
def, ok := Lookup(eventKey)
if !ok {
@@ -189,44 +319,33 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
return "", nil, &SchemaPendingError{EventKey: eventKey}
}
userID := strings.TrimSpace(opts.UserID)
openDingTalkID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
switch def.RuleType {
case "at":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
return "", nil, fmt.Errorf("--user is not supported for %s", eventKey)
}
if openDingTalkID != "" {
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s", eventKey)
}
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
return "", nil, fmt.Errorf("--group is not supported for %s", eventKey)
}
return def.RuleType, map[string]any{}, nil
case "singleChat":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
case "sender":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
case "group":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
return "", nil, fmt.Errorf("--user is not supported for %s; use --group", eventKey)
}
if openDingTalkID != "" {
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s; use --group", eventKey)
}
if groupID == "" {
return "", nil, fmt.Errorf("--group is required")
return "", nil, fmt.Errorf("--group is required for %s", eventKey)
}
return def.RuleType, map[string]any{
"openConversationId": groupID,
@@ -236,6 +355,28 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
}
}
func buildTargetUIDRuleParam(ruleType, eventKey, userID, openDingTalkID, groupID string) (string, map[string]any, error) {
if groupID != "" {
return "", nil, fmt.Errorf("--group is not supported for %s; use --user or --open-dingtalk-id", eventKey)
}
if userID != "" && openDingTalkID != "" {
return "", nil, fmt.Errorf("--user and --open-dingtalk-id are mutually exclusive for %s", eventKey)
}
if userID == "" && openDingTalkID == "" {
return "", nil, fmt.Errorf("one of --user or --open-dingtalk-id is required for %s", eventKey)
}
if openDingTalkID != "" {
return ruleType, map[string]any{
"targetUid": openDingTalkID,
"targetUidType": "openDingtalkId",
}, nil
}
return ruleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
}
func BuildFilter(filterJSON string, queryCSV string) (any, string, error) {
var parts []any
filterJSON = strings.TrimSpace(filterJSON)
@@ -337,61 +478,12 @@ func normalizeFilterAliases(v any) any {
}
}
func personalMessageSchema(eventKey string) map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"type": map[string]any{
"type": "string",
"description": "事件类型,固定为当前 event_key",
"enum": []string{eventKey},
},
"event_id": map[string]any{
"type": "string",
"description": "事件 ID,可用于去重",
},
"timestamp": map[string]any{
"type": "integer",
"description": "事件发生时间戳,对应 occurredAtMs",
"format": "timestamp_ms",
},
"subscribe_id": map[string]any{
"type": "string",
"description": "订阅 ID,对应 subId",
},
"message_id": map[string]any{
"type": "string",
"description": "开放消息 ID,对应 payload.body.openMessageId",
"format": "open_message_id",
},
"conversation_id": map[string]any{
"type": "string",
"description": "会话 ID,对应 payload.body.openConversationId",
"format": "open_conversation_id",
},
"sender": map[string]any{
"type": "string",
"description": "发送人展示名,对应 payload.body.sender",
},
"sender_open_dingtalk_id": map[string]any{
"type": "string",
"description": "发送人开放 ID,对应 payload.body.senderOpenDingTalkId",
"format": "open_dingtalk_id",
},
"content": map[string]any{
"type": "string",
"description": "消息正文,对应 payload.body.content",
},
"create_time": map[string]any{
"type": "string",
"description": "消息创建时间,对应 payload.body.createTime",
},
"event_time": map[string]any{
"type": "integer",
"description": "消息事件时间戳,对应 payload.event_time",
"format": "timestamp_ms",
},
},
func isMessageReceiveEvent(eventKey string) bool {
switch eventKey {
case EventMention, EventSingleChat, EventInChat, EventFromUser:
return true
default:
return false
}
}
+263 -15
View File
@@ -33,18 +33,25 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
func TestEventFromUserRemainsInternalButNotPublic(t *testing.T) {
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed, want internal definition retained", EventFromUser)
t.Fatalf("Lookup(%q) failed", EventFromUser)
}
if IsPublic(EventFromUser) {
t.Fatalf("IsPublic(%q) = true, want hidden", EventFromUser)
if !IsPublic(EventFromUser) {
t.Fatalf("IsPublic(%q) = false, want public", EventFromUser)
}
}
@@ -54,6 +61,16 @@ func TestLegacyEventKeysAreUnknown(t *testing.T) {
"im_message_receive_o2o",
"im_message_receive_group",
"im_message_receive_user",
"im_message_read_o2o",
"im_message_read_group",
"im_message_recall_o2o",
"im_message_recall_group",
"im_message_emotion_o2o",
"im_message_emotion_group",
"im_message_reaction_o2o",
"im_message_reaction_group",
"user_im_message_emotion_o2o",
"user_im_message_emotion_group",
}
for _, key := range legacyKeys {
if _, ok := Lookup(key); ok {
@@ -140,8 +157,8 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
}
}
if doc.JQRootPath != ".data | fromjson" {
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
if doc.RequiredParams == nil {
t.Fatalf("required_params = nil, want empty slice")
@@ -150,13 +167,151 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
"create_time", "event_time",
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d DTO fields", props, len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
})
}
}
func TestTargetUIDEventSchemasRequireUserOrOpenDingTalkID(t *testing.T) {
wantConstraints := &ParameterConstraints{
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
}
for _, eventKey := range []string{
EventSingleChat,
EventFromUser,
EventReadO2O,
EventRecallO2O,
EventReactionO2O,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
if len(def.RequiredParams) != 0 {
t.Fatalf("required_params = %#v, want no unconditional parameters", def.RequiredParams)
}
if !reflect.DeepEqual(def.Constraints, wantConstraints) {
t.Fatalf("constraints = %#v, want %#v", def.Constraints, wantConstraints)
}
doc := BuildSchemaDocument(def)
if !reflect.DeepEqual(doc.Constraints, wantConstraints) {
t.Fatalf("schema constraints = %#v, want %#v", doc.Constraints, wantConstraints)
}
})
}
mention, _ := Lookup(EventMention)
if len(mention.RequiredParams) != 0 {
t.Fatalf("mention required_params = %#v, want none", mention.RequiredParams)
}
if mention.Constraints != nil {
t.Fatalf("mention constraints = %#v, want none", mention.Constraints)
}
group, _ := Lookup(EventInChat)
if want := []string{"group"}; !reflect.DeepEqual(group.RequiredParams, want) {
t.Fatalf("group required_params = %#v, want %#v", group.RequiredParams, want)
}
if group.Constraints != nil {
t.Fatalf("group constraints = %#v, want none", group.Constraints)
}
}
func TestDefinitionCopiesDoNotMutateRegistryConstraints(t *testing.T) {
def, ok := Lookup(EventSingleChat)
if !ok {
t.Fatalf("Lookup(%q) failed", EventSingleChat)
}
def.Constraints.RequireOneOf[0][0] = "mutated"
again, _ := Lookup(EventSingleChat)
if got := again.Constraints.RequireOneOf[0][0]; got != "user" {
t.Fatalf("registry constraint mutated through lookup copy: %q", got)
}
}
func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
tests := []struct {
name string
eventKeys []string
properties []string
}{
{
name: "read",
eventKeys: []string{EventReadO2O, EventReadGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "read_time", "event_time",
},
},
{
name: "recall",
eventKeys: []string{EventRecallO2O, EventRecallGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "recall_time", "event_time",
},
},
{
name: "reaction",
eventKeys: []string{EventReactionO2O, EventReactionGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
"reaction_text", "operation_type", "operation_time", "sender",
"sender_open_dingtalk_id", "event_time",
},
},
}
for _, tt := range tests {
for _, eventKey := range tt.eventKeys {
t.Run(tt.name+"/"+eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocument(def)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v", doc.Schema["properties"])
}
if len(props) != len(tt.properties) {
t.Fatalf("schema properties = %#v, want exactly %d DTO fields", props, len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if _, ok := props[internal]; ok {
t.Fatalf("schema exposed internal property %q", internal)
}
}
})
}
}
}
func TestBuildRuleParamMention(t *testing.T) {
rule, param, err := BuildRuleParam(EventMention, RuleOptions{})
if err != nil {
@@ -172,8 +327,8 @@ func TestBuildRuleParamMention(t *testing.T) {
func TestBuildRuleParamSingleChatRequiresPeer(t *testing.T) {
_, _, err := BuildRuleParam(EventSingleChat, RuleOptions{})
if err == nil || !strings.Contains(err.Error(), "--user is required") {
t.Fatalf("error = %v, want user requirement", err)
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
t.Fatalf("error = %v, want target identity requirement", err)
}
}
@@ -190,9 +345,22 @@ func TestBuildRuleParamSingleChatUserIDMapsToStaffID(t *testing.T) {
}
}
func TestBuildRuleParamSingleChatOpenDingTalkID(t *testing.T) {
rule, param, err := BuildRuleParam(EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatalf("BuildRuleParam() error = %v", err)
}
if rule != "singleChat" {
t.Fatalf("rule = %q, want singleChat", rule)
}
if param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
t.Fatalf("param = %#v", param)
}
}
func TestBuildRuleParamSender(t *testing.T) {
_, _, err := BuildRuleParam(EventFromUser, RuleOptions{})
if err == nil || !strings.Contains(err.Error(), "--user is required") {
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
t.Fatalf("error = %v, want sender requirement", err)
}
@@ -206,6 +374,14 @@ func TestBuildRuleParamSender(t *testing.T) {
if param["targetUidType"] != "staffId" || param["targetUid"] != "staff-1" {
t.Fatalf("param = %#v", param)
}
rule, param, err = BuildRuleParam(EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatalf("BuildRuleParam(openDingtalkId) error = %v", err)
}
if rule != "sender" || param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
}
}
func TestBuildRuleParamGroup(t *testing.T) {
@@ -226,16 +402,73 @@ func TestBuildRuleParamGroup(t *testing.T) {
}
}
func TestBuildRuleParamActionEvents(t *testing.T) {
for _, eventKey := range []string{EventReadO2O, EventRecallO2O, EventReactionO2O} {
t.Run(eventKey, func(t *testing.T) {
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+eventKey) {
t.Fatalf("missing target identity error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+eventKey) {
t.Fatalf("wrong group error = %v", err)
}
rule, param, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"})
if err != nil {
t.Fatal(err)
}
if rule != "singleChat" || param["targetUid"] != "staff-1" || param["targetUidType"] != "staffId" {
t.Fatalf("rule = %q, param = %#v", rule, param)
}
rule, param, err = BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatal(err)
}
if rule != "singleChat" || param["targetUid"] != "open-user-1" || param["targetUidType"] != "openDingtalkId" {
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
}
})
}
for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventReactionGroup} {
t.Run(eventKey, func(t *testing.T) {
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("missing group error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
t.Fatalf("wrong user error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+eventKey) {
t.Fatalf("wrong openDingtalkId error = %v", err)
}
rule, param, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"})
if err != nil {
t.Fatal(err)
}
if rule != "group" || param["openConversationId"] != "cid-1" {
t.Fatalf("rule = %q, param = %#v", rule, param)
}
})
}
}
func TestBuildRuleParamRejectsWrongScopedFlags(t *testing.T) {
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventMention) {
t.Fatalf("mention with user error = %v, want unsupported user", err)
}
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is only supported") {
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+EventSingleChat) {
t.Fatalf("singleChat with group error = %v, want unsupported group", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+EventSingleChat) {
t.Fatalf("singleChat with both identities error = %v, want mutually exclusive", err)
}
if _, _, err := BuildRuleParam(EventMention, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventMention) {
t.Fatalf("mention with openDingtalkId error = %v, want unsupported openDingtalkId", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventInChat) {
t.Fatalf("group with user error = %v, want unsupported user", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{OpenDingTalkID: "open-user-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventInChat) {
t.Fatalf("group with openDingtalkId error = %v, want unsupported openDingtalkId", err)
}
}
func TestBuildFilterQueryAndJSON(t *testing.T) {
@@ -262,10 +495,25 @@ func TestBuildFilterQueryAndJSON(t *testing.T) {
func TestIdempotencyKeyUsesLocalIdentityKey(t *testing.T) {
left := Identity{LocalSubject: "refresh:left", ClientID: "client-1", SourceID: "open"}
right := Identity{LocalSubject: "refresh:right", ClientID: "client-1", SourceID: "open"}
ruleParam := map[string]any{"targetUid": "507971", "targetUidType": "staffId"}
ruleParam := map[string]any{"targetUid": "test-user-001", "targetUidType": "staffId"}
leftKey := IdempotencyKey(left, EventSingleChat, "singleChat", ruleParam, "")
rightKey := IdempotencyKey(right, EventSingleChat, "singleChat", ruleParam, "")
if leftKey == rightKey {
t.Fatalf("idempotency key collapsed for different local subjects: %s", leftKey)
}
}
func TestIdempotencyKeySeparatesTargetUIDTypes(t *testing.T) {
identity := Identity{LocalSubject: "refresh:subject", ClientID: "client-1", SourceID: "open"}
staffIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
"targetUid": "same-value",
"targetUidType": "staffId",
}, "")
openIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
"targetUid": "same-value",
"targetUidType": "openDingtalkId",
}, "")
if staffIDKey == openIDKey {
t.Fatalf("idempotency key collapsed for different targetUidType: %s", staffIDKey)
}
}
@@ -0,0 +1,56 @@
package personal
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
type accessTokenRoundTripper func(*http.Request) (*http.Response, error)
func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) {
tokens := []string{"token-a", "token-b"}
calls := 0
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
client.AccessTokenProvider = func(context.Context) (string, error) {
token := tokens[calls]
calls++
return token, nil
}
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) {
want := tokens[calls-1]
if got := req.Header.Get("Authorization"); got != "Bearer "+want {
t.Fatalf("Authorization = %q, want Bearer %s", got, want)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil
})}
for range 2 {
if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil {
t.Fatal(err)
}
}
if calls != 2 {
t.Fatalf("provider calls = %d, want 2", calls)
}
}
func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) {
want := errors.New("keychain failed")
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
client.AccessTokenProvider = func(context.Context) (string, error) { return "", want }
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) {
t.Fatal("HTTP must not run after token provider failure")
return nil, nil
})}
_, err := client.ListSubscriptions(context.Background(), ListOptions{})
if !errors.Is(err, want) {
t.Fatalf("error = %v, want %v", err, want)
}
}
+39 -15
View File
@@ -41,19 +41,22 @@ const (
)
type PersonalConfig struct {
AccessToken string
ClientID string
ClientSecret string
SourceID string
TicketURL string
TicketMode string
HTTPClient *http.Client
WebSocketDialer *websocket.Dialer
Now func() time.Time
ReconnectMin time.Duration
ReconnectMax time.Duration
AccessToken string
AccessTokenProvider AccessTokenProvider
ClientID string
ClientSecret string
SourceID string
TicketURL string
TicketMode string
HTTPClient *http.Client
WebSocketDialer *websocket.Dialer
Now func() time.Time
ReconnectMin time.Duration
ReconnectMax time.Duration
}
type AccessTokenProvider func(context.Context) (string, error)
type PersonalSource struct {
cfg PersonalConfig
machine *Machine
@@ -73,8 +76,8 @@ type ticketResponse struct {
}
func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) {
if strings.TrimSpace(cfg.AccessToken) == "" {
return nil, errors.New("personal source: AccessToken is required")
if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" {
return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return nil, errors.New("personal source: ClientID is required")
@@ -192,6 +195,10 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) (
}
func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) {
accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source")
if err != nil {
return nil, err
}
body := map[string]any{
"sourceId": s.cfg.SourceID,
"mode": s.cfg.TicketMode,
@@ -207,8 +214,8 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("x-user-access-token", s.cfg.AccessToken)
req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken)
req.Header.Set("x-user-access-token", accessToken)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID)
req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID)
@@ -238,6 +245,23 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
return ticket, nil
}
func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) {
if provider != nil {
token, err := provider(ctx)
if err != nil {
return "", fmt.Errorf("%s: resolve access token: %w", component, err)
}
if token = strings.TrimSpace(token); token != "" {
return token, nil
}
return "", fmt.Errorf("%s: access token provider returned empty token", component)
}
if token := strings.TrimSpace(fallback); token != "" {
return token, nil
}
return "", fmt.Errorf("%s: access token is required", component)
}
func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error {
df, err := payload.DecodeDataFrame(data)
if err != nil {
+76
View File
@@ -615,6 +615,82 @@ func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) {
}
}
func TestPersonalSourceActionEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_reaction_group"
subscribeID = "sub-reaction-group"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_reaction_group","subId":"sub-reaction-group","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action event")
}
}
func TestPersonalSourceSenderEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_receive_user"
subscribeID = "sub-receive-user"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_receive_user","subId":"sub-receive-user","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for sender event")
}
}
func personalSourceForRawEventTests() *PersonalSource {
return &PersonalSource{cfg: PersonalConfig{
SourceID: "fallback_source",

Some files were not shown because too many files have changed in this diff Show More