Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8332b3eeb8 | ||
|
|
66bb86cc9e | ||
|
|
3e4886fc71 | ||
|
|
72cb8f188e | ||
|
|
2f8614aa1f | ||
|
|
0e60d09980 | ||
|
|
4d182dea45 | ||
|
|
f56d3263e8 | ||
|
|
22c94900d7 | ||
|
|
0871c5d88c | ||
|
|
15a15a1c12 | ||
|
|
0cc049eaa1 | ||
|
|
dd2d91ae7e | ||
|
|
98309fa239 | ||
|
|
b4e92f4e65 | ||
|
|
b34bbc9aa0 | ||
|
|
40444a6f78 | ||
|
|
97248bf7c2 | ||
|
|
fd6b3be046 | ||
|
|
835c9229fd | ||
|
|
ea7d8cc666 | ||
|
|
d2e36a76e2 | ||
|
|
52cf261000 | ||
|
|
8dac7d5fa5 | ||
|
|
4543e9935c | ||
|
|
e79c3ea5b9 | ||
|
|
ad2ba15a45 | ||
|
|
74350b3c7b | ||
|
|
02f66df599 | ||
|
|
883f082425 | ||
|
|
b1e7b43f85 | ||
|
|
571a096004 | ||
|
|
0d3fef0037 | ||
|
|
72934ddc39 | ||
|
|
eaf53bc2d2 | ||
|
|
3e148c6745 | ||
|
|
07bc528c6c | ||
|
|
7ee87d93ee | ||
|
|
7b77b4e615 | ||
|
|
5dcf95ce07 | ||
|
|
e70b21ae8a | ||
|
|
897eb6515b | ||
|
|
ad0582ea48 | ||
|
|
f9443af460 | ||
|
|
876afcddfb | ||
|
|
c771d48d6c | ||
|
|
9e48ef759f | ||
|
|
9fb2b76f9a | ||
|
|
228c62bc0f | ||
|
|
321514ad99 | ||
|
|
883f397554 | ||
|
|
276d5bcd73 | ||
|
|
8321f1ffea | ||
|
|
888e4432a3 | ||
|
|
cb200af3b2 | ||
|
|
cf5b76de07 | ||
|
|
3832e7f5e4 | ||
|
|
71f90ee45f | ||
|
|
423e16ced0 | ||
|
|
0d175c4d53 | ||
|
|
a5a6a0f2ce | ||
|
|
c1a4bd6781 | ||
|
|
02817bc043 | ||
|
|
b08f0f77e3 | ||
|
|
6d7cc41284 | ||
|
|
9f76c1844a | ||
|
|
857d9b8c1b | ||
|
|
5bdaad092a | ||
|
|
55cf6cfb71 | ||
|
|
a7fdcea086 | ||
|
|
1bc17bc4bd | ||
|
|
9fc63b6405 | ||
|
|
3233e1fe93 | ||
|
|
f7e61feacf | ||
|
|
cdc3fbe328 | ||
|
|
b4ea1f168d | ||
|
|
b03017997d | ||
|
|
902e084d8a | ||
|
|
ccb69f93b8 | ||
|
|
58b4d6f9f4 | ||
|
|
a3478ad587 | ||
|
|
5d1092da73 | ||
|
|
46fe02f72c | ||
|
|
2dba64b880 | ||
|
|
e564c8d923 | ||
|
|
2e7e6a1010 | ||
|
|
f88bc32259 | ||
|
|
f3cce5f49b | ||
|
|
2e389fe27d | ||
|
|
dd112a845e | ||
|
|
c0cb81c12b | ||
|
|
2b76047164 | ||
|
|
241444e992 | ||
|
|
309f833f15 | ||
|
|
115cce7308 |
@@ -1,4 +0,0 @@
|
||||
# Default code owners for all files
|
||||
# These users will be automatically requested for review on PRs.
|
||||
|
||||
* @DingTalk-Real-AI/cli-maintainers
|
||||
@@ -3,22 +3,41 @@
|
||||
- What changed?
|
||||
- Why is this change needed?
|
||||
|
||||
## Risk tier
|
||||
|
||||
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
|
||||
packaging, or interface behavior changed
|
||||
- [ ] Standard: ordinary implementation change with a stable package graph
|
||||
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
|
||||
platform, auth/keychain, installer, packaging, release, transport, recovery,
|
||||
or another fail-closed infrastructure change
|
||||
|
||||
## 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.
|
||||
Record the smallest targeted evidence that proves the changed behavior. Do not
|
||||
repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
- [ ] Exact in-place `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`
|
||||
- [ ] `make policy`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
`N/A`)
|
||||
- [ ] Full local suite run because the change is high-risk (optional for other
|
||||
tiers; record command/result or `N/A`)
|
||||
- [ ] `./scripts/policy/check-generated-drift.sh`
|
||||
(when generator inputs or generated artifacts may change)
|
||||
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
|
||||
- [ ] `./scripts/release/verify-package-managers.sh`
|
||||
(after `make package`, if packaging or installer surfaces changed)
|
||||
|
||||
## Notes
|
||||
|
||||
- Any risks, follow-up work, or intentional scope cuts
|
||||
|
||||
The repository automatically requests one eligible peer reviewer, including
|
||||
after a new head push when another review is needed. Once the latest push has
|
||||
peer approval and all nine required checks are current and green, auto-merge
|
||||
completes the PR; authors do not need to coordinate a separate routine merge.
|
||||
|
||||
+605
-108
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,11 @@ on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sync_release_version:
|
||||
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
@@ -23,7 +28,6 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
@@ -76,3 +80,42 @@ jobs:
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
|
||||
- name: Download GitHub Release assets
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "📥 Downloading release assets for ${VERSION}"
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist/
|
||||
|
||||
- name: Verify release artifacts
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
run: |
|
||||
set -eu
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
./scripts/release/verify-release-artifacts.sh "$VERSION"
|
||||
|
||||
- name: Sync release assets to Gitee
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
run: |
|
||||
set -eu
|
||||
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
./scripts/release/sync-to-gitee.sh
|
||||
|
||||
+709
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
name: Reviewer routing
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# pull_request_target deliberately runs only this workflow from the protected
|
||||
# base branch. Never check out or execute pull-request code here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reviewer-router-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
route:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return currentPull;
|
||||
}
|
||||
const pullRequest = await getReadyEventPull('initial read');
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
const author = pullRequest.user.login.toLowerCase();
|
||||
const headSha = pullRequest.head.sha;
|
||||
const latestPusher =
|
||||
context.payload.action === 'synchronize'
|
||||
? context.payload.sender?.login?.toLowerCase()
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
reviewer.toLowerCase() !== latestPusher,
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDecisionByLogin = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login?.toLowerCase();
|
||||
if (
|
||||
!login ||
|
||||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
|
||||
review.state,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const previous = latestDecisionByLogin.get(login);
|
||||
if (!previous || review.id > previous.id) {
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const openPullRequest of openPullRequests) {
|
||||
for (const reviewer of openPullRequest.requested_reviewers || []) {
|
||||
const candidate = eligible.find(
|
||||
login => login.toLowerCase() === reviewer.login.toLowerCase(),
|
||||
);
|
||||
if (candidate) {
|
||||
loads.set(candidate, loads.get(candidate) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = pullNumber % eligible.length;
|
||||
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
|
||||
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
|
||||
const staleChangeRequester = [...latestDecisionByLogin.values()]
|
||||
.filter(review => review.state === 'CHANGES_REQUESTED')
|
||||
.sort((left, right) => right.id - left.id)
|
||||
.map(review =>
|
||||
eligible.find(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.find(Boolean);
|
||||
const ranked = [...eligible].sort(
|
||||
(left, right) =>
|
||||
Number(right === staleChangeRequester) -
|
||||
Number(left === staleChangeRequester) ||
|
||||
loads.get(left) - loads.get(right) ||
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('auto-merge enable');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (currentPull.auto_merge) {
|
||||
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: MERGE
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{pullRequestId: currentPull.node_id},
|
||||
);
|
||||
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await routeReview();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
|
||||
);
|
||||
}
|
||||
await enableAutoMerge();
|
||||
@@ -62,3 +62,7 @@ dwsbin
|
||||
/docs/shortcut-comparison.html
|
||||
/docs/shortcut-gsb-eval.*
|
||||
/scripts/run_shortcut_real_read_matrix.py
|
||||
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
@@ -6,6 +6,73 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Shortcut Runtime Schema delivery** — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
|
||||
|
||||
## [1.0.55-beta.4] - 2026-07-27
|
||||
|
||||
This beta validates the shortcut projection fixes for group bots, bot search,
|
||||
and mail threads, together with hardened release delivery to Gitee and npm on
|
||||
top of the `v1.0.55-beta.3` baseline.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection fixes** (#795) — `chat +chat-bots` no longer projects a non-empty `list_group_bots` response to an empty list, `+bot-find` recognizes the `search_bots` response shape (`result.bots` entries with `botOpenDingTalkId`), and mail thread listings keep `lastUpdated` when the backend returns `lastModifiedDateTime`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hardened release delivery** — the Gitee mirror workflow can synchronize a specific release's assets on demand, release lookup tolerates Gitee's HTTP 200 null-body response for missing releases, npm dist-tag verification waits through slow registry CDN propagation with incremental backoff, and beta/stable release operations are role-enforced (#791).
|
||||
|
||||
## [1.0.55-beta.3] - 2026-07-24
|
||||
|
||||
This beta validates the HR Brain command surface, smoother guarded release
|
||||
automation, and deterministic Markdown test coverage on top of the
|
||||
`v1.0.55-beta.2` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Faster guarded releases** — trusts an independently revalidated, exact `CHANGELOG.md`-only successor of an already admitted `main` commit, runs cloud planning alongside governance, and executes sealed-release automation, compatibility, and multi-profile validation in parallel with artifact compilation. Normal cloud publication no longer requires an unshareable local packaging preflight.
|
||||
- **Scoped document reads and group mentions** — `doc read --content-format jsonml` can return `outline`, `range`, `section`, or custom-tag fragments with depth and block-boundary controls; document comment create, reply, and update can mention groups through `--mentioned-open-conversation-id`.
|
||||
- **Drive overwrite uploads** — `drive upload --node <fileId>` can replace an existing Drive or document-space file, is mutually exclusive with `--folder`, supports dry-run, and requires confirmation before writing.
|
||||
- **Chat nickname clearing and cross-organization todos** — omitting `--nick` from `chat group update-nick` now clears the current user's group nickname, while `todo task list --query-all` queries todos across organizations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy authentication compatibility** (#756) — migrates pre-v1.0.53 global and organization-scoped login state into the identity-aware token store, including all legacy organizations, while keeping unresolved accounts isolated from exact `corpId:userId` credentials so external or no-directory identities can complete login without borrowing another user's token.
|
||||
|
||||
## [1.0.55-beta.1] - 2026-07-23
|
||||
|
||||
This beta validates MCP Market URL resolution, the supported Wukong local-file
|
||||
send path after retiring the legacy credential-based media upload command from
|
||||
discovery, and reliable message-read rendering for rich content, forwarded
|
||||
records, encrypted messages, and media-download ID aliases.
|
||||
|
||||
### Added
|
||||
|
||||
- **MCP URL resolution** — adds `dws mcp url get <mcpId>` for resolving a DingTalk MCP Market ID to the current user and organization scoped Streamable HTTP URL, while keeping the helper-only `mcp-meta` endpoint out of the public product command surface.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat local-file sending** — hides the open-source-only `chat media upload` compatibility command from Help, Schema, and bundled Skills, and removes its legacy AppKey/AppSecret OAPI path. Historical argv still receives an actionable migration error. Send local images and files through `chat message send --msg-type file --file-path`; callers that already hold a mediaId may continue to use `--msg-type image --media-id`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
|
||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||
|
||||
## [1.0.54] - 2026-07-21
|
||||
|
||||
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
|
||||
|
||||
+46
-8
@@ -27,7 +27,9 @@ notes that are intentionally kept out of the repository root.
|
||||
|
||||
## Local Checks
|
||||
|
||||
Run the verification commands that match the surface you changed before you hand work back.
|
||||
Run the verification commands that match the surface you changed before you
|
||||
hand work back. The goal is useful, change-specific evidence, not a second
|
||||
local execution of every CI job.
|
||||
|
||||
Common repository checks already used here include:
|
||||
|
||||
@@ -44,19 +46,55 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Select the PR risk tier before choosing checks:
|
||||
|
||||
| Tier | Typical scope | Developer evidence | CI expansion |
|
||||
|---|---|---|---|
|
||||
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
|
||||
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
|
||||
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
|
||||
|
||||
Classification fails closed: an incomplete diff, package add/remove/rename, or
|
||||
uncertain dependency graph selects the high-risk suite. Native changed-code
|
||||
coverage is additionally selected for platform-sensitive code.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Include verification evidence in your PR description.
|
||||
2. Select the documentation-only, standard, or high-risk tier and run the
|
||||
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
|
||||
a complete local pass is warranted; it is not required for every ordinary
|
||||
PR.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
1. Make the smallest atomic change that satisfies the task.
|
||||
2. Keep doc edits factual and limited to implemented behavior.
|
||||
3. Run the relevant verification commands.
|
||||
4. Report the validation results with the handoff.
|
||||
4. Report the validation results and risk tier with the handoff.
|
||||
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
|
||||
peer reviewer, balancing the current open-review load and excluding the
|
||||
author. A new head push re-enters the same routing flow when the latest
|
||||
revision still needs review.
|
||||
6. After the latest push has one peer approval and the exact nine required
|
||||
contexts are current and green, auto-merge completes the PR. If `main`
|
||||
advances first, strict status checks revalidate the branch; no separate
|
||||
routine merge request is needed.
|
||||
|
||||
Contributors without repository write access stop at the PR flow. Explicitly
|
||||
authorized collaborators with `write`, `maintain`, or `admin` access can use
|
||||
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
|
||||
to publish beta releases without manual approval. The same internal roles may
|
||||
start a stable release, but a different repository administrator must approve
|
||||
the `release-stable` Environment deployment before publication continues.
|
||||
|
||||
@@ -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.54-beta.2"
|
||||
version "1.0.55-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.54-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "46b57bed1f6e9f7ba007d8a86a6f5eb280fdeb557fc9bb5946f14f9b1f8f0c9f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "05b269fe44a125ee8b368d6228c5229950b8216872fb569741d1e30a83ce952a"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "1b7fd08e64b1c86bbcee217604ffe07e0e8f1b3b5c4de518534386972bcf0f9b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b0d7604299336c83b7805d3b1a47a90668f2e2f3fc54702b0e846bb2907b6170"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "108d3861ef606519f9934530d29654eab55a73607d1ee6775461f98ef5a6acd4"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "a9c1dd5c6171091a84fc18e5081c9f75d037826cd3966726545d715ce832ae31"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "6cb96ee09419bbbcc1eb336218ac2aa1d9ca0ed5cbd5a80c79bc20e1e1f03ff7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "5e97ba398f5a3e15b9d235bc53f596d31a4d6b7af7bb2185b7b48beeda6a2ebb"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-skills.zip"
|
||||
sha256 "572b93f04a10268d185ad1f8e70e0d412949ae056be8494a9949387076fd14bc"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-skills.zip"
|
||||
sha256 "4ebc0294b65d90adb5c5d639a548b528af240e4117ccca3d388e2efb6030170a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -17,6 +17,7 @@ help:
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@@ -38,8 +39,8 @@ help:
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -54,6 +55,10 @@ test:
|
||||
test-plan:
|
||||
@./scripts/ci/test-packages.sh verify
|
||||
|
||||
test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -76,7 +81,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy:
|
||||
policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
|
||||
+20
-7
@@ -52,7 +52,13 @@ run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CA["CI"]
|
||||
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
|
||||
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
|
||||
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
|
||||
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
|
||||
DOCS --> CA["CI"]
|
||||
STANDARD --> CA
|
||||
HIGH --> CA
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
@@ -72,9 +78,16 @@ flowchart TB
|
||||
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.
|
||||
All nine named contexts are produced for every tier. Domain-specific helpers
|
||||
run when their owned surface is affected; otherwise the corresponding context
|
||||
records an explicit unaffected success. Standard code changes still receive
|
||||
representative Darwin/Windows compilation. High-risk PRs and protected `main`
|
||||
run the complete race and native test suites, while platform-sensitive diffs
|
||||
also receive native changed-code coverage.
|
||||
|
||||
Review orchestration is also base-owned: it requests one eligible peer without
|
||||
executing PR code, re-routes an updated head when needed, and auto-merge
|
||||
completes only after the latest push has peer approval plus the current
|
||||
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
|
||||
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
|
||||
classification, context, reviewer, and ruleset contract.
|
||||
|
||||
+26
-32
@@ -62,32 +62,27 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
## Homebrew Formula Delivery
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`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.
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
|
||||
exactly one tracked Formula after the immutable GitHub assets and their
|
||||
checksums have passed verification. The publisher validates the rendered Ruby,
|
||||
commits only the configured Formula path, never force-pushes `main`, and retries
|
||||
from a fresh clone up to three times when `main` advances concurrently. Normal
|
||||
stable and beta releases do not create a Formula PR or run a permission
|
||||
canary. The workflow uses the existing repository-scoped
|
||||
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
|
||||
Actions App to bypass this repository's rulesets. That identity is the sole
|
||||
user bypass actor on the two default-branch rulesets. The workflow creates the
|
||||
nine Code Admission checks for the Formula-only commit only after proving its
|
||||
sole parent already has all nine successful checks and the committed Formula
|
||||
exactly matches this release's verified bytes.
|
||||
|
||||
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.
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
keep its owner as the designated ruleset bypass actor, and do not reuse
|
||||
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
|
||||
path restriction; GitHub rulesets do not infer that restriction from the token.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
@@ -98,15 +93,14 @@ administration setting and cannot be read by the workflow's built-in
|
||||
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.
|
||||
commit, sealed metadata, original failed run/attempt, requester identity and
|
||||
Release state all match; it then reuses the normal release jobs without a
|
||||
second-person environment approval. A same-run “Re-run failed jobs” is even
|
||||
lighter: the seal job may adopt an existing tag only when its complete
|
||||
authority matches that run and its original attempt is not newer than the
|
||||
current attempt. Do not put publication secrets in temporary branches or
|
||||
create ad-hoc recovery workflows.
|
||||
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
|
||||
+80
-20
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
| 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 |
|
||||
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
|
||||
| `Coverage` | Scope-matched 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 |
|
||||
@@ -56,8 +56,27 @@ 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.
|
||||
surface is unaffected.
|
||||
|
||||
The protected `main` push keeps that fast path only when all of these
|
||||
fail-closed conditions hold:
|
||||
|
||||
- the event is a non-forced update of the existing `refs/heads/main`;
|
||||
- the event `after` SHA is the exact workflow SHA, and both event SHAs are
|
||||
complete, non-zero commit IDs;
|
||||
- GitHub's comparison reports the previous main tip as the unique linear merge
|
||||
base, with no commits behind it;
|
||||
- the complete resulting tree diff is exactly one in-place modification of
|
||||
`CHANGELOG.md`;
|
||||
- the previous main tip already has successful GitHub Actions checks for all
|
||||
nine Code Admission contexts.
|
||||
|
||||
`Policy` then independently checks out the pushed revision and runs the same
|
||||
`check-changelog-pr.sh --fast-path` contract from the event's `before` SHA to
|
||||
its `after` SHA. If identity, ancestry, file scope, tree mode, CHANGELOG
|
||||
content, or predecessor admission cannot be proved, classification falls back
|
||||
to the complete main admission suite. A source change can therefore never
|
||||
inherit the CHANGELOG-only result.
|
||||
|
||||
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
|
||||
@@ -65,13 +84,28 @@ 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
|
||||
## Risk tiers 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.
|
||||
`Lint` resolves the complete base/head diff before any helper is skipped.
|
||||
Unknown or truncated input fails closed into the high-risk tier.
|
||||
|
||||
| Tier | Selection | Admission work |
|
||||
|---|---|---|
|
||||
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
|
||||
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
|
||||
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
|
||||
|
||||
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
|
||||
for example) execute their substantive suites when the diff can affect that
|
||||
contract or when the high-risk tier is selected. Otherwise their stable named
|
||||
contexts still report a successful, explicit unaffected result. Release-script
|
||||
tests follow the same impact rule. This preserves the ruleset contract without
|
||||
charging every developer for unrelated work.
|
||||
|
||||
Platform-sensitive changes additionally run native changed-code coverage.
|
||||
Protected `main` always runs native tests; generic portable changes are held to
|
||||
the Linux changed-code gate rather than being forced to manufacture
|
||||
platform-only coverage.
|
||||
|
||||
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
|
||||
@@ -97,9 +131,28 @@ flowchart TB
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Review ownership and auto-merge
|
||||
|
||||
A base-owned `pull_request_target` workflow routes newly opened, updated,
|
||||
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
|
||||
does not check out or execute PR code, excludes both the author and the known
|
||||
latest pusher, and balances the open requested-review load across the reviewed
|
||||
maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change:
|
||||
Run the contracts relevant to the change. Ordinary contributors are not
|
||||
expected to repeat every CI job locally:
|
||||
|
||||
```sh
|
||||
make build
|
||||
@@ -120,15 +173,18 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`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
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated 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.
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched 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.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
@@ -156,3 +212,7 @@ 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.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
|
||||
+35
-28
@@ -1,19 +1,28 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
|
||||
## 推荐入口:GitHub 云端发布
|
||||
|
||||
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
|
||||
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
|
||||
|
||||
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
|
||||
|
||||
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
|
||||
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
|
||||
|
||||
基于当时最新的 `main` 发起发布:
|
||||
|
||||
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -62,7 +71,9 @@ dws-release
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
|
||||
|
||||
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
|
||||
|
||||
## 发布模型
|
||||
|
||||
@@ -89,13 +100,7 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、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 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -105,14 +110,13 @@ beta 验证通过后,运行正式版入口:
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
@@ -130,14 +134,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
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。
|
||||
|
||||
@@ -164,32 +168,35 @@ dws-release recover v1.2.3-beta.1
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 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 中的固定证据。
|
||||
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 单独配置 `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 发布不受影响。
|
||||
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
|
||||
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
|
||||
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
|
||||
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
|
||||
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
|
||||
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
+342
-44
@@ -40,12 +40,14 @@ import (
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
}
|
||||
|
||||
type authLoginGuideAction string
|
||||
@@ -148,7 +150,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
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)
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
Explicit: cfg.HistoryProfileSelectorExplicit,
|
||||
})
|
||||
}
|
||||
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||
if err != nil {
|
||||
@@ -163,7 +168,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
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)
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
Explicit: cfg.HistoryProfileSelectorExplicit,
|
||||
})
|
||||
}
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||
@@ -175,11 +183,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
postLoginSelector := authpkg.TokenProfileSelector(tokenData)
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
||||
if profiles, loadErr := authLoadProfiles(configDir); loadErr == nil && profiles != nil {
|
||||
for i := range profiles.Profiles {
|
||||
profile := profiles.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(tokenData.CorpID) && strings.TrimSpace(profile.UserID) == "" {
|
||||
postLoginSelector = profileCLISelector(profile, profiles)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
|
||||
restoreProfile := replaceRuntimeProfile(postLoginSelector)
|
||||
defer restoreProfile()
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
@@ -287,7 +307,7 @@ var (
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") }
|
||||
authRunForm = (*huh.Form).Run
|
||||
authSaveTokenData = authpkg.SaveTokenData
|
||||
authSaveTokenData = authpkg.SaveLoginTokenData
|
||||
authSaveAppConfig = authpkg.SaveAppConfig
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||
return provider.Login(ctx)
|
||||
@@ -494,7 +514,9 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
if selected == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
|
||||
}
|
||||
profileSelector = authpkg.ProfileSelector(*selected)
|
||||
if strings.TrimSpace(selected.UserID) != "" {
|
||||
profileSelector = authpkg.ProfileSelector(*selected)
|
||||
}
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
@@ -522,7 +544,11 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
_ = authDeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
refreshFailure = refreshErr
|
||||
_ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired)
|
||||
markSelector := profileSelector
|
||||
if markSelector == "" {
|
||||
markSelector = authpkg.StableTokenProfileSelector(configDir, tokenData)
|
||||
}
|
||||
_ = authMarkProfileStatus(configDir, markSelector, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
|
||||
@@ -652,7 +678,14 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
||||
}
|
||||
stableSelector := selected.CorpID
|
||||
if exact {
|
||||
stableSelector = authpkg.ProfileSelector(*selected)
|
||||
if strings.TrimSpace(selected.UserID) == "" {
|
||||
// A blank historical profile can coexist with exact accounts in the
|
||||
// same organization. Preserve the exact local-name selector; reducing
|
||||
// it to corpId would log out the entire organization.
|
||||
stableSelector = strings.TrimSpace(selector)
|
||||
} else {
|
||||
stableSelector = authpkg.ProfileSelector(*selected)
|
||||
}
|
||||
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
@@ -661,7 +694,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
||||
if profile.CorpID != selected.CorpID {
|
||||
continue
|
||||
}
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
@@ -687,7 +720,7 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
|
||||
_ = authRevokeToken(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
@@ -1206,7 +1239,7 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||
}
|
||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||||
targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector)
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
@@ -1221,15 +1254,18 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
"flow", flow,
|
||||
"profile_selector", strings.TrimSpace(profileSelector),
|
||||
"target_corp_id", targetCorpID,
|
||||
"history_profile_selector", historyProfileSelector,
|
||||
"recommend", recommend,
|
||||
)
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1238,17 +1274,57 @@ func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
}
|
||||
|
||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
targetCorpID, _, _, err := resolveAuthLoginTarget(configDir, selector)
|
||||
return targetCorpID, err
|
||||
}
|
||||
|
||||
// resolveAuthLoginTarget keeps the authorization target separate from the
|
||||
// local identity hint used only when contact cannot resolve the logged-in
|
||||
// account. An implicit current profile must never constrain a fresh OAuth
|
||||
// authorization to that profile's organization.
|
||||
func resolveAuthLoginTarget(configDir, selector string) (targetCorpID, historySelector string, explicit bool, err error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
if profile, resolveErr := authResolveProfile(configDir, ""); resolveErr == nil && profile != nil {
|
||||
return "", authLoginHistorySelector(configDir, profile), false, nil
|
||||
}
|
||||
return "", "", false, nil
|
||||
}
|
||||
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
historySelector := authLoginHistorySelector(configDir, profile)
|
||||
_, _, identityExact := authpkg.ParseIdentitySelector(selector)
|
||||
if selector != strings.TrimSpace(profile.CorpID) && selector != strings.TrimSpace(profile.CorpName) {
|
||||
identityExact = true
|
||||
}
|
||||
return strings.TrimSpace(profile.CorpID), historySelector, identityExact, nil
|
||||
}
|
||||
if _, _, exact := authpkg.ParseIdentitySelector(selector); exact || strings.Contains(selector, ":") {
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
return selector, nil
|
||||
// A known organization that failed resolution is ambiguous (for
|
||||
// example, two local accounts without an org-current pointer), not a
|
||||
// request to invent a new corpId.
|
||||
if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil && cfg != nil {
|
||||
for i := range cfg.Profiles {
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector {
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q is ambiguous; use an exact corpId:userId selector", selector))
|
||||
}
|
||||
}
|
||||
}
|
||||
return selector, "", false, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
|
||||
func authLoginHistorySelector(configDir string, profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if cfg, err := authLoadProfiles(configDir); err == nil && cfg != nil {
|
||||
return authpkg.ProfileSelectionSelector(*profile, cfg)
|
||||
}
|
||||
return authpkg.ProfileSelector(*profile)
|
||||
}
|
||||
|
||||
type contactProfileIdentity struct {
|
||||
@@ -1258,12 +1334,23 @@ type contactProfileIdentity struct {
|
||||
UserName string
|
||||
}
|
||||
|
||||
type authLoginHistoryHint struct {
|
||||
Selector string
|
||||
Explicit bool
|
||||
}
|
||||
|
||||
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 {
|
||||
func enrichAuthLoginProfileFromContact(
|
||||
ctx context.Context,
|
||||
configDir string,
|
||||
caller edition.ToolCaller,
|
||||
data *authpkg.TokenData,
|
||||
hints ...authLoginHistoryHint,
|
||||
) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
@@ -1288,6 +1375,27 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
)
|
||||
return nil
|
||||
}
|
||||
hint := authLoginHistoryHint{}
|
||||
if len(hints) > 0 {
|
||||
hint = hints[0]
|
||||
}
|
||||
tryHistory := func() bool {
|
||||
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, data, hint)
|
||||
if historyErr != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.history.error",
|
||||
"corp_id", corpID,
|
||||
"error", historyErr,
|
||||
)
|
||||
}
|
||||
return reused
|
||||
}
|
||||
if caller == nil {
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
result *edition.ToolResult
|
||||
@@ -1297,7 +1405,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
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")
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1311,11 +1419,15 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
if strings.TrimSpace(data.UserID) != "" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
tryHistory()
|
||||
return nil
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
identity, ok := contactProfileIdentityFromToolResult(result, corpID)
|
||||
if !ok {
|
||||
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
logging.AuthDebug(
|
||||
@@ -1327,19 +1439,42 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
"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)
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.mismatch",
|
||||
"login_corp_id", corpID,
|
||||
"contact_corp_id", strings.TrimSpace(identity.CorpID),
|
||||
)
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
updated := *data
|
||||
exchangeUserID := strings.TrimSpace(data.UserID)
|
||||
if identity.CorpName != "" {
|
||||
updated.CorpName = identity.CorpName
|
||||
}
|
||||
if identity.UserID != "" {
|
||||
if exchangeUserID == "" && identity.UserID != "" {
|
||||
updated.UserID = identity.UserID
|
||||
}
|
||||
if identity.UserName != "" {
|
||||
if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) {
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if strings.TrimSpace(updated.UserID) == "" {
|
||||
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, &updated, hint)
|
||||
if historyErr != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.history.error",
|
||||
"corp_id", corpID,
|
||||
"error", historyErr,
|
||||
)
|
||||
}
|
||||
if reused {
|
||||
*data = updated
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
@@ -1361,7 +1496,144 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||||
// enrichAuthLoginProfileFromHistory recovers display metadata when the contact
|
||||
// service cannot describe an external-worker account. Historical profile
|
||||
// selection is never proof of the user who completed a fresh authorization:
|
||||
// only the token exchange or contact service may supply UserID.
|
||||
//
|
||||
// An explicit profile remains useful as a storage/selection hint. Keeping it in
|
||||
// LegacyOrgScopedProfile prevents the login from switching the process-global
|
||||
// current profile while SaveTokenData publishes the UID-less credential to the
|
||||
// unresolved organization slot. A historical blank profile is updated in
|
||||
// place; an exact historical profile and its token remain untouched.
|
||||
func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) {
|
||||
if data == nil || strings.TrimSpace(data.UserID) != "" {
|
||||
return false, nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return false, nil
|
||||
}
|
||||
cfg, err := authLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return false, nil
|
||||
}
|
||||
sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles))
|
||||
for i := range cfg.Profiles {
|
||||
profile := &cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) != corpID {
|
||||
continue
|
||||
}
|
||||
sameCorp = append(sameCorp, profile)
|
||||
}
|
||||
if len(sameCorp) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hint := authLoginHistoryHint{}
|
||||
if len(hints) > 0 {
|
||||
hint = hints[0]
|
||||
}
|
||||
var candidate *authpkg.Profile
|
||||
if hint.Explicit {
|
||||
candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp)
|
||||
if candidate == nil {
|
||||
// An explicit account is a hard identity boundary. If that exact
|
||||
// historical hint no longer matches the token's organization, do
|
||||
// not silently substitute org-current, sole, or global-current.
|
||||
return false, nil
|
||||
}
|
||||
} else if len(sameCorp) > 1 {
|
||||
// Organization-current is a storage preference, not proof of which user
|
||||
// completed a fresh authorization. With multiple accounts, only an exact
|
||||
// user selection may be used when the token/contact response has no UID.
|
||||
return false, nil
|
||||
} else {
|
||||
candidate = sameCorp[0]
|
||||
}
|
||||
|
||||
updated := *data
|
||||
if hint.Explicit {
|
||||
updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector)
|
||||
}
|
||||
if strings.TrimSpace(updated.CorpName) == "" {
|
||||
updated.CorpName = strings.TrimSpace(candidate.CorpName)
|
||||
}
|
||||
if strings.TrimSpace(updated.UserName) == "" {
|
||||
updated.UserName = strings.TrimSpace(candidate.UserName)
|
||||
}
|
||||
*data = updated
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
"source", "local_profile_history_display_only",
|
||||
"corp_id", corpID,
|
||||
"user_id", updated.UserID,
|
||||
"user_name", updated.UserName,
|
||||
"corp_name", updated.CorpName,
|
||||
"identity_proven", false,
|
||||
)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return nil
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))}
|
||||
for _, profile := range profiles {
|
||||
if profile != nil {
|
||||
cfg.Profiles = append(cfg.Profiles, *profile)
|
||||
}
|
||||
}
|
||||
var stableMatch *authpkg.Profile
|
||||
for _, profile := range profiles {
|
||||
if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) ||
|
||||
authpkg.ProfileSelectionSelector(*profile, cfg) != selector {
|
||||
continue
|
||||
}
|
||||
if stableMatch != nil {
|
||||
return nil
|
||||
}
|
||||
stableMatch = profile
|
||||
}
|
||||
if stableMatch != nil {
|
||||
return stableMatch
|
||||
}
|
||||
if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
|
||||
return nil
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var named *authpkg.Profile
|
||||
for _, profile := range profiles {
|
||||
if profile == nil || strings.TrimSpace(profile.Name) != selector {
|
||||
continue
|
||||
}
|
||||
if named != nil {
|
||||
return nil
|
||||
}
|
||||
named = profile
|
||||
}
|
||||
if named != nil {
|
||||
return named
|
||||
}
|
||||
if selector == strings.TrimSpace(corpID) && len(profiles) == 1 {
|
||||
return profiles[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||
if result == nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
@@ -1369,14 +1641,14 @@ func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactPr
|
||||
if strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||||
func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||
var payload struct {
|
||||
Result []struct {
|
||||
OrgEmployeeModel struct {
|
||||
@@ -1396,14 +1668,40 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
|
||||
if len(payload.Result) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
org := payload.Result[0].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
identities := make([]contactProfileIdentity, 0, len(payload.Result))
|
||||
for i := range payload.Result {
|
||||
org := payload.Result[i].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
if identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
if len(identities) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
expectedCorpID := ""
|
||||
if len(expectedCorpIDs) > 0 {
|
||||
expectedCorpID = strings.TrimSpace(expectedCorpIDs[0])
|
||||
}
|
||||
if expectedCorpID != "" {
|
||||
for _, identity := range identities {
|
||||
if identity.CorpID == expectedCorpID {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
// Older contact responses omit corpId. A single result is still
|
||||
// unambiguous; multiple organization records without a target match
|
||||
// must fall back to local history instead of choosing result[0].
|
||||
if len(payload.Result) != 1 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
|
||||
@@ -262,7 +262,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
||||
t.Fatal("device error should propagate")
|
||||
}
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.IdentityEnricher == nil {
|
||||
t.Error("device login missing shared identity enricher")
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
@@ -275,7 +278,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
|
||||
t.Fatal("oauth error should propagate")
|
||||
}
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.IdentityEnricher == nil {
|
||||
t.Error("OAuth login missing shared identity enricher")
|
||||
}
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
|
||||
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
|
||||
@@ -356,8 +362,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"}); err != nil {
|
||||
t.Fatalf("contact failure must remain best effort: %v", err)
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(
|
||||
ctx,
|
||||
@@ -374,8 +380,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err == nil {
|
||||
t.Fatal("corp mismatch should fail")
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil {
|
||||
t.Fatalf("contact corp mismatch must remain best effort: %v", err)
|
||||
}
|
||||
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
|
||||
@@ -390,6 +396,25 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||
t.Fatalf("enriched = %#v, %v", data, err)
|
||||
}
|
||||
known := &authpkg.TokenData{CorpID: "ding", UserID: "exchange-user", AccessToken: "token"}
|
||||
differentContactUser := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"other-user","name":"Other User"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", differentContactUser, known); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known.UserID != "exchange-user" || known.UserName != "" || known.CorpName != "Corp" {
|
||||
t.Fatalf("token-exchange identity was overwritten: %#v", known)
|
||||
}
|
||||
multiOrg := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other","userid":"other-user"}},{"orgEmployeeModel":{"corpId":"ding","orgName":"Target Corp","userid":"target-user","name":"Target User"}}]}`}}}}
|
||||
multiOrgData := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", multiOrg, multiOrgData); err != nil || multiOrgData.UserID != "target-user" || multiOrgData.CorpName != "Target Corp" {
|
||||
t.Fatalf("multi-org contact selection = %#v, %v", multiOrgData, err)
|
||||
}
|
||||
if _, ok := contactProfileIdentityFromJSON(
|
||||
[]byte(`{"result":[{"orgEmployeeModel":{"corpId":"other-a","userid":"user-a"}},{"orgEmployeeModel":{"corpId":"other-b","userid":"user-b"}}]}`),
|
||||
"ding",
|
||||
); ok {
|
||||
t.Fatal("multiple nonmatching organizations must not select an arbitrary contact identity")
|
||||
}
|
||||
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
|
||||
t.Fatal("nil result should not parse")
|
||||
}
|
||||
@@ -398,6 +423,570 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactFailureReusesOnlySameCorpHistoricalDisplayMetadata(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 1,
|
||||
Profiles: []authpkg.Profile{{
|
||||
CorpID: "ding_ecological_worker",
|
||||
CorpName: "Historical Corp",
|
||||
UserID: "external-user",
|
||||
UserName: "Historical Worker",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller edition.ToolCaller
|
||||
wantCorp string
|
||||
}{
|
||||
{
|
||||
name: "contact business error",
|
||||
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact has no identity",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"success":false}`}}}},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact identity is missing user id",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_ecological_worker","orgName":"Contact Corp"}}]}`,
|
||||
}}}},
|
||||
wantCorp: "Contact Corp",
|
||||
},
|
||||
{
|
||||
name: "ordinary contact error",
|
||||
caller: &authCoverageCaller{err: errors.New("network failure")},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "other contact business error",
|
||||
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||
"permission denied",
|
||||
apperrors.WithReason("business_error"),
|
||||
)},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact caller unavailable",
|
||||
caller: nil,
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact returns another organization",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_other","userid":"other-user"}}]}`,
|
||||
}}}},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data := &authpkg.TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
CorpID: "ding_ecological_worker",
|
||||
CorpName: "Fresh Corp",
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil {
|
||||
t.Fatalf("contact failure blocked historical identity recovery: %v", err)
|
||||
}
|
||||
if data.UserID != "" || data.UserName != "Historical Worker" {
|
||||
t.Fatalf("historical metadata supplied UID evidence: %#v", data)
|
||||
}
|
||||
if data.CorpName != tc.wantCorp {
|
||||
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("new token material was changed: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactFailureDoesNotGuessHistoricalIdentity(t *testing.T) {
|
||||
businessErr := apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
corpID string
|
||||
profiles []authpkg.Profile
|
||||
callErr error
|
||||
}{
|
||||
{
|
||||
name: "same corp has two identities",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user-b"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "same corp has one identity and one blank profile",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||
{CorpID: "ding_ecological_worker"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "identity belongs to another corp",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_other", UserID: "external-user"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "no historical identity",
|
||||
corpID: "ding_ecological_worker",
|
||||
callErr: businessErr,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{Version: 2, Profiles: tc.profiles}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
data := &authpkg.TokenData{AccessToken: "new-access", CorpID: tc.corpID}
|
||||
err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
configDir,
|
||||
&authCoverageCaller{err: tc.callErr},
|
||||
data,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("contact failure must not block unresolved legacy login: %v", err)
|
||||
}
|
||||
if data.UserID != "" {
|
||||
t.Fatalf("ambiguous/cross-corp identity was reused: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) {
|
||||
for _, data := range []*authpkg.TokenData{
|
||||
nil,
|
||||
{UserID: "known"},
|
||||
{},
|
||||
} {
|
||||
reused, err := enrichAuthLoginProfileFromHistory(t.TempDir(), data)
|
||||
if reused || err != nil {
|
||||
t.Fatalf("ineligible history fallback = %v, %v", reused, err)
|
||||
}
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
CorpID: "ding_external",
|
||||
CorpName: "Historical Corp",
|
||||
UserID: "external-user",
|
||||
UserName: "Historical Worker",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
data := &authpkg.TokenData{CorpID: "ding_external"}
|
||||
reused, err := enrichAuthLoginProfileFromHistory(configDir, data)
|
||||
if err != nil || !reused {
|
||||
t.Fatalf("history fallback = %v, %v", reused, err)
|
||||
}
|
||||
if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "" {
|
||||
t.Fatalf("history metadata = %#v", data)
|
||||
}
|
||||
|
||||
corruptDir := t.TempDir()
|
||||
if err := os.Mkdir(authpkg.ProfilesPath(corruptDir), 0o700); err != nil {
|
||||
t.Fatalf("create unreadable profiles path: %v", err)
|
||||
}
|
||||
if reused, err := enrichAuthLoginProfileFromHistory(corruptDir, &authpkg.TokenData{CorpID: "ding_external"}); reused || err == nil {
|
||||
t.Fatalf("corrupt history fallback = %v, %v; want load error", reused, err)
|
||||
}
|
||||
|
||||
businessErr := apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *authCoverageCaller
|
||||
}{
|
||||
{
|
||||
name: "contact business error",
|
||||
caller: &authCoverageCaller{err: businessErr},
|
||||
},
|
||||
{
|
||||
name: "contact has no identity",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{}},
|
||||
},
|
||||
{
|
||||
name: "contact identity is missing user id",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_external"}}]}`,
|
||||
}}}},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
corruptDir,
|
||||
tc.caller,
|
||||
&authpkg.TokenData{CorpID: "ding_external", AccessToken: "new-access"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("best-effort contact/history lookup blocked login: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldResolve := authResolveProfile
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() {
|
||||
authResolveProfile = oldResolve
|
||||
authLoadProfiles = oldLoad
|
||||
})
|
||||
|
||||
explicit := &authpkg.Profile{CorpID: "ding_same", UserID: "user_2", Name: "second"}
|
||||
current := &authpkg.Profile{CorpID: "ding_current", UserID: "current_user"}
|
||||
authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||
switch selector {
|
||||
case "ding_same:user_2":
|
||||
clone := *explicit
|
||||
return &clone, nil
|
||||
case "external-worker":
|
||||
return &authpkg.Profile{Name: "external-worker", CorpID: "ding_external"}, nil
|
||||
case "":
|
||||
clone := *current
|
||||
return &clone, nil
|
||||
default:
|
||||
return nil, errors.New("missing")
|
||||
}
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
|
||||
cmd := newAuthLoginCommand(nil)
|
||||
root, _, _ := authCoverageRoot(cmd, "table", true)
|
||||
if err := root.PersistentFlags().Set("profile", "ding_same:user_2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := resolveAuthLoginConfig(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TargetCorpID != "ding_same" || cfg.HistoryProfileSelector != "ding_same:user_2" || !cfg.HistoryProfileSelectorExplicit {
|
||||
t.Fatalf("explicit login config = %#v", cfg)
|
||||
}
|
||||
if target, hint, exact, err := resolveAuthLoginTarget("cfg", "external-worker"); err != nil ||
|
||||
target != "ding_external" || hint != "ding_external" || !exact {
|
||||
t.Fatalf("blank-userId profile target = %q/%q/%v, %v", target, hint, exact, err)
|
||||
}
|
||||
|
||||
if err := root.PersistentFlags().Set("profile", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = resolveAuthLoginConfig(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TargetCorpID != "" || cfg.HistoryProfileSelector != "ding_current:current_user" || cfg.HistoryProfileSelectorExplicit {
|
||||
t.Fatalf("implicit login config constrained authorization target: %#v", cfg)
|
||||
}
|
||||
|
||||
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_same:missing"); err == nil {
|
||||
t.Fatal("missing exact profile must not be reinterpreted as a corpId")
|
||||
}
|
||||
if target, hint, explicitHint, err := resolveAuthLoginTarget("cfg", "ding_new"); err != nil || target != "ding_new" || hint != "" || explicitHint {
|
||||
t.Fatalf("new organization target = %q/%q/%v, %v", target, hint, explicitHint, err)
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ambiguous", UserID: "user_1"},
|
||||
{CorpID: "ding_ambiguous", UserID: "user_2"},
|
||||
}}, nil
|
||||
}
|
||||
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_ambiguous"); err == nil {
|
||||
t.Fatal("ambiguous known organization must require an exact profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory(t *testing.T) {
|
||||
oldResolve := authResolveProfile
|
||||
oldLoad := authLoadProfiles
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authResolveProfile = oldResolve
|
||||
authLoadProfiles = oldLoad
|
||||
authDeviceLogin = oldDevice
|
||||
authOAuthLogin = oldOAuth
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
})
|
||||
|
||||
authResolveProfile = authpkg.ResolveProfile
|
||||
authLoadProfiles = authpkg.LoadProfiles
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
for _, flow := range []string{"oauth", "device"} {
|
||||
t.Run(flow, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
keychainDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
// StorageDirEnv isolates file-backed keychains, while Windows uses
|
||||
// DPAPI-protected HKCU values. Give every flow its own namespace so
|
||||
// OAuth/device fixtures cannot leak into each other or later tests.
|
||||
t.Setenv(keychain.TestNamespaceEnv, keychainDir)
|
||||
t.Cleanup(func() {
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
t.Errorf("clean auth keychain fixture: %v", err)
|
||||
}
|
||||
})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
|
||||
const (
|
||||
corpID = "ding_same"
|
||||
historicalUID = "user_a"
|
||||
exactSelector = corpID + ":" + historicalUID
|
||||
)
|
||||
oldToken := &authpkg.TokenData{
|
||||
AccessToken: "old-user-a-access",
|
||||
RefreshToken: "old-user-a-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: "Same Corp",
|
||||
UserID: historicalUID,
|
||||
UserName: "Historical User A",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, oldToken); err != nil {
|
||||
t.Fatalf("persist historical exact identity: %v", err)
|
||||
}
|
||||
|
||||
caller := &authCoverageCaller{err: errors.New("contact unavailable")}
|
||||
var enriched *authpkg.TokenData
|
||||
freshToken := func() *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "fresh-user-b-access-" + flow,
|
||||
RefreshToken: "fresh-user-b-refresh-" + flow,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
}
|
||||
}
|
||||
persistUnknown := func(ctx context.Context, identityEnricher func(context.Context, *authpkg.TokenData) error) (*authpkg.TokenData, error) {
|
||||
if identityEnricher == nil {
|
||||
return nil, errors.New("missing identity enricher")
|
||||
}
|
||||
data := freshToken()
|
||||
if err := identityEnricher(ctx, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enriched = data
|
||||
if data.UserID != "" {
|
||||
return nil, fmt.Errorf("historical profile supplied unproven userId %q", data.UserID)
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
flags := map[string]string{"profile": exactSelector}
|
||||
switch flow {
|
||||
case "device":
|
||||
flags["device"] = "true"
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||
return persistUnknown(ctx, provider.IdentityEnricher)
|
||||
}
|
||||
case "oauth":
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.TargetCorpID != corpID {
|
||||
return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID)
|
||||
}
|
||||
return persistUnknown(ctx, provider.IdentityEnricher)
|
||||
}
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, caller, "table", true, flags); err != nil {
|
||||
t.Fatalf("%s login with unresolved fresh identity: %v", flow, err)
|
||||
}
|
||||
if enriched == nil || enriched.UserID != "" ||
|
||||
enriched.LegacyOrgScopedProfile != exactSelector ||
|
||||
enriched.CorpName != "Same Corp" ||
|
||||
enriched.UserName != "Historical User A" {
|
||||
t.Fatalf("%s history hint became identity evidence: %#v", flow, enriched)
|
||||
}
|
||||
|
||||
historical, err := authpkg.LoadTokenDataForProfile(configDir, exactSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("load historical exact identity: %v", err)
|
||||
}
|
||||
if historical.AccessToken != oldToken.AccessToken || historical.UserID != historicalUID {
|
||||
t.Fatalf("historical exact slot was overwritten: %#v", historical)
|
||||
}
|
||||
|
||||
profiles, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load profiles: %v", err)
|
||||
}
|
||||
var unresolved *authpkg.Profile
|
||||
for i := range profiles.Profiles {
|
||||
profile := &profiles.Profiles[i]
|
||||
if profile.CorpID == corpID && profile.UserID == "" {
|
||||
unresolved = profile
|
||||
break
|
||||
}
|
||||
}
|
||||
if unresolved == nil {
|
||||
t.Fatalf("fresh UID-less token did not create an unresolved profile: %#v", profiles.Profiles)
|
||||
}
|
||||
unresolvedSelector := authpkg.ProfileSelectionSelector(*unresolved, profiles)
|
||||
if unresolvedSelector == "" || unresolvedSelector == exactSelector {
|
||||
t.Fatalf("unresolved selector = %q", unresolvedSelector)
|
||||
}
|
||||
fresh, err := authpkg.LoadTokenDataForProfile(configDir, unresolvedSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("load fresh unresolved identity: %v", err)
|
||||
}
|
||||
if fresh.AccessToken != "fresh-user-b-access-"+flow || fresh.UserID != "" {
|
||||
t.Fatalf("fresh token was not isolated in unresolved org slot: %#v", fresh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testing.T) {
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() { authLoadProfiles = oldLoad })
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
if reused, err := enrichAuthLoginProfileFromHistory("cfg", &authpkg.TokenData{CorpID: "ding_same"}); reused || err != nil {
|
||||
t.Fatalf("nil history registry = reused=%v err=%v", reused, err)
|
||||
}
|
||||
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
CurrentProfile: "ding_same:user_1",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"ding_same": "ding_same:user_2",
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||
},
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||
|
||||
explicitData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true})
|
||||
if err != nil || !reused || explicitData.UserID != "" ||
|
||||
explicitData.LegacyOrgScopedProfile != "ding_same:user_1" ||
|
||||
explicitData.CorpName != "Same Corp" || explicitData.UserName != "First" {
|
||||
t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err)
|
||||
}
|
||||
mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", mismatchedHintData, authLoginHistoryHint{Selector: "ding_other:user_9", Explicit: true})
|
||||
if err != nil || reused || mismatchedHintData.UserID != "" {
|
||||
t.Fatalf("cross-corp explicit hint reused another identity: %#v, reused=%v err=%v", mismatchedHintData, reused, err)
|
||||
}
|
||||
orgCurrentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", orgCurrentData)
|
||||
if err != nil || reused || orgCurrentData.UserID != "" {
|
||||
t.Fatalf("implicit multi-account org-current was treated as identity proof: %#v, reused=%v err=%v", orgCurrentData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]}
|
||||
soleData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData)
|
||||
if err != nil || !reused || soleData.UserID != "" ||
|
||||
soleData.CorpName != "Same Corp" || soleData.UserName != "Second" {
|
||||
t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||
}
|
||||
cfg.OrgCurrentProfiles = nil
|
||||
currentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", currentData)
|
||||
if err != nil || reused || currentData.UserID != "" {
|
||||
t.Fatalf("implicit multi-account current was treated as identity proof: %#v, reused=%v err=%v", currentData, reused, err)
|
||||
}
|
||||
|
||||
cfg.CurrentProfile = ""
|
||||
ambiguousData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", ambiguousData)
|
||||
if err != nil || reused || ambiguousData.UserID != "" {
|
||||
t.Fatalf("ambiguous history selection = %#v, reused=%v err=%v", ambiguousData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{{
|
||||
Name: "external-worker", CorpID: "ding_same", CorpName: "Legacy Corp", UserName: "Legacy Worker",
|
||||
}}
|
||||
blankData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", blankData, authLoginHistoryHint{Selector: "external-worker", Explicit: true})
|
||||
if err != nil || !reused || blankData.UserID != "" || blankData.LegacyOrgScopedProfile != "external-worker" || blankData.CorpName != "Legacy Corp" || blankData.UserName != "Legacy Worker" {
|
||||
t.Fatalf("blank-userId history selection = %#v, reused=%v err=%v", blankData, reused, err)
|
||||
}
|
||||
contactBlankData := &authpkg.TokenData{CorpID: "ding_same", AccessToken: "new-token"}
|
||||
if err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
"cfg",
|
||||
&authCoverageCaller{err: errors.New("contact unavailable")},
|
||||
contactBlankData,
|
||||
authLoginHistoryHint{Selector: "external-worker", Explicit: true},
|
||||
); err != nil {
|
||||
t.Fatalf("blank-userId history must keep contact best effort: %v", err)
|
||||
}
|
||||
if contactBlankData.LegacyOrgScopedProfile != "external-worker" {
|
||||
t.Fatalf("blank-userId contact fallback did not authorize the historical organization slot: %#v", contactBlankData)
|
||||
}
|
||||
|
||||
profiles := []*authpkg.Profile{
|
||||
nil,
|
||||
{Name: "duplicate", CorpID: "ding_same", UserID: "user_1"},
|
||||
{Name: "duplicate", CorpID: "ding_same", UserID: "user_2"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
selector string
|
||||
profiles []*authpkg.Profile
|
||||
want *authpkg.Profile
|
||||
}{
|
||||
{name: "empty selector", selector: "", profiles: profiles},
|
||||
{name: "missing exact identity", selector: "ding_same:missing", profiles: profiles},
|
||||
{name: "duplicate name", selector: "duplicate", profiles: profiles},
|
||||
{name: "unmatched name", selector: "not-found", profiles: profiles},
|
||||
{name: "sole organization selector", selector: "ding_same", profiles: profiles[1:2], want: profiles[1]},
|
||||
} {
|
||||
t.Run("selector "+tc.name, func(t *testing.T) {
|
||||
if got := historicalProfileForSelector("ding_same", tc.selector, tc.profiles); got != tc.want {
|
||||
t.Fatalf("historicalProfileForSelector(%q) = %#v, want %#v", tc.selector, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -748,7 +1337,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
if err := importCmd.RunE(badForce, nil); err == nil {
|
||||
t.Fatal("invalid force flag should fail")
|
||||
}
|
||||
_, out, _ = authCoverageRoot(importCmd, "table", false)
|
||||
_, _, _ = authCoverageRoot(importCmd, "table", false)
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("missing input should fail")
|
||||
}
|
||||
|
||||
@@ -195,6 +195,15 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
// Windows stores credentials in HKCU rather than StorageDirEnv. Use a
|
||||
// fresh registry namespace so this zero-state assertion cannot inherit a
|
||||
// token from an earlier test in the same package binary.
|
||||
t.Setenv(keychain.TestNamespaceEnv, root)
|
||||
t.Cleanup(func() {
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
t.Errorf("clean import guard keychain fixture: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"io"
|
||||
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginUsesStableBlankProfileForPostLoginAuthorization(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldOAuth := authOAuthLogin
|
||||
oldLoadProfiles := authLoadProfiles
|
||||
oldRecommend := authRunLoginRecommend
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
oldResolve := authResolveProfile
|
||||
t.Cleanup(func() {
|
||||
authOAuthLogin = oldOAuth
|
||||
authLoadProfiles = oldLoadProfiles
|
||||
authRunLoginRecommend = oldRecommend
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
authResolveProfile = oldResolve
|
||||
})
|
||||
|
||||
const corpID = "corp_post_login_blank"
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
{Name: "Fixture Organization", CorpID: corpID, CorpName: "Fixture Organization"},
|
||||
{Name: "Exact Fixture", CorpID: corpID, CorpName: "Fixture Organization", UserID: "identity_exact"},
|
||||
}}
|
||||
wantSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
if wantSelector == "" || wantSelector == corpID {
|
||||
t.Fatalf("blank selector = %q, want a stable account selector", wantSelector)
|
||||
}
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
|
||||
return nil, errors.New("no implicit profile")
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "new-access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: corpID,
|
||||
}, nil
|
||||
}
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
seenSelector := ""
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
seenSelector = authpkg.RuntimeProfile()
|
||||
return nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"recommend": "true"}); err != nil {
|
||||
t.Fatalf("blank-profile login error = %v", err)
|
||||
}
|
||||
if seenSelector != wantSelector {
|
||||
t.Fatalf("post-login runtime selector = %q, want %q", seenSelector, wantSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthStatusAndLogoutPreserveExactSelectors(t *testing.T) {
|
||||
t.Run("status canonicalizes a known identity", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
const exactSelector = "corp_status_fixture:identity_status_fixture"
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Status Fixture",
|
||||
CorpID: "corp_status_fixture",
|
||||
UserID: "identity_status_fixture",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
oldStatus := authOAuthStatus
|
||||
t.Cleanup(func() { authOAuthStatus = oldStatus })
|
||||
seenSelector := ""
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||
seenSelector = authpkg.RuntimeProfile()
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp_status_fixture",
|
||||
UserID: "identity_status_fixture",
|
||||
}, nil
|
||||
}
|
||||
cmd := newAuthStatusCommand()
|
||||
_, _, _ = authCoverageRoot(cmd, "table", false)
|
||||
if err := cmd.Flags().Set("profile", " Status Fixture "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("auth status error = %v", err)
|
||||
}
|
||||
if seenSelector != exactSelector {
|
||||
t.Fatalf("status runtime selector = %q, want %q", seenSelector, exactSelector)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logout keeps a blank local selector", func(t *testing.T) {
|
||||
oldResolve := authResolveProfileDeletion
|
||||
oldLoad := authLoadTokenForProfile
|
||||
oldRevoke := authRevokeTokenForData
|
||||
oldDelete := authDeleteProfileToken
|
||||
t.Cleanup(func() {
|
||||
authResolveProfileDeletion = oldResolve
|
||||
authLoadTokenForProfile = oldLoad
|
||||
authRevokeTokenForData = oldRevoke
|
||||
authDeleteProfileToken = oldDelete
|
||||
})
|
||||
|
||||
const selector = "legacy-external-worker"
|
||||
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
|
||||
return &authpkg.Profile{CorpID: "corp_logout_blank"}, true, nil
|
||||
}
|
||||
loadedSelector := ""
|
||||
authLoadTokenForProfile = func(_ string, got string) (*authpkg.TokenData, error) {
|
||||
loadedSelector = got
|
||||
return &authpkg.TokenData{CorpID: "corp_logout_blank"}, nil
|
||||
}
|
||||
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return nil }
|
||||
deletedSelector := ""
|
||||
authDeleteProfileToken = func(_ string, got string) error {
|
||||
deletedSelector = got
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", " "+selector+" "); err != nil {
|
||||
t.Fatalf("logoutOneProfile() error = %v", err)
|
||||
}
|
||||
if loadedSelector != selector || deletedSelector != selector {
|
||||
t.Fatalf("blank logout selectors = load %q delete %q, want %q", loadedSelector, deletedSelector, selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthHistorySelectorRemainingBranches(t *testing.T) {
|
||||
if got := authLoginHistorySelector("cfg", nil); got != "" {
|
||||
t.Fatalf("nil history selector = %q", got)
|
||||
}
|
||||
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() { authLoadProfiles = oldLoad })
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return nil, errors.New("profiles unavailable")
|
||||
}
|
||||
profile := &authpkg.Profile{CorpID: "corp_history", UserID: "identity_history"}
|
||||
if got := authLoginHistorySelector("cfg", profile); got != "corp_history:identity_history" {
|
||||
t.Fatalf("history selector fallback = %q", got)
|
||||
}
|
||||
|
||||
duplicateA := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
|
||||
duplicateB := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
|
||||
if got := historicalProfileForSelector(
|
||||
"corp_history",
|
||||
"corp_history:duplicate_identity",
|
||||
[]*authpkg.Profile{duplicateA, duplicateB},
|
||||
); got != nil {
|
||||
t.Fatalf("duplicate stable identity selected %#v", got)
|
||||
}
|
||||
|
||||
// Whitespace keeps the raw selector from matching the stable string while
|
||||
// ParseIdentitySelector still resolves its components.
|
||||
exactFallback := &authpkg.Profile{CorpID: "corp_history", UserID: "fallback_identity"}
|
||||
if got := historicalProfileForSelector(
|
||||
"corp_history",
|
||||
"corp_history : fallback_identity",
|
||||
[]*authpkg.Profile{exactFallback},
|
||||
); got != exactFallback {
|
||||
t.Fatalf("exact history fallback = %#v, want %#v", got, exactFallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProfileSwitchLegacyBlankAndNormalizedIdentityPointers(t *testing.T) {
|
||||
t.Run("one legacy blank name", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{
|
||||
{Name: "Fixture Organization", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization"},
|
||||
{Name: "Exact Fixture", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization", UserID: "identity_exact"},
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "Fixture Organization", cfg); got != 0 {
|
||||
t.Fatalf("legacy blank profile index = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate legacy names fall through to blank-name compatibility", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{
|
||||
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
|
||||
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "duplicate-legacy", cfg); got != 0 {
|
||||
t.Fatalf("duplicate legacy fallback index = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalized exact identity", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{{CorpID: "corp_profile_fixture", UserID: "identity_exact"}}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : identity_exact", cfg); got != 0 {
|
||||
t.Fatalf("normalized exact profile index = %d, want 0", got)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : missing", cfg); got != -1 {
|
||||
t.Fatalf("missing normalized exact profile index = %d, want -1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRunnerPreservesBlankSelectorInSingleAndMultiRuns(t *testing.T) {
|
||||
exact := authLogoutTestToken("corp_runner_blank")
|
||||
exact.UserID = "identity_exact_runner"
|
||||
other := authLogoutTestToken("corp_runner_other")
|
||||
configDir := setupAuthLogoutProfiles(t, exact, other)
|
||||
blank := authLogoutTestToken("corp_runner_blank")
|
||||
blank.AccessToken = "access-unresolved-runner"
|
||||
blank.RefreshToken = "refresh-unresolved-runner"
|
||||
blank.UserID = ""
|
||||
blank.UserName = ""
|
||||
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
|
||||
t.Fatalf("SaveTokenData(blank) error = %v", err)
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
blankSelector := ""
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID == blank.CorpID && profile.UserID == "" {
|
||||
blankSelector = authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
break
|
||||
}
|
||||
}
|
||||
if blankSelector == "" || blankSelector == blank.CorpID {
|
||||
t.Fatalf("blank runner selector = %q, want exact local selector", blankSelector)
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
invocation := executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
}
|
||||
authpkg.SetRuntimeProfile(blankSelector)
|
||||
result, err := runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("single blank Run() error = %v", err)
|
||||
}
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if got := content["runtimeProfile"]; got != blankSelector {
|
||||
t.Fatalf("single blank runtime profile = %#v, want %q", got, blankSelector)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != blankSelector {
|
||||
t.Fatalf("single blank runtime restoration = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(blankSelector + ",corp_runner_other")
|
||||
result, err = runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("multi blank Run() error = %v", err)
|
||||
}
|
||||
entries := result.Response["content"].(map[string]any)["profiles"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("multi blank profiles = %#v, want two", entries)
|
||||
}
|
||||
first := entries[0].(map[string]any)
|
||||
if first["selector"] != blankSelector || first["profile"] != blankSelector || first["userId"] != "" {
|
||||
t.Fatalf("multi blank first entry = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusSelectorCanonicalFallback(t *testing.T) {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
identity := personal.Identity{
|
||||
CorpID: "corp_event_fallback",
|
||||
UserID: "identity_event_fallback",
|
||||
SourceID: "open",
|
||||
}
|
||||
if got := personalBusProfileSelector(t.TempDir(), identity); got != "corp_event_fallback:identity_event_fallback" {
|
||||
t.Fatalf("personal bus fallback selector = %q", got)
|
||||
}
|
||||
args := personalBusSpawnArgs(identity, "", "", " ")
|
||||
if got := strings.Join(args, " "); !strings.Contains(got, "--profile corp_event_fallback:identity_event_fallback") {
|
||||
t.Fatalf("personal bus default profile args = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 (
|
||||
"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 TestPATFreshAuthorizationSaveUsesLoginIsolationBoundary(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
const (
|
||||
corpID = "corp_pat_login_boundary"
|
||||
userID = "exact-user"
|
||||
)
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{
|
||||
{Name: "External Account", CorpID: corpID, CorpName: "PAT Boundary Organization"},
|
||||
{Name: "Exact Account", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
cfg.PrimaryProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
blank := &authpkg.TokenData{AccessToken: "existing-unresolved", CorpID: corpID, CorpName: "PAT Boundary Organization"}
|
||||
exact := &authpkg.TokenData{AccessToken: "existing-exact", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID}
|
||||
if err := authpkg.SaveTokenDataKeychainForCorpID(corpID, blank); err != nil {
|
||||
t.Fatalf("save unresolved token: %v", err)
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForIdentity(corpID, userID, exact); err != nil {
|
||||
t.Fatalf("save exact token: %v", err)
|
||||
}
|
||||
previousRuntimeProfile := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile(previousRuntimeProfile) })
|
||||
|
||||
fresh := &authpkg.TokenData{AccessToken: "pat-fresh-unknown", CorpID: corpID, CorpName: "PAT Boundary Organization"}
|
||||
err := patSaveTokenData(configDir, fresh)
|
||||
if err == nil || !strings.Contains(err.Error(), "fresh UID-less token") {
|
||||
t.Fatalf("patSaveTokenData() error = %v, want unresolved-sibling protection", err)
|
||||
}
|
||||
persisted, loadErr := authpkg.LoadTokenDataKeychainForCorpID(corpID)
|
||||
if loadErr != nil || persisted.AccessToken != blank.AccessToken || persisted.UserID != "" {
|
||||
t.Fatalf("PAT save changed unresolved sibling: token=%#v err=%v", persisted, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualLoginSaveRepairsHalfMigratedGlobalBeforeOverwrite(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
const (
|
||||
corpID = "corp_manual_login_boundary"
|
||||
userID = "legacy-user"
|
||||
)
|
||||
selector := corpID + ":" + userID
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
CurrentProfile: selector,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Legacy Exact Account", CorpID: corpID, CorpName: "Manual Boundary Organization", UserID: userID,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
legacy := &authpkg.TokenData{AccessToken: "only-legacy-copy", CorpID: corpID, CorpName: "Manual Boundary Organization"}
|
||||
if err := authpkg.SaveTokenDataKeychain(legacy); err != nil {
|
||||
t.Fatalf("save half-migrated global: %v", err)
|
||||
}
|
||||
manual := &authpkg.TokenData{AccessToken: "manual-default", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := authSaveTokenData(configDir, manual); err != nil {
|
||||
t.Fatalf("authSaveTokenData(manual) error = %v", err)
|
||||
}
|
||||
org, err := authpkg.LoadTokenDataKeychainForCorpID(corpID)
|
||||
if err != nil || org.AccessToken != legacy.AccessToken || org.UserID != "" {
|
||||
t.Fatalf("organization repair = %#v, %v", org, err)
|
||||
}
|
||||
identity, err := authpkg.LoadTokenDataKeychainForIdentity(corpID, userID)
|
||||
if err != nil || identity.AccessToken != legacy.AccessToken || identity.UserID != userID {
|
||||
t.Fatalf("identity repair = %#v, %v", identity, err)
|
||||
}
|
||||
global, err := authpkg.LoadTokenDataKeychain()
|
||||
if err != nil || global.AccessToken != manual.AccessToken || global.CorpID != "" {
|
||||
t.Fatalf("manual global = %#v, %v", global, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
func blankProfileSelectorAppFixture(blankName, corpName string) *authpkg.ProfilesConfig {
|
||||
const (
|
||||
corpID = "corp_selector_fixture"
|
||||
exactUserID = "identity_exact_fixture"
|
||||
)
|
||||
exactSelector := corpID + ":" + exactUserID
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
PrimaryProfile: exactSelector,
|
||||
PreviousProfile: exactSelector,
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
corpID: exactSelector,
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "Exact Fixture Account",
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: exactUserID,
|
||||
UserName: "Exact Fixture Account",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: blankName,
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func captureProfileListSelectors(t *testing.T, cfg *authpkg.ProfilesConfig) ([]string, []profileView) {
|
||||
t.Helper()
|
||||
originalLoadToken := profileLoadTokenData
|
||||
selectors := make([]string, 0, len(cfg.Profiles))
|
||||
profileLoadTokenData = func(_ string, selector string) (*authpkg.TokenData, error) {
|
||||
selectors = append(selectors, selector)
|
||||
return nil, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
t.Cleanup(func() { profileLoadTokenData = originalLoadToken })
|
||||
views := profileViews("unused-config-dir", cfg)
|
||||
return selectors, views
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsThroughListAndTUI(t *testing.T) {
|
||||
cfg := blankProfileSelectorAppFixture("Fixture Organization", "Fixture Organization")
|
||||
blank := cfg.Profiles[1]
|
||||
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
|
||||
|
||||
if blankSelector == blank.Name || blankSelector == blank.CorpID {
|
||||
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", blankSelector)
|
||||
}
|
||||
if got := profileCLISelector(blank, cfg); got != blankSelector {
|
||||
t.Errorf("profileCLISelector(blank) = %q, want %q", got, blankSelector)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
|
||||
t.Errorf("profileSwitchProfileIndex(blank current) = %d, want 1", got)
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
|
||||
if model.selected != 1 {
|
||||
t.Errorf("TUI selected index = %d, want blank profile index 1", model.selected)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != blankSelector {
|
||||
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
selectors, views := captureProfileListSelectors(t, cfg)
|
||||
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
|
||||
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
|
||||
}
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("profile list views = %#v, want two entries", views)
|
||||
}
|
||||
if views[0].IsCurrent {
|
||||
t.Error("exact account should not be marked current when blank local selector is current")
|
||||
}
|
||||
if views[1].Profile != blankSelector || !views[1].IsCurrent {
|
||||
t.Errorf("blank list view = %#v, want local selector marked current", views[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentityParsingInListAndTUI(t *testing.T) {
|
||||
cfg := blankProfileSelectorAppFixture("legacy:outsourced", "Fixture Organization")
|
||||
blank := cfg.Profiles[1]
|
||||
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
|
||||
|
||||
if blankSelector == blank.Name {
|
||||
t.Fatalf("colon-containing name leaked as selector %q", blankSelector)
|
||||
}
|
||||
if _, _, parsedAsIdentity := authpkg.ParseIdentitySelector(blankSelector); parsedAsIdentity {
|
||||
t.Fatalf("stable blank selector %q was parsed as an identity", blankSelector)
|
||||
}
|
||||
if got := profileCLISelector(blank, cfg); got != blankSelector {
|
||||
t.Errorf("profileCLISelector(colon blank) = %q, want %q", got, blankSelector)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
|
||||
t.Errorf("profileSwitchProfileIndex(colon blank current) = %d, want 1", got)
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
|
||||
if model.selected != 1 {
|
||||
t.Errorf("TUI selected index = %d, want colon-name blank profile index 1", model.selected)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != blankSelector {
|
||||
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
selectors, views := captureProfileListSelectors(t, cfg)
|
||||
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
|
||||
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
|
||||
}
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("profile list views = %#v, want two entries", views)
|
||||
}
|
||||
if views[0].IsCurrent {
|
||||
t.Error("exact account should not be marked current when colon-name blank selector is current")
|
||||
}
|
||||
if views[1].Profile != blankSelector || !views[1].IsCurrent {
|
||||
t.Errorf("colon-name blank list view = %#v, want local selector marked current", views[1])
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
@@ -247,7 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -304,7 +305,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -869,7 +870,44 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
||||
func personalBusProfileSelector(configDir string, identity personal.Identity) string {
|
||||
// The parent already resolved and loaded this selector. Preserve it before
|
||||
// consulting identity metadata: personal event discovery can fill an empty
|
||||
// token userId from runtime defaults, and that inferred value must not turn a
|
||||
// historical unresolved account into a different exact same-corp account in
|
||||
// the detached child.
|
||||
if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" {
|
||||
return selector
|
||||
}
|
||||
if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil {
|
||||
// With no explicit process-local override, LoadTokenData selected the
|
||||
// persisted current profile. Prefer that selection over the enriched
|
||||
// identity: $currentUserId may describe an exact same-corp account even
|
||||
// though the token came from the historical unresolved profile.
|
||||
currentSelector := strings.TrimSpace(cfg.CurrentProfile)
|
||||
for i := range cfg.Profiles {
|
||||
profile := cfg.Profiles[i]
|
||||
selector := authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
if selector == currentSelector &&
|
||||
(strings.TrimSpace(identity.CorpID) == "" || strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID)) {
|
||||
return selector
|
||||
}
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
profile := cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) &&
|
||||
strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) {
|
||||
return authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return authpkg.ProfileSelector(authpkg.Profile{
|
||||
CorpID: identity.CorpID,
|
||||
UserID: identity.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string {
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
@@ -878,10 +916,11 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
// credentials as the parent, including when one organization has multiple
|
||||
// logged-in users.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
|
||||
CorpID: identity.CorpID,
|
||||
UserID: identity.UserID,
|
||||
}))
|
||||
profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID})
|
||||
if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" {
|
||||
profileSelector = strings.TrimSpace(profileSelectors[0])
|
||||
}
|
||||
args = append(args, "--profile", profileSelector)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestPersonalBusProfileSelectorUsesDefaultBlankCurrentBeforeRuntimeEnrichedIdentity(t *testing.T) {
|
||||
configDir, cfg, blankSelector, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identityAfterRuntimeEnrichment := personal.Identity{
|
||||
CorpID: cfg.Profiles[0].CorpID,
|
||||
UserID: cfg.Profiles[1].UserID,
|
||||
}
|
||||
if got := personalBusProfileSelector(configDir, identityAfterRuntimeEnrichment); got != blankSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want default blank selector %q", got, blankSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBusProfileSelectorUsesDefaultExactCurrent(t *testing.T) {
|
||||
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = exactSelector
|
||||
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want default exact selector %q", got, exactSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector(t *testing.T) {
|
||||
configDir, cfg, blankSelector, _ := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
const explicitSelector = "corp_explicit:user_explicit"
|
||||
authpkg.SetRuntimeProfile(explicitSelector)
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != explicitSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want explicit selector %q", got, explicitSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusProfileSelectorFallsBackToMatchingIdentity(t *testing.T) {
|
||||
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
Name: "Other Current",
|
||||
CorpID: "corp_event_other_fixture",
|
||||
CorpName: "Other Fixture Organization",
|
||||
UserID: "identity_event_other_fixture",
|
||||
})
|
||||
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[2], cfg)
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want identity fallback %q", got, exactSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func seedPersonalBusProfileSelectorConfig(t *testing.T) (string, *authpkg.ProfilesConfig, string, string) {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "External Fixture",
|
||||
CorpID: "corp_event_current_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
},
|
||||
{
|
||||
Name: "Exact Fixture",
|
||||
CorpID: "corp_event_current_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_runtime_enriched_fixture",
|
||||
},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
exactSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
|
||||
if blankSelector == "" || blankSelector == cfg.Profiles[0].CorpID {
|
||||
t.Fatalf("blank selector = %q, want stable account selector", blankSelector)
|
||||
}
|
||||
return configDir, cfg, blankSelector, exactSelector
|
||||
}
|
||||
@@ -13,14 +13,18 @@ import (
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
markdown := &cobra.Command{Use: "markdown"}
|
||||
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(event, unregistered)
|
||||
root.AddCommand(event, markdown, unregistered)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if event.Hidden {
|
||||
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if markdown.Hidden {
|
||||
t.Fatal("locally routed markdown command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if !unregistered.Hidden {
|
||||
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
@@ -63,3 +65,100 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_event_fixture": "corp_event_fixture:identity_exact_fixture",
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "Fixture Organization",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
},
|
||||
{
|
||||
Name: "Exact Fixture Account",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_exact_fixture",
|
||||
},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
cfg.PrimaryProfile = blankSelector
|
||||
cfg.CurrentProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
blankToken := &authpkg.TokenData{
|
||||
AccessToken: "parent-blank-token",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
}
|
||||
exactToken := &authpkg.TokenData{
|
||||
AccessToken: "other-exact-token",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_exact_fixture",
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForCorpID(blankToken.CorpID, blankToken); err != nil {
|
||||
t.Fatalf("save parent blank token: %v", err)
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForIdentity(exactToken.CorpID, exactToken.UserID, exactToken); err != nil {
|
||||
t.Fatalf("save other exact token: %v", err)
|
||||
}
|
||||
|
||||
// Runtime identity enrichment points at the exact sibling, but the parent
|
||||
// already loaded the persisted blank current profile.
|
||||
identity := personal.Identity{
|
||||
CorpID: "corp_event_fixture",
|
||||
UserID: "identity_exact_fixture",
|
||||
SourceID: "open",
|
||||
}
|
||||
selector := personalBusProfileSelector(configDir, identity)
|
||||
want := blankSelector
|
||||
if selector != want || selector == identity.CorpID {
|
||||
t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want)
|
||||
}
|
||||
args := personalBusSpawnArgs(identity, "", "", selector)
|
||||
forwardedSelector := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == want {
|
||||
forwardedSelector = args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if forwardedSelector == "" {
|
||||
t.Fatalf("spawn args did not preserve reserved blank selector: %v", args)
|
||||
}
|
||||
parentToken, err := authpkg.LoadTokenDataForProfile(configDir, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("load parent token: %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile(forwardedSelector)
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
childToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load detached child token: %v", err)
|
||||
}
|
||||
if parentToken.AccessToken != blankToken.AccessToken ||
|
||||
childToken.AccessToken != parentToken.AccessToken ||
|
||||
childToken.UserID != "" {
|
||||
t.Fatalf("parent/child token drift: parent=%#v child=%#v", parentToken, childToken)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(want)
|
||||
inferredExact := personal.Identity{
|
||||
CorpID: "corp_event_fixture",
|
||||
UserID: "identity_exact_fixture",
|
||||
SourceID: "open",
|
||||
}
|
||||
if got := personalBusProfileSelector(configDir, inferredExact); got != want {
|
||||
t.Fatalf("runtime blank selector changed after inferred userId: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpMetaServerID = "mcp-meta"
|
||||
mcpMetaURLTool = "get_mcp_server_url"
|
||||
)
|
||||
|
||||
func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
Use: "url",
|
||||
Short: "管理 MCP 服务连接地址",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
group.AddCommand(newMCPURLGetCommand(caller))
|
||||
return group
|
||||
}
|
||||
|
||||
func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <mcpId>",
|
||||
Short: "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
|
||||
Long: "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 " +
|
||||
"Streamable HTTP 服务地址。\n\n" +
|
||||
"安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用," +
|
||||
"请勿分享到群聊、文档、邮件、代码仓库或日志。",
|
||||
Example: " dws mcp url get 2480\n" +
|
||||
" dws mcp url get 2480 --format json",
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if caller == nil {
|
||||
return fmt.Errorf("MCP tool caller is not configured")
|
||||
}
|
||||
mcpID := strings.TrimSpace(args[0])
|
||||
if mcpID == "" {
|
||||
return fmt.Errorf("mcpId 不能为空")
|
||||
}
|
||||
|
||||
result, err := caller.CallTool(cmd.Context(), mcpMetaServerID, mcpMetaURLTool, map[string]any{
|
||||
"mcpId": mcpID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 MCP 服务地址: %w", err)
|
||||
}
|
||||
return writeMCPURLResult(cmd, result)
|
||||
},
|
||||
}
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
Name: "mcp_id",
|
||||
Type: "string",
|
||||
Description: "钉钉 MCP 市场中的 mcpId",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writeMCPURLResult(cmd *cobra.Command, result *edition.ToolResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("MCP 元服务返回空结果")
|
||||
}
|
||||
// get_mcp_server_url returns one JSON document in its first non-empty text
|
||||
// block. Other block types and trailing blocks are intentionally ignored.
|
||||
for _, block := range result.Content {
|
||||
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if err := apperrors.ClassifyMCPResponseText(block.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
|
||||
return fmt.Errorf("MCP 元服务返回了无效 JSON: %w", err)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
}
|
||||
return fmt.Errorf("MCP 元服务返回空结果")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mcpURLTestCaller struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *mcpURLTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.productID = productID
|
||||
c.toolName = toolName
|
||||
c.args = args
|
||||
return c.result, c.err
|
||||
}
|
||||
|
||||
func (*mcpURLTestCaller) Format() string { return "json" }
|
||||
func (*mcpURLTestCaller) DryRun() bool { return false }
|
||||
func (*mcpURLTestCaller) Fields() string { return "" }
|
||||
func (*mcpURLTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeMCPURLCommand(t *testing.T, caller edition.ToolCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "mcp", SilenceErrors: true, SilenceUsage: true}
|
||||
root.AddCommand(newMCPURLGroup(caller))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
err := root.ExecuteContext(t.Context())
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestMCPURLGetCallsMetaServerAndPreservesResponse(t *testing.T) {
|
||||
const response = `{"result":{"mcpURL":"https://example.test/mcp?key=one&token=two","mcpJSON":{"transport":"streamable-http"},"name":"Example"}}`
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}},
|
||||
}
|
||||
|
||||
out, err := executeMCPURLCommand(t, caller, "url", "get", " 10043 ")
|
||||
if err != nil {
|
||||
t.Fatalf("execute mcp url get: %v", err)
|
||||
}
|
||||
if caller.productID != mcpMetaServerID {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, mcpMetaServerID)
|
||||
}
|
||||
if caller.toolName != mcpMetaURLTool {
|
||||
t.Fatalf("toolName = %q, want %q", caller.toolName, mcpMetaURLTool)
|
||||
}
|
||||
if got := caller.args["mcpId"]; got != "10043" {
|
||||
t.Fatalf("mcpId = %#v, want %q", got, "10043")
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
result, ok := payload["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("output result = %#v", payload["result"])
|
||||
}
|
||||
if got := result["mcpURL"]; got != "https://example.test/mcp?key=one&token=two" {
|
||||
t.Fatalf("result.mcpURL = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsBlankID(t *testing.T) {
|
||||
_, err := executeMCPURLCommand(t, &mcpURLTestCaller{}, "url", "get", " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcpId 不能为空") {
|
||||
t.Fatalf("error = %v, want blank mcpId error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGroupShowsHelp(t *testing.T) {
|
||||
out, err := executeMCPURLCommand(t, nil, "url")
|
||||
if err != nil {
|
||||
t.Fatalf("execute mcp url: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "get") {
|
||||
t.Fatalf("help output does not list get command:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsMissingCaller(t *testing.T) {
|
||||
_, err := executeMCPURLCommand(t, nil, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "caller is not configured") {
|
||||
t.Fatalf("error = %v, want missing caller error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetPropagatesCallError(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{err: errors.New("permission denied")}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("error = %v, want call error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsInvalidJSON(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "not-json"}}},
|
||||
}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "无效 JSON") {
|
||||
t.Fatalf("error = %v, want invalid JSON error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsEmptyResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *edition.ToolResult
|
||||
}{
|
||||
{name: "nil result"},
|
||||
{
|
||||
name: "no usable text content",
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{
|
||||
{Type: "image", Text: "ignored"},
|
||||
{Type: "text", Text: " "},
|
||||
}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{result: tt.result}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "返回空结果") {
|
||||
t.Fatalf("error = %v, want empty result error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetClassifiesBusinessError(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"success":false,"errorMsg":"搜索内容不能为空"}`,
|
||||
}}},
|
||||
}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil {
|
||||
t.Fatal("expected classified business error")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "business_error" {
|
||||
t.Fatalf("error = %#v, want classified business error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRegistersMCPURLGet(t *testing.T) {
|
||||
root := NewRootCommand(t.Context())
|
||||
mcp, _, err := root.Find([]string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mcp: %v", err)
|
||||
}
|
||||
if mcp.Hidden {
|
||||
t.Fatal("mcp command must be public when it contains reviewed public helpers")
|
||||
}
|
||||
cmd, _, err := root.Find([]string{"mcp", "url", "get"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mcp url get: %v", err)
|
||||
}
|
||||
if got := cmd.CommandPath(); got != "dws mcp url get" {
|
||||
t.Fatalf("command path = %q, want %q", got, "dws mcp url get")
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,45 @@ func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRunnerDeduplicatesReservedAndOrganizationAliasesForBlankProfile(t *testing.T) {
|
||||
exact := authLogoutTestToken("corp_blank_alias")
|
||||
exact.UserID = "identity_exact_alias"
|
||||
configDir := setupAuthLogoutProfiles(t, exact)
|
||||
blank := authLogoutTestToken("corp_blank_alias")
|
||||
blank.AccessToken = "access-unresolved-alias"
|
||||
blank.RefreshToken = "refresh-unresolved-alias"
|
||||
blank.UserID = ""
|
||||
blank.UserName = ""
|
||||
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
|
||||
t.Fatalf("SaveTokenData(blank) error = %v", err)
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
var reserved string
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID == blank.CorpID && profile.UserID == "" {
|
||||
reserved = authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
break
|
||||
}
|
||||
}
|
||||
if reserved == "" || reserved == blank.CorpID {
|
||||
t.Fatalf("blank selector = %q, want reserved selector", reserved)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, reserved+","+blank.CorpID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi || len(selections) != 1 {
|
||||
t.Fatalf("blank aliases = multi %v selections %#v, want one identity", multi, selections)
|
||||
}
|
||||
if selections[0].Selector != reserved || selections[0].Profile.UserID != "" {
|
||||
t.Fatalf("blank selection = %#v, want first reserved alias preserved", selections[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
@@ -61,7 +61,7 @@ var (
|
||||
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||
patSaveAppConfig = authpkg.SaveAppConfig
|
||||
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
|
||||
patSaveTokenData = authpkg.SaveTokenData
|
||||
patSaveTokenData = authpkg.SaveLoginTokenData
|
||||
patSleep = time.Sleep
|
||||
patPollHTTPDo = (*http.Client).Do
|
||||
patPollNewRequest = http.NewRequestWithContext
|
||||
|
||||
@@ -266,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = authpkg.ProfileSelector(cfg.Profiles[0])
|
||||
choice = authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
}
|
||||
return profileSwitchTUIRunner(cmd, cfg, choice)
|
||||
}
|
||||
@@ -428,11 +428,36 @@ func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return authpkg.ProfileSelector(m.profiles[m.selected])
|
||||
selected := m.profiles[m.selected]
|
||||
return authpkg.ProfileSelectionSelector(selected, &authpkg.ProfilesConfig{Profiles: m.profiles})
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
|
||||
selector = strings.TrimSpace(selector)
|
||||
for i, profile := range profiles {
|
||||
if authpkg.ProfileSelectionSelector(profile, cfg) == selector {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// Accept an old current/previous pointer long enough for the migration path
|
||||
// to canonicalize it. Only an unresolved profile in a multi-account
|
||||
// organization qualifies, so ordinary exact account names cannot capture an
|
||||
// identity selector that contains ':'.
|
||||
legacyBlank := -1
|
||||
for i, profile := range profiles {
|
||||
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != selector ||
|
||||
profileCountForCorp(cfg, profile.CorpID) <= 1 {
|
||||
continue
|
||||
}
|
||||
if legacyBlank >= 0 {
|
||||
legacyBlank = -1
|
||||
break
|
||||
}
|
||||
legacyBlank = i
|
||||
}
|
||||
if legacyBlank >= 0 {
|
||||
return legacyBlank
|
||||
}
|
||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
|
||||
@@ -443,6 +468,9 @@ func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg
|
||||
}
|
||||
fallback := -1
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(p.Name) == selector {
|
||||
return i
|
||||
}
|
||||
if strings.TrimSpace(p.CorpID) == selector {
|
||||
if fallback < 0 {
|
||||
fallback = i
|
||||
@@ -486,7 +514,7 @@ func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
|
||||
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, cfg, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
@@ -636,13 +664,14 @@ func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesC
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
selector := profileCLISelector(p, cfg)
|
||||
view := profileViewFromProfile(
|
||||
p,
|
||||
cfg,
|
||||
cfg.PrimaryProfile,
|
||||
cfg.CurrentProfile,
|
||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||
loadProfileTokenState(configDir, p),
|
||||
loadProfileTokenState(configDir, p, selector),
|
||||
)
|
||||
current := ""
|
||||
if view.IsCurrent {
|
||||
@@ -698,13 +727,14 @@ func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
selector := profileCLISelector(p, cfg)
|
||||
views = append(views, profileViewFromProfile(
|
||||
p,
|
||||
cfg,
|
||||
cfg.PrimaryProfile,
|
||||
cfg.CurrentProfile,
|
||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||
loadProfileTokenState(configDir, p),
|
||||
loadProfileTokenState(configDir, p, selector),
|
||||
))
|
||||
}
|
||||
return views
|
||||
@@ -719,7 +749,7 @@ func profileViewFromProfile(
|
||||
) profileView {
|
||||
isOrgCurrent := profileIsOrgCurrent(p, cfg)
|
||||
view := profileView{
|
||||
Profile: authpkg.ProfileSelector(p),
|
||||
Profile: profileCLISelector(p, cfg),
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
@@ -731,8 +761,8 @@ func profileViewFromProfile(
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg),
|
||||
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg),
|
||||
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||||
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||||
IsOrgCurrent: isOrgCurrent,
|
||||
}
|
||||
if tokenState != nil {
|
||||
@@ -743,8 +773,12 @@ func profileViewFromProfile(
|
||||
return view
|
||||
}
|
||||
|
||||
func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState {
|
||||
data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile))
|
||||
func loadProfileTokenState(configDir string, profile authpkg.Profile, selectors ...string) *profileTokenState {
|
||||
selector := authpkg.ProfileSelector(profile)
|
||||
if len(selectors) > 0 && strings.TrimSpace(selectors[0]) != "" {
|
||||
selector = strings.TrimSpace(selectors[0])
|
||||
}
|
||||
data, err := profileLoadTokenData(configDir, selector)
|
||||
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
|
||||
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
|
||||
}
|
||||
@@ -762,6 +796,10 @@ func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTo
|
||||
}
|
||||
}
|
||||
|
||||
func profileCLISelector(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
return authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
}
|
||||
|
||||
func profileTokenTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
@@ -769,8 +807,11 @@ func profileTokenTime(value time.Time) string {
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, isOrgCurrent, onlyAccountInOrg bool) bool {
|
||||
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, cfg *authpkg.ProfilesConfig, isOrgCurrent, onlyAccountInOrg bool) bool {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == authpkg.ProfileSelectionSelector(profile, cfg) {
|
||||
return true
|
||||
}
|
||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
|
||||
@@ -385,11 +385,16 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
// The legacy dynamic MCP surface remains disabled, but reviewed static MCP
|
||||
// helpers registered below are part of the public CLI and Schema surface.
|
||||
mcpCmd.Hidden = false
|
||||
mcpCmd.Short = "管理 MCP 服务连接信息"
|
||||
mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。"
|
||||
// Wrap the caller so every MCP tool call's shape is recorded to the local
|
||||
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
|
||||
// `dws shortcut stats` and future high-frequency shortcut distillation.
|
||||
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
|
||||
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
@@ -619,6 +624,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
"profile": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"markdown": true,
|
||||
"recovery": true,
|
||||
"schema": true,
|
||||
"mcp": true,
|
||||
|
||||
@@ -15,11 +15,14 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -75,7 +78,14 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
|
||||
mediaGroup := mustFindCommand(t, root, "chat", "media")
|
||||
if mediaGroup.Deprecated == "" || mediaGroup.Hidden || !mediaGroup.Runnable() {
|
||||
t.Fatalf("chat media compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaGroup.Deprecated, mediaGroup.Hidden, mediaGroup.Runnable())
|
||||
}
|
||||
mediaUpload := mustFindCommand(t, mediaGroup, "upload")
|
||||
if mediaUpload.Deprecated == "" || mediaUpload.Hidden || !mediaUpload.Runnable() {
|
||||
t.Fatalf("chat media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaUpload.Deprecated, mediaUpload.Hidden, mediaUpload.Runnable())
|
||||
}
|
||||
for _, flag := range []string{"file", "type"} {
|
||||
if mediaUpload.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat media upload missing --%s", flag)
|
||||
@@ -88,6 +98,113 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
mustFindCommand(t, root, "conference", "meeting", "reserve")
|
||||
}
|
||||
|
||||
func TestChatHelpAndSchemaHideRetiredMediaUpload(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "--help"},
|
||||
{"chat", "media", "--help"},
|
||||
} {
|
||||
root := NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("dws %s: %v\n%s", strings.Join(args, " "), err, output.String())
|
||||
}
|
||||
for _, line := range strings.Split(output.String(), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && (fields[0] == "media" || fields[0] == "upload") {
|
||||
t.Fatalf("dws %s exposes retired command in Help line %q:\n%s", strings.Join(args, " "), line, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs([]string{"schema", "--cli-path", "chat media upload", "--format", "json"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("retired chat media upload remains queryable from Schema:\n%s", output.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown runtime schema path") {
|
||||
t.Fatalf("retired chat media upload Schema error = %v, want unknown path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *testing.T) {
|
||||
for _, key := range []string{"DWS_CLIENT_ID", "DWS_CLIENT_SECRET"} {
|
||||
value, existed := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
_ = os.Setenv(key, value)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
t.Fatalf("%s is still set", key)
|
||||
}
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "image.png")
|
||||
if err := os.WriteFile(filePath, []byte("image"), 0o600); err != nil {
|
||||
t.Fatalf("write image fixture: %v", err)
|
||||
}
|
||||
commandArgs := []string{
|
||||
"chat", "media", "upload",
|
||||
"--file", filePath,
|
||||
"--type", "image",
|
||||
}
|
||||
previousArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, commandArgs...)
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
|
||||
root := NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(commandArgs)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("chat media upload succeeded without app credentials:\n%s", output.String())
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("chat media upload error type = %T, want *errors.Error: %v", err, err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("chat media upload category = %q, want %q", typed.Category, apperrors.CategoryValidation)
|
||||
}
|
||||
if exitCode := apperrors.ExitCode(err); exitCode != 3 {
|
||||
t.Fatalf("chat media upload exit code = %d, want 3", exitCode)
|
||||
}
|
||||
|
||||
got := output.String() + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"DWS_CLIENT_ID",
|
||||
"DWS_CLIENT_SECRET",
|
||||
"缺少应用凭证",
|
||||
"AppSecret",
|
||||
"clientSecret",
|
||||
} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("chat media upload returned credential error %q:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
label := mustFindCommand(t, root, "contact", "label")
|
||||
|
||||
+11
-1
@@ -210,7 +210,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if profile == nil {
|
||||
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
|
||||
}
|
||||
authpkg.SetRuntimeProfile(authpkg.ProfileSelector(*profile))
|
||||
resolvedSelector := authpkg.ProfileSelector(*profile)
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
// Preserve a unique local-name selector for an unresolved account.
|
||||
// Reducing it to corpId can select a different exact account through
|
||||
// the organization's current-account pointer.
|
||||
resolvedSelector = rawProfile
|
||||
}
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
defer authpkg.SetRuntimeProfile(rawProfile)
|
||||
}
|
||||
|
||||
@@ -351,6 +358,9 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
|
||||
|
||||
for _, selection := range selections {
|
||||
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
||||
if strings.TrimSpace(selection.Profile.UserID) == "" {
|
||||
resolvedSelector = selection.Selector
|
||||
}
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const publicShortcutSchemaCount = 210
|
||||
|
||||
func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
public := make([]shortcut.Shortcut, 0, publicShortcutSchemaCount)
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
|
||||
continue
|
||||
}
|
||||
public = append(public, candidate)
|
||||
}
|
||||
if got := len(public); got != publicShortcutSchemaCount {
|
||||
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutSchemaCount)
|
||||
}
|
||||
|
||||
deliveredShortcuts := 0
|
||||
for canonical := range tools {
|
||||
if strings.Contains(canonical, ".shortcut_") {
|
||||
deliveredShortcuts++
|
||||
}
|
||||
}
|
||||
if deliveredShortcuts != publicShortcutSchemaCount {
|
||||
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, publicShortcutSchemaCount)
|
||||
}
|
||||
|
||||
for _, declared := range public {
|
||||
declared := declared
|
||||
t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) {
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("embedded schema --all is missing %s (%s %s)", canonical, declared.Service, declared.Command)
|
||||
}
|
||||
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
|
||||
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
|
||||
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "not_required"; got != want {
|
||||
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
|
||||
}
|
||||
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
|
||||
if required, _ := conversationID["required"].(bool); !required {
|
||||
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
|
||||
}
|
||||
if got := leaf["constraints"]; got != nil {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
|
||||
}
|
||||
|
||||
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
|
||||
wantConstraints := map[string]any{
|
||||
"require_one_of": [][]string{{"users", "rooms"}},
|
||||
}
|
||||
if got := constrainedLeaf["constraints"]; !schemaContractJSONEqual(got, wantConstraints) {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantConstraints)
|
||||
}
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 120; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
shortcutCount := 0
|
||||
for _, summary := range summaries {
|
||||
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
|
||||
shortcutCount++
|
||||
}
|
||||
}
|
||||
if shortcutCount != 42 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs(append([]string{"schema"}, append(args, "--format", "json")...))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute dws schema %q: %v; stderr=%s", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dws schema %q: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
|
||||
name := strings.ReplaceAll(strings.TrimPrefix(declared.Command, "+"), "-", "_")
|
||||
return declared.Service + ".shortcut_" + name
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutIdentityAndSelection(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
if got, want := schemaContractString(tool["canonical_path"]), canonical; got != want {
|
||||
t.Errorf("canonical_path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := schemaContractString(tool["primary_cli_path"]), declared.Service+" "+declared.Command; got != want {
|
||||
t.Errorf("%s primary_cli_path = %q, want %q", canonical, got, want)
|
||||
}
|
||||
if got, want := schemaContractString(tool["agent_summary"]), declared.Description; got != want {
|
||||
t.Errorf("%s agent_summary = %q, want %q", canonical, got, want)
|
||||
}
|
||||
if got, want := schemaContractStringSlice(tool["use_when"]), []string{declared.Intent}; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s use_when = %#v, want %#v", canonical, got, want)
|
||||
}
|
||||
if len(schemaContractStringSlice(tool["avoid_when"])) == 0 {
|
||||
t.Errorf("%s has no reviewed avoid_when", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 || len(examples) > 2 {
|
||||
t.Errorf("%s examples = %d, want 1..2", canonical, len(examples))
|
||||
}
|
||||
for _, example := range examples {
|
||||
if strings.Contains(example, "--yes") {
|
||||
t.Errorf("%s stores unsafe example %q", canonical, example)
|
||||
}
|
||||
if !strings.HasPrefix(example, "dws "+declared.Service+" "+declared.Command) {
|
||||
t.Errorf("%s example does not use its primary path: %q", canonical, example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutSafetyAndInterface(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
"confirmation": wantConfirmation,
|
||||
"idempotency": wantIdempotency,
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
} {
|
||||
if got := schemaContractString(tool[field]); got != want {
|
||||
t.Errorf("%s %s = %q, want %q", canonical, field, got, want)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(schemaContractString(tool["interface_reason"])) == "" {
|
||||
t.Errorf("%s has no reviewed composite interface reason", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutParameters(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
publicFlags := make([]shortcut.Flag, 0, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
t.Errorf("%s parameters = %d, want %d", canonical, got, want)
|
||||
}
|
||||
for _, flag := range publicFlags {
|
||||
parameter := parameters[flag.Name]
|
||||
if parameter == nil {
|
||||
t.Errorf("%s is missing parameter --%s", canonical, flag.Name)
|
||||
continue
|
||||
}
|
||||
flagType := flag.Type
|
||||
if flagType == "" {
|
||||
flagType = shortcut.FlagString
|
||||
}
|
||||
wantType := map[shortcut.FlagType]string{
|
||||
shortcut.FlagString: "string",
|
||||
shortcut.FlagBool: "boolean",
|
||||
shortcut.FlagInt: "integer",
|
||||
shortcut.FlagStringSlice: "array",
|
||||
}[flagType]
|
||||
if got := schemaContractString(parameter["type"]); got != wantType {
|
||||
t.Errorf("%s --%s type = %q, want %q", canonical, flag.Name, got, wantType)
|
||||
}
|
||||
if got, _ := parameter["required"].(bool); got != shortcutSchemaRequired(declared, flag.Name) {
|
||||
t.Errorf("%s --%s required = %t, want %t", canonical, flag.Name, got, shortcutSchemaRequired(declared, flag.Name))
|
||||
}
|
||||
if got, want := schemaContractString(parameter["default"]), shortcutSchemaDefault(flag); got != want {
|
||||
t.Errorf("%s --%s default = %q, want %q", canonical, flag.Name, got, want)
|
||||
}
|
||||
gotEnum := schemaContractStringSlice(parameter["enum"])
|
||||
if len(flag.Enum) == 0 {
|
||||
if len(gotEnum) != 0 {
|
||||
t.Errorf("%s --%s enum = %#v, want empty", canonical, flag.Name, gotEnum)
|
||||
}
|
||||
} else if !schemaContractJSONEqual(gotEnum, flag.Enum) {
|
||||
t.Errorf("%s --%s enum = %#v, want %#v", canonical, flag.Name, gotEnum, flag.Enum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutSchemaDefault(flag shortcut.Flag) string {
|
||||
value := strings.TrimSpace(flag.Default)
|
||||
switch flag.Type {
|
||||
case shortcut.FlagBool:
|
||||
if value != "true" {
|
||||
return ""
|
||||
}
|
||||
case shortcut.FlagInt:
|
||||
if value == "0" {
|
||||
return ""
|
||||
}
|
||||
case shortcut.FlagStringSlice:
|
||||
if value != "" {
|
||||
return "[" + value + "]"
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
for _, flag := range declared.Flags {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
public[flag.Name] = true
|
||||
}
|
||||
}
|
||||
for _, constraint := range declared.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintAtLeastOne && constraint.Kind != shortcut.ConstraintExactlyOne {
|
||||
continue
|
||||
}
|
||||
visible := make([]string, 0, len(constraint.Flags))
|
||||
for _, constrained := range constraint.Flags {
|
||||
if public[constrained] {
|
||||
visible = append(visible, constrained)
|
||||
}
|
||||
}
|
||||
if len(visible) == 1 && visible[0] == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutConstraints(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
public[flag.Name] = true
|
||||
}
|
||||
}
|
||||
want := map[string][][]string{}
|
||||
for _, constraint := range declared.Constraints {
|
||||
flags := make([]string, 0, len(constraint.Flags))
|
||||
for _, flagName := range constraint.Flags {
|
||||
if public[flagName] {
|
||||
flags = append(flags, flagName)
|
||||
}
|
||||
}
|
||||
switch constraint.Kind {
|
||||
case shortcut.ConstraintAtLeastOne:
|
||||
if len(flags) > 1 {
|
||||
want["require_one_of"] = append(want["require_one_of"], flags)
|
||||
}
|
||||
case shortcut.ConstraintExactlyOne:
|
||||
if len(flags) > 1 {
|
||||
want["require_one_of"] = append(want["require_one_of"], flags)
|
||||
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
|
||||
}
|
||||
case shortcut.ConstraintMutuallyExclusive:
|
||||
if len(flags) > 1 {
|
||||
want["mutually_exclusive"] = append(want["mutually_exclusive"], flags)
|
||||
}
|
||||
case shortcut.ConstraintCustom:
|
||||
for _, flagName := range flags {
|
||||
description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"])
|
||||
for _, requiredText := range []string{"原文不能为空", "不能重复"} {
|
||||
if !strings.Contains(description, requiredText) {
|
||||
t.Errorf("%s --%s description does not publish custom constraint %q: %q", canonical, flagName, requiredText, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s has unsupported declared shortcut constraint %q", canonical, constraint.Kind)
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
if got := tool["constraints"]; got != nil {
|
||||
t.Errorf("%s constraints = %#v, want omitted", canonical, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := tool["constraints"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s constraints = %s, want %s", canonical, mustShortcutJSON(got), mustShortcutJSON(want))
|
||||
}
|
||||
}
|
||||
|
||||
func mustShortcutJSON(value any) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -22,15 +26,92 @@ func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginReauthorizesAfterLoadFailureAndRejectsUnreadableTarget(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setLoginPreflightCredentials(t)
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v, want cause %v", err, want)
|
||||
oldLoad := oauthLoadToken
|
||||
oldOpenBrowser := oauthOpenBrowser
|
||||
oldExchange := oauthExchange
|
||||
oldCheckStatus := oauthCheckStatus
|
||||
oldSave := oauthSaveToken
|
||||
oldKeychainGet := authKeychainGet
|
||||
oldLoginTimeout := oauthLoginTimeout
|
||||
t.Cleanup(func() {
|
||||
oauthLoadToken = oldLoad
|
||||
oauthOpenBrowser = oldOpenBrowser
|
||||
oauthExchange = oldExchange
|
||||
oauthCheckStatus = oldCheckStatus
|
||||
oauthSaveToken = oldSave
|
||||
authKeychainGet = oldKeychainGet
|
||||
oauthLoginTimeout = oldLoginTimeout
|
||||
})
|
||||
oauthLoginTimeout = 2 * time.Second
|
||||
|
||||
loadErr := errors.New("keychain permission denied")
|
||||
targetErr := errors.New("target token ciphertext is unreadable")
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return nil, loadErr }
|
||||
|
||||
browserCalls := 0
|
||||
oauthOpenBrowser = func(authURL string) error {
|
||||
browserCalls++
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callbackURL := parsed.Query().Get("redirect_uri") + "?code=reauthorize"
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Get(callbackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return response.Body.Close()
|
||||
}
|
||||
|
||||
exchangeCalls := 0
|
||||
oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) {
|
||||
exchangeCalls++
|
||||
return &TokenData{
|
||||
AccessToken: "new-access",
|
||||
CorpID: "corp-target",
|
||||
UserID: "user-target",
|
||||
}, nil
|
||||
}
|
||||
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
|
||||
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
|
||||
}
|
||||
|
||||
targetReads := 0
|
||||
authKeychainGet = func(_ string, account string) (string, error) {
|
||||
if account == TokenAccountForIdentity("corp-target", "user-target") {
|
||||
targetReads++
|
||||
return "", targetErr
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
saveCalls := 0
|
||||
oauthSaveToken = func(string, *TokenData) error {
|
||||
saveCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
provider := NewOAuthProvider(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
provider.Output = io.Discard
|
||||
_, err := provider.Login(context.Background(), false)
|
||||
if !errors.Is(err, targetErr) {
|
||||
t.Fatalf("Login() error = %v, want target cause %v", err, targetErr)
|
||||
}
|
||||
if errors.Is(err, loadErr) {
|
||||
t.Fatalf("Login() returned stale load failure instead of reauthorizing: %v", err)
|
||||
}
|
||||
if browserCalls != 1 || exchangeCalls != 1 {
|
||||
t.Fatalf("authorization calls = browser:%d exchange:%d, want 1 each", browserCalls, exchangeCalls)
|
||||
}
|
||||
if targetReads != 1 {
|
||||
t.Fatalf("target slot reads = %d, want 1", targetReads)
|
||||
}
|
||||
if saveCalls != 0 {
|
||||
t.Fatalf("SaveTokenData calls = %d, want 0", saveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// The native coverage jobs intentionally execute only TestCrossPlatformCoverage
|
||||
// entry points. Keep the compatibility assertions below as independently named
|
||||
// regression tests for the stable make target, and exercise the same functions
|
||||
// here as isolated subtests so their cleanup hooks run between cases.
|
||||
func TestCrossPlatformCoverageAuthLegacyCompatibilityRegressions(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{"prepare unique global owner", TestPrepareLoginPersistenceRepairsOnlySafeGlobalOwner},
|
||||
{"token load isolation matrix", TestTokenLoadIsolationMatrix},
|
||||
{"reject future profiles before remote work", TestPersistingLoginFlowsRejectFutureProfilesBeforeRemoteWork},
|
||||
{"fresh UID-less isolation", TestFreshUIDLessExactLoginCannotOverwriteExistingUnresolvedProfile},
|
||||
{"reject mismatched exact switch", TestSetCurrentProfileRejectsMismatchedExactIdentitySlotBesideBlankProfile},
|
||||
{"reject unreadable previous identity", TestUsePreviousProfileRejectsUnreadableExactIdentityBesideBlankProfile},
|
||||
{"reauthorization guidance without profile", TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin},
|
||||
{"repair v3 unresolved profile", TestPrepareLoginPersistenceV3UnresolvedProfileRepair},
|
||||
{"device ignores unrelated unreadable profile", TestDeviceLoginIgnoresUnreadableUnrelatedProfile},
|
||||
{"reject unreadable global before login", TestPrepareLoginPersistenceUnreadableGlobalFailsClosedBeforeRemote},
|
||||
{"device validates resolved target", TestDeviceFlowChecksResolvedTargetBeforeSave},
|
||||
{"accept recoverable credential material", TestPrepareLoginPersistenceRequiresCredentialMaterialButNotValidity},
|
||||
{"auth code validates resolved target", TestExchangeAuthCodeChecksResolvedTargetBeforeSave},
|
||||
{"standalone exchange prepares and marks fresh", TestExchangeCodeForTokenPreparesBeforeRemoteAndMarksFresh},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, test.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHalfMigratedGlobalRepairRemainingEdges(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
|
||||
t.Run("nil global token", func(t *testing.T) {
|
||||
isolateHalfMigratedRepairHooks(t)
|
||||
profilesLoadLegacy = func() (*TokenData, error) { return nil, nil }
|
||||
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_nil_global"}}}
|
||||
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
|
||||
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global token without organization", func(t *testing.T) {
|
||||
isolateHalfMigratedRepairHooks(t)
|
||||
profilesLoadLegacy = func() (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "legacy"}, nil
|
||||
}
|
||||
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}}
|
||||
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
|
||||
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("orphan global organization", func(t *testing.T) {
|
||||
isolateHalfMigratedRepairHooks(t)
|
||||
profilesLoadLegacy = func() (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "legacy", CorpID: "corp_orphan"}, nil
|
||||
}
|
||||
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}}
|
||||
if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil {
|
||||
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identity repair write failure", func(t *testing.T) {
|
||||
isolateHalfMigratedRepairHooks(t)
|
||||
failure := errors.New("identity repair write failure")
|
||||
const corpID, userID = "corp_identity_repair", "user_identity_repair"
|
||||
profilesLoadLegacy = func() (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil
|
||||
}
|
||||
profilesLoadCorp = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "organization", CorpID: corpID}, nil
|
||||
}
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return failure }
|
||||
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID, UserID: userID}}}
|
||||
if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) {
|
||||
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("organization repair write failure", func(t *testing.T) {
|
||||
isolateHalfMigratedRepairHooks(t)
|
||||
failure := errors.New("organization repair write failure")
|
||||
const corpID = "corp_organization_repair"
|
||||
profilesLoadLegacy = func() (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil
|
||||
}
|
||||
profilesSaveCorp = func(string, *TokenData) error { return failure }
|
||||
cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID}}}
|
||||
if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) {
|
||||
t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil profile is not canonical", func(t *testing.T) {
|
||||
if loginProfileHasUsableCanonicalToken(nil, nil, nil) {
|
||||
t.Fatal("nil profile was treated as a usable canonical token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global write preflight reports unreadable slot", func(t *testing.T) {
|
||||
oldGet := authKeychainGet
|
||||
failure := errors.New("global ciphertext is unreadable")
|
||||
authKeychainGet = func(_, account string) (string, error) {
|
||||
if account == keychain.AccountToken {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
t.Cleanup(func() { authKeychainGet = oldGet })
|
||||
|
||||
err := preflightTokenWritePersistence(t.TempDir(), &TokenData{AccessToken: "fresh"})
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("preflightTokenWritePersistence() error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyProfileGuardRemainingEdges(t *testing.T) {
|
||||
t.Run("empty v3 registry normalizes and persists", func(t *testing.T) {
|
||||
oldLoad := profilesLoad
|
||||
oldSave := profilesSave
|
||||
cfg := &ProfilesConfig{Version: profilesUnresolvedSelectorVersion}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
|
||||
saves := 0
|
||||
profilesSave = func(string, *ProfilesConfig) error {
|
||||
saves++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
profilesLoad = oldLoad
|
||||
profilesSave = oldSave
|
||||
})
|
||||
|
||||
if err := ensureProfilesMigrationLocked(t.TempDir()); err != nil {
|
||||
t.Fatalf("ensureProfilesMigrationLocked() error = %v", err)
|
||||
}
|
||||
if cfg.Version != profilesVersion || saves != 1 {
|
||||
t.Fatalf("normalized registry = version %d saves %d", cfg.Version, saves)
|
||||
}
|
||||
})
|
||||
|
||||
if normalizeProfilesVersionForSelectors(nil) {
|
||||
t.Fatal("nil profile registry reported a version change")
|
||||
}
|
||||
future := &ProfilesConfig{Version: profilesMaxVersion + 1}
|
||||
if normalizeProfilesVersionForSelectors(future) {
|
||||
t.Fatal("future profile registry reported a version change")
|
||||
}
|
||||
if profilesConfigContainsUnresolvedSelector(nil) {
|
||||
t.Fatal("nil profile registry contained an unresolved selector")
|
||||
}
|
||||
reserved := unresolvedProfileSelector("corp_org_current_guard")
|
||||
if !profilesConfigContainsUnresolvedSelector(&ProfilesConfig{
|
||||
OrgCurrentProfiles: map[string]string{"corp_org_current_guard": reserved},
|
||||
}) {
|
||||
t.Fatal("reserved organization-current selector was not detected")
|
||||
}
|
||||
if selectorConflictsWithOrganizationGrammar(nil, "corp") {
|
||||
t.Fatal("nil profile registry reported an organization-selector conflict")
|
||||
}
|
||||
if err := validateIdentityOnlyProfileToken(Profile{}); !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("validateIdentityOnlyProfileToken(blank) error = %v", err)
|
||||
}
|
||||
|
||||
oldLoadIdentity := profilesLoadIdentity
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, nil }
|
||||
t.Cleanup(func() { profilesLoadIdentity = oldLoadIdentity })
|
||||
if err := validateIdentityOnlyProfileToken(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"}); !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("validateIdentityOnlyProfileToken(nil token) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTokenPersistenceRemainingEdges(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
|
||||
plan := planTokenPersistenceWrites(&ProfilesConfig{}, nil, "")
|
||||
if !plan.WriteGlobal || plan.CorpID != "" {
|
||||
t.Fatalf("nil-token write plan = %#v", plan)
|
||||
}
|
||||
if err := SaveLoginTokenData(t.TempDir(), nil); err == nil {
|
||||
t.Fatal("SaveLoginTokenData(nil) succeeded")
|
||||
}
|
||||
|
||||
futureDir := t.TempDir()
|
||||
writeFutureProfilesForLoginPreflight(t, futureDir)
|
||||
err := SaveLoginTokenData(futureDir, &TokenData{AccessToken: "fresh"})
|
||||
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
|
||||
t.Fatalf("SaveLoginTokenData(future schema) error = %v", err)
|
||||
}
|
||||
|
||||
t.Run("nil identity token", func(t *testing.T) {
|
||||
isolateTokenProfileLoadHooks(t)
|
||||
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, nil }
|
||||
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"})
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil organization fallback", func(t *testing.T) {
|
||||
isolateTokenProfileLoadHooks(t)
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil }
|
||||
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_org", UserID: "user_nil_org"})
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("organization fallback belongs to another organization", func(t *testing.T) {
|
||||
isolateTokenProfileLoadHooks(t)
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "wrong", CorpID: "corp_other", UserID: "user_wrong_org"}, nil
|
||||
}
|
||||
_, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_expected", UserID: "user_wrong_org"})
|
||||
if err == nil || !strings.Contains(err.Error(), "contains token for corpId") {
|
||||
t.Fatalf("tokenLoadProfileIdentity() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identity repair write failure", func(t *testing.T) {
|
||||
isolateTokenProfileLoadHooks(t)
|
||||
failure := errors.New("identity repair write failure")
|
||||
const corpID, userID = "corp_identity_save", "user_identity_save"
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) {
|
||||
return &TokenData{AccessToken: "organization", CorpID: corpID, UserID: userID}, nil
|
||||
}
|
||||
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return failure }
|
||||
_, err := tokenLoadProfileIdentity(Profile{CorpID: corpID, UserID: userID})
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("tokenLoadProfileIdentity() error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isolateHalfMigratedRepairHooks(t *testing.T) {
|
||||
t.Helper()
|
||||
oldLoadLegacy := profilesLoadLegacy
|
||||
oldLoadCorp := profilesLoadCorp
|
||||
oldLoadIdentity := profilesLoadIdentity
|
||||
oldSaveCorp := profilesSaveCorp
|
||||
oldSaveIdentity := profilesSaveIdentity
|
||||
t.Cleanup(func() {
|
||||
profilesLoadLegacy = oldLoadLegacy
|
||||
profilesLoadCorp = oldLoadCorp
|
||||
profilesLoadIdentity = oldLoadIdentity
|
||||
profilesSaveCorp = oldSaveCorp
|
||||
profilesSaveIdentity = oldSaveIdentity
|
||||
})
|
||||
profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesSaveCorp = func(string, *TokenData) error { return nil }
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
|
||||
}
|
||||
|
||||
func isolateTokenProfileLoadHooks(t *testing.T) {
|
||||
t.Helper()
|
||||
oldLoadIdentity := tokenLoadKeychainIdentity
|
||||
oldLoadCorp := tokenLoadKeychainForCorpID
|
||||
oldSaveIdentity := tokenSaveKeychainForIdentity
|
||||
t.Cleanup(func() {
|
||||
tokenLoadKeychainIdentity = oldLoadIdentity
|
||||
tokenLoadKeychainForCorpID = oldLoadCorp
|
||||
tokenSaveKeychainForIdentity = oldSaveIdentity
|
||||
})
|
||||
tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil }
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type blankProfileSelectorFixture struct {
|
||||
configDir string
|
||||
corpID string
|
||||
blankName string
|
||||
blankSelector string
|
||||
exactUserID string
|
||||
exactSelector string
|
||||
blankToken *TokenData
|
||||
exactToken *TokenData
|
||||
}
|
||||
|
||||
func seedBlankProfileSelectorFixture(
|
||||
t *testing.T,
|
||||
blankName string,
|
||||
corpName string,
|
||||
blankCurrent bool,
|
||||
) blankProfileSelectorFixture {
|
||||
t.Helper()
|
||||
cleanupKeychain(t)
|
||||
|
||||
configDir := t.TempDir()
|
||||
corpID := "corp_selector_fixture"
|
||||
exactUserID := "identity_exact_fixture"
|
||||
exactSelector := profileSelector(corpID, exactUserID)
|
||||
|
||||
blankToken := testToken("at_unresolved_fixture", corpID, corpName)
|
||||
blankToken.UserID = ""
|
||||
blankToken.UserName = ""
|
||||
exactToken := testToken("at_exact_fixture", corpID, corpName)
|
||||
exactToken.UserID = exactUserID
|
||||
exactToken.UserName = "Exact Fixture Account"
|
||||
|
||||
if err := SaveTokenDataKeychainForCorpID(corpID, blankToken); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID(blank) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity(corpID, exactUserID, exactToken); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity(exact) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenDataKeychain(exactToken); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychain(exact mirror) error = %v", err)
|
||||
}
|
||||
if err := WriteTokenMarker(configDir); err != nil {
|
||||
t.Fatalf("WriteTokenMarker() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
PrimaryProfile: exactSelector,
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
corpID: exactSelector,
|
||||
},
|
||||
Profiles: []Profile{
|
||||
{
|
||||
Name: blankName,
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
Status: ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: "Exact Fixture Account",
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: exactUserID,
|
||||
UserName: "Exact Fixture Account",
|
||||
Status: ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
blankSelector := ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
persistedBlankPointer := strings.TrimSpace(blankName)
|
||||
if selectorConflictsWithOrganizationGrammar(cfg, persistedBlankPointer) {
|
||||
// An ambiguous local name has always been captured by CorpId/CorpName
|
||||
// grammar in the public resolver. New writers must use the reserved
|
||||
// selector to preserve exact blank-profile intent without changing that
|
||||
// precedence.
|
||||
persistedBlankPointer = blankSelector
|
||||
if _, reserved := parseUnresolvedProfileSelector(persistedBlankPointer); reserved {
|
||||
cfg.Version = profilesUnresolvedSelectorVersion
|
||||
}
|
||||
}
|
||||
cfg.CurrentProfile = exactSelector
|
||||
cfg.PreviousProfile = persistedBlankPointer
|
||||
if blankCurrent {
|
||||
cfg.CurrentProfile = persistedBlankPointer
|
||||
cfg.PreviousProfile = exactSelector
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("json.MarshalIndent(profiles) error = %v", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(ProfilesPath(configDir), data, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(profiles.json) error = %v", err)
|
||||
}
|
||||
|
||||
return blankProfileSelectorFixture{
|
||||
configDir: configDir,
|
||||
corpID: corpID,
|
||||
blankName: blankName,
|
||||
blankSelector: blankSelector,
|
||||
exactUserID: exactUserID,
|
||||
exactSelector: exactSelector,
|
||||
blankToken: blankToken,
|
||||
exactToken: exactToken,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameResolvesCurrentProfileExactly(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
|
||||
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileWithScope(current) error = %v", err)
|
||||
}
|
||||
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||
t.Fatalf("resolved current profile = %#v, want unresolved profile", selected)
|
||||
}
|
||||
if !exact {
|
||||
t.Fatal("current local-name selector should resolve one exact unresolved profile")
|
||||
}
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != fixture.blankSelector {
|
||||
t.Fatalf("current profile = %q, want stable unresolved selector %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameLoadsOrganizationToken(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||
|
||||
if fixture.blankSelector == fixture.blankName || fixture.blankSelector == fixture.corpID {
|
||||
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", fixture.blankSelector)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(blank local name) error = %v", err)
|
||||
}
|
||||
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
|
||||
t.Fatalf("loaded token = %#v, want unresolved organization token", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsPreviousProfile(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||
|
||||
selected, err := UsePreviousProfile(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UsePreviousProfile() error = %v", err)
|
||||
}
|
||||
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||
t.Fatalf("selected previous profile = %#v, want unresolved profile", selected)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != fixture.blankSelector || cfg.PreviousProfile != fixture.exactSelector {
|
||||
t.Fatalf(
|
||||
"profile pointers = current %q previous %q, want %q and %q",
|
||||
cfg.CurrentProfile,
|
||||
cfg.PreviousProfile,
|
||||
fixture.blankSelector,
|
||||
fixture.exactSelector,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameDeletesOnlyUnresolvedProfile(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||
|
||||
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
|
||||
t.Fatalf("DeleteTokenDataForProfile(blank local name) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != fixture.corpID || cfg.Profiles[0].UserID != fixture.exactUserID {
|
||||
t.Fatalf("profiles after blank deletion = %#v, want only exact account", cfg.Profiles)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.exactSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(exact after blank deletion) error = %v", err)
|
||||
}
|
||||
if loaded.UserID != fixture.exactUserID || loaded.AccessToken != fixture.exactToken.AccessToken {
|
||||
t.Fatalf("exact token after blank deletion = %#v, want exact account preserved", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentitySyntax(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "legacy:outsourced", "Fixture Organization", true)
|
||||
|
||||
if fixture.blankSelector == fixture.blankName {
|
||||
t.Fatalf("colon-containing name leaked as selector %q", fixture.blankSelector)
|
||||
}
|
||||
if _, _, parsedAsIdentity := ParseIdentitySelector(fixture.blankSelector); parsedAsIdentity {
|
||||
t.Fatalf("stable blank selector %q was parsed as an identity", fixture.blankSelector)
|
||||
}
|
||||
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileWithScope(colon local name) error = %v", err)
|
||||
}
|
||||
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||
t.Fatalf("resolved colon-name profile = %#v, want unresolved profile", selected)
|
||||
}
|
||||
if !exact {
|
||||
t.Fatal("colon-containing local name should resolve one exact unresolved profile")
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != fixture.blankSelector {
|
||||
t.Fatalf("current profile = %q, want migrated colon-name selector %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(colon local name) error = %v", err)
|
||||
}
|
||||
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
|
||||
t.Fatalf("loaded colon-name token = %#v, want unresolved organization token", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRealExactSelectorWinsOverMatchingBlankLegacyName(t *testing.T) {
|
||||
const exactSelector = "corp_selector_fixture:identity_exact_fixture"
|
||||
fixture := seedBlankProfileSelectorFixture(t, exactSelector, "Fixture Organization", true)
|
||||
|
||||
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileWithScope(current exact collision) error = %v", err)
|
||||
}
|
||||
if selected == nil || selected.UserID != fixture.exactUserID || !exact {
|
||||
t.Fatalf("resolved current collision = %#v exact=%v, want real exact identity", selected, exact)
|
||||
}
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != exactSelector {
|
||||
t.Fatalf("current collision selector = %q, want real exact %q", cfg.CurrentProfile, exactSelector)
|
||||
}
|
||||
|
||||
blank, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(reserved blank collision) error = %v", err)
|
||||
}
|
||||
if blank.UserID != "" || blank.AccessToken != fixture.blankToken.AccessToken {
|
||||
t.Fatalf("reserved blank collision token = %#v", blank)
|
||||
}
|
||||
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
|
||||
t.Fatalf("DeleteTokenDataForProfile(reserved blank collision) error = %v", err)
|
||||
}
|
||||
exactToken, err := LoadTokenDataForProfile(fixture.configDir, exactSelector)
|
||||
if err != nil || exactToken.UserID != fixture.exactUserID {
|
||||
t.Fatalf("exact identity after blank collision delete = %#v, %v", exactToken, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnrelatedProfileDeletionPreservesBlankOrganizationCurrentMapping(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, Profile{
|
||||
Name: "Unrelated Account",
|
||||
CorpID: "corp_unrelated_fixture",
|
||||
UserID: "identity_unrelated_fixture",
|
||||
Status: ProfileStatusActive,
|
||||
})
|
||||
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles(unrelated) error = %v", err)
|
||||
}
|
||||
if _, err := RemoveProfile(fixture.configDir, "corp_unrelated_fixture:identity_unrelated_fixture"); err != nil {
|
||||
t.Fatalf("RemoveProfile(unrelated) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err = LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles(after unrelated delete) error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != fixture.blankSelector {
|
||||
t.Fatalf("blank current after unrelated delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||
}
|
||||
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
|
||||
t.Fatalf("organization current after unrelated delete = %q, want %q", got, fixture.exactSelector)
|
||||
}
|
||||
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
|
||||
if err != nil || selected.UserID != fixture.exactUserID {
|
||||
t.Fatalf("organization selector after unrelated delete = %#v, %v", selected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSameCorpNonCurrentDeletionPreservesExactOrganizationCurrent(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
cfg, err := LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, Profile{
|
||||
Name: "Another Exact Account",
|
||||
CorpID: fixture.corpID,
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_noncurrent_fixture",
|
||||
Status: ProfileStatusActive,
|
||||
})
|
||||
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles(non-current exact) error = %v", err)
|
||||
}
|
||||
if _, err := RemoveProfile(fixture.configDir, fixture.corpID+":identity_noncurrent_fixture"); err != nil {
|
||||
t.Fatalf("RemoveProfile(non-current exact) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err = LoadProfiles(fixture.configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles(after same-corp delete) error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != fixture.blankSelector {
|
||||
t.Fatalf("blank current after same-corp delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||
}
|
||||
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
|
||||
t.Fatalf("organization current after same-corp delete = %q, want %q", got, fixture.exactSelector)
|
||||
}
|
||||
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
|
||||
if err != nil || selected.UserID != fixture.exactUserID {
|
||||
t.Fatalf("organization selector after same-corp delete = %#v, %v", selected, err)
|
||||
}
|
||||
}
|
||||
@@ -3159,7 +3159,13 @@ func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testin
|
||||
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)
|
||||
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err == nil {
|
||||
t.Fatal("unresolved profile loaded organization token owned by an exact identity")
|
||||
}
|
||||
profilesLoadCorp = func(string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: "corp-a", AccessToken: "organization"}, nil
|
||||
}
|
||||
if got, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil || got.AccessToken != "organization" {
|
||||
t.Fatalf("organization-only token load = %#v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
if err := prepareLoginPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
@@ -357,6 +357,9 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
if err := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+207
-27
@@ -138,11 +138,177 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies that every registered token slot can be
|
||||
// read before an OAuth login or exchange can target any profile.
|
||||
// A missing slot is safe (first login or a legacy fallback); any other error
|
||||
// stops the remote operation when existing ciphertext is already known to be
|
||||
// unreadable and therefore unsafe to update.
|
||||
// prepareLoginPersistence rejects profile registries written by a newer client
|
||||
// and protects the legacy global mirror before a new authorization flow
|
||||
// performs remote work. Version-1 registries keep using the existing full
|
||||
// migration in saveTokenDataLocked before any compatibility mirror is
|
||||
// overwritten.
|
||||
//
|
||||
// For v2/v3, only the organization referenced by the readable global mirror is
|
||||
// inspected. A uniquely matching half-migrated profile is repaired from that
|
||||
// mirror under the profiles lock. Missing or damaged slots in unrelated
|
||||
// organizations and orphan inventory are deliberately not scanned.
|
||||
func prepareLoginPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
cfg, err := profilesLoad(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
if err := ensureProfilesWritable(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return repairHalfMigratedGlobalTokenLocked(cfg)
|
||||
})
|
||||
}
|
||||
|
||||
// repairHalfMigratedGlobalTokenLocked preserves the only readable copy left by
|
||||
// an interrupted v1.0.53 migration. The caller must hold the profiles lock.
|
||||
func repairHalfMigratedGlobalTokenLocked(cfg *ProfilesConfig) error {
|
||||
if cfg == nil || cfg.Version < profilesVersion || len(cfg.Profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
global, err := profilesLoadLegacy()
|
||||
if errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"legacy token slot %q is unreadable; refusing to overwrite a potentially unique old login: %w",
|
||||
keychain.AccountToken,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if global == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(global.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) == 0 {
|
||||
// A readable global token for an unregistered organization is an orphan,
|
||||
// not a profile credential that this registry still promises to retain.
|
||||
return nil
|
||||
}
|
||||
|
||||
orgToken, orgErr := profilesLoadCorp(corpID)
|
||||
allCanonical := true
|
||||
for _, profile := range profiles {
|
||||
if !loginProfileHasUsableCanonicalToken(profile, orgToken, orgErr) {
|
||||
allCanonical = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allCanonical {
|
||||
return nil
|
||||
}
|
||||
|
||||
profile := uniqueV2GlobalRepairProfile(cfg, corpID)
|
||||
if profile == nil {
|
||||
return fmt.Errorf(
|
||||
"legacy token slot %q may be the only recoverable login for one of %d accounts in organization %q; refusing to overwrite it until each account has a usable identity slot",
|
||||
keychain.AccountToken,
|
||||
len(profiles),
|
||||
corpID,
|
||||
)
|
||||
}
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
if userID != "" &&
|
||||
orgErr == nil &&
|
||||
loginTokenHasCredentialMaterial(orgToken) &&
|
||||
legacyTokenMatchesV2RepairProfile(orgToken, profile) {
|
||||
if err := repairLoginIdentityToken(profile, orgToken); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !legacyTokenMatchesV2RepairProfile(global, profile) {
|
||||
return fmt.Errorf(
|
||||
"legacy token slot %q does not safely match the only profile in organization %q; refusing to overwrite a potentially unique old login",
|
||||
keychain.AccountToken,
|
||||
corpID,
|
||||
)
|
||||
}
|
||||
if !loginTokenHasCredentialMaterial(global) {
|
||||
return fmt.Errorf(
|
||||
"legacy token slot %q has no recoverable credential material for organization %q; refusing to overwrite a potentially unique old login",
|
||||
keychain.AccountToken,
|
||||
corpID,
|
||||
)
|
||||
}
|
||||
|
||||
// The matching global token is the only recoverable copy. Overwrite a
|
||||
// damaged organization slot as well as filling a missing one.
|
||||
if err := profilesSaveCorp(corpID, global); err != nil {
|
||||
return fmt.Errorf("repair organization token slot %q: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
return repairLoginIdentityToken(profile, global)
|
||||
}
|
||||
|
||||
func loginProfileHasUsableCanonicalToken(
|
||||
profile *Profile,
|
||||
orgToken *TokenData,
|
||||
orgErr error,
|
||||
) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
if userID == "" {
|
||||
return orgErr == nil &&
|
||||
loginTokenHasCredentialMaterial(orgToken) &&
|
||||
strings.TrimSpace(orgToken.CorpID) == corpID &&
|
||||
strings.TrimSpace(orgToken.UserID) == ""
|
||||
}
|
||||
identity, err := profilesLoadIdentity(corpID, userID)
|
||||
if err == nil &&
|
||||
loginTokenHasCredentialMaterial(identity) &&
|
||||
strings.TrimSpace(identity.CorpID) == corpID &&
|
||||
strings.TrimSpace(identity.UserID) == userID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loginTokenHasCredentialMaterial(data *TokenData) bool {
|
||||
return data != nil &&
|
||||
(strings.TrimSpace(data.AccessToken) != "" ||
|
||||
strings.TrimSpace(data.RefreshToken) != "" ||
|
||||
strings.TrimSpace(data.PersistentCode) != "")
|
||||
}
|
||||
|
||||
func repairLoginIdentityToken(profile *Profile, source *TokenData) error {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
identityToken := source
|
||||
if strings.TrimSpace(source.UserID) == "" {
|
||||
enriched := *source
|
||||
enriched.UserID = userID
|
||||
if strings.TrimSpace(enriched.UserName) == "" {
|
||||
enriched.UserName = strings.TrimSpace(profile.UserName)
|
||||
}
|
||||
identityToken = &enriched
|
||||
}
|
||||
if err := profilesSaveIdentity(corpID, userID, identityToken); err != nil {
|
||||
return fmt.Errorf("repair identity token slot %q: %w", TokenAccountForIdentity(corpID, userID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies every persisted token slot, including
|
||||
// unregistered/orphan ciphertext. Keep this full-inventory validator for
|
||||
// migration, export and explicit storage diagnostics; login must use the
|
||||
// schema-only and target-only preflights instead so an unrelated damaged
|
||||
// account cannot block reauthorization.
|
||||
func preflightTokenPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
@@ -191,10 +357,11 @@ func preflightTokenPersistence(configDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
|
||||
// preflightTokenWritePersistence checks only the slots SaveTokenData can write
|
||||
// for data under the current runtime selector. It is shared by login and
|
||||
// refresh so both paths stay aligned with the same identity/org/global mirror
|
||||
// isolation rules.
|
||||
func preflightTokenWritePersistence(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -206,33 +373,46 @@ func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile())
|
||||
if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
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)
|
||||
if plan.WriteGlobal {
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
}
|
||||
checkOrganizationMirror := true
|
||||
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
|
||||
checkOrganizationMirror =
|
||||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
|
||||
profileSelector(corpID, userID)
|
||||
if plan.CorpID == "" {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
if plan.WriteIdentity {
|
||||
if _, err := LoadTokenDataKeychainForIdentity(plan.CorpID, plan.UserID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"identity token slot %q is unreadable: %w",
|
||||
TokenAccountForIdentity(plan.CorpID, plan.UserID),
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if plan.WriteOrganization {
|
||||
if _, err := LoadTokenDataKeychainForCorpID(plan.CorpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"profile token slot %q is unreadable: %w",
|
||||
TokenAccountForCorpID(plan.CorpID),
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
|
||||
return preflightTokenWritePersistence(configDir, data)
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
|
||||
func DeleteTokenDataKeychain() error {
|
||||
return authKeychainRemove(keychain.Service, keychain.AccountToken)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageLegacySelectorCompatibilityEdges(t *testing.T) {
|
||||
upgradeDir := t.TempDir()
|
||||
upgradeCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{{
|
||||
Name: "Legacy Organization",
|
||||
CorpID: "corp_upgrade_fixture",
|
||||
}},
|
||||
}
|
||||
if err := upsertProfileFromToken(upgradeDir, upgradeCfg, &TokenData{
|
||||
CorpID: "corp_upgrade_fixture",
|
||||
UserID: "identity_upgrade_fixture",
|
||||
UserName: "Upgraded Account",
|
||||
}, false); err != nil {
|
||||
t.Fatalf("upsertProfileFromToken(upgrade legacy profile) error = %v", err)
|
||||
}
|
||||
if len(upgradeCfg.Profiles) != 1 || upgradeCfg.Profiles[0].UserID != "identity_upgrade_fixture" {
|
||||
t.Fatalf("upgraded profiles = %#v", upgradeCfg.Profiles)
|
||||
}
|
||||
|
||||
renameDir := t.TempDir()
|
||||
renameCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "duplicate", CorpID: "corp_rename_fixture"},
|
||||
{Name: "duplicate", CorpID: "corp_rename_fixture", UserID: "identity_exact_fixture"},
|
||||
},
|
||||
}
|
||||
if err := upsertProfileFromToken(renameDir, renameCfg, &TokenData{
|
||||
CorpID: "corp_rename_fixture",
|
||||
CorpName: "Renamed Organization",
|
||||
}, false); err != nil {
|
||||
t.Fatalf("upsertProfileFromToken(rename blank profile) error = %v", err)
|
||||
}
|
||||
if renameCfg.Profiles[0].Name != "Renamed Organization" {
|
||||
t.Fatalf("blank profile name = %q, want conflict-free organization name", renameCfg.Profiles[0].Name)
|
||||
}
|
||||
|
||||
blank := Profile{CorpID: "corp_selector_fixture"}
|
||||
if got := storedProfileSelector(nil, nil); got != "" {
|
||||
t.Fatalf("storedProfileSelector(nil profile) = %q", got)
|
||||
}
|
||||
if got := storedProfileSelector(nil, &blank); got != blank.CorpID {
|
||||
t.Fatalf("storedProfileSelector(nil config) = %q", got)
|
||||
}
|
||||
if localProfileSelectorIsSafe(nil, &blank, "local") ||
|
||||
localProfileSelectorIsSafe(&ProfilesConfig{}, nil, "local") {
|
||||
t.Fatal("nil selector inputs were treated as safe")
|
||||
}
|
||||
if got := unresolvedProfileSelector(" "); got != "" {
|
||||
t.Fatalf("unresolvedProfileSelector(blank) = %q", got)
|
||||
}
|
||||
if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "!"); ok {
|
||||
t.Fatal("invalid base64 legacy selector parsed successfully")
|
||||
}
|
||||
if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "IA"); ok {
|
||||
t.Fatal("blank decoded legacy selector parsed successfully")
|
||||
}
|
||||
|
||||
previousRuntime := RuntimeProfile()
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile(previousRuntime) })
|
||||
if got := StableTokenProfileSelector(t.TempDir(), nil); got != "" {
|
||||
t.Fatalf("StableTokenProfileSelector(nil) = %q", got)
|
||||
}
|
||||
|
||||
ambiguousDir := t.TempDir()
|
||||
ambiguousCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: "corp_ambiguous_fixture",
|
||||
Profiles: []Profile{
|
||||
{Name: "First", CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"},
|
||||
{Name: "Second", CorpID: "corp_ambiguous_fixture", UserID: "identity_second_fixture"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(ambiguousDir, ambiguousCfg); err != nil {
|
||||
t.Fatalf("SaveProfiles(ambiguous current) error = %v", err)
|
||||
}
|
||||
ambiguousToken := &TokenData{CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"}
|
||||
if got, want := StableTokenProfileSelector(ambiguousDir, ambiguousToken), "corp_ambiguous_fixture:identity_first_fixture"; got != want {
|
||||
t.Fatalf("StableTokenProfileSelector(ambiguous organization) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
reserved := unresolvedProfileSelector("corp_missing_fixture")
|
||||
emptyCfg := &ProfilesConfig{}
|
||||
if _, _, err := resolveProfileSelection("", emptyCfg, reserved); err == nil {
|
||||
t.Fatal("missing reserved profile selection succeeded")
|
||||
}
|
||||
if _, _, err := resolveProfileDeletionSelection(emptyCfg, reserved); err == nil {
|
||||
t.Fatal("missing reserved profile deletion succeeded")
|
||||
}
|
||||
if got := canonicalStoredSelector(emptyCfg, reserved); got != "" {
|
||||
t.Fatalf("canonical missing reserved selector = %q", got)
|
||||
}
|
||||
if !selectorTargetsCorp(reserved, "corp_missing_fixture") {
|
||||
t.Fatal("reserved selector did not target its organization")
|
||||
}
|
||||
|
||||
localCfg := &ProfilesConfig{Profiles: []Profile{{
|
||||
Name: "local-profile-fixture",
|
||||
CorpID: "corp_local_fixture",
|
||||
UserID: "identity_local_fixture",
|
||||
}}}
|
||||
if got, want := canonicalStoredSelector(localCfg, "local-profile-fixture"), "corp_local_fixture:identity_local_fixture"; got != want {
|
||||
t.Fatalf("canonical local selector = %q, want %q", got, want)
|
||||
}
|
||||
if unresolvedProfileForCorp(nil, "corp") != nil || unresolvedProfileForLocalName(nil, "local") != nil {
|
||||
t.Fatal("nil profile registry returned an unresolved profile")
|
||||
}
|
||||
if unresolvedProfileForLocalName(localCfg, " ") != nil {
|
||||
t.Fatal("blank local name returned an unresolved profile")
|
||||
}
|
||||
duplicateCfg := &ProfilesConfig{Profiles: []Profile{
|
||||
{Name: "duplicate-blank", CorpID: "corp_duplicate_one"},
|
||||
{Name: "Exact One", CorpID: "corp_duplicate_one", UserID: "identity_one"},
|
||||
{Name: "duplicate-blank", CorpID: "corp_duplicate_two"},
|
||||
{Name: "Exact Two", CorpID: "corp_duplicate_two", UserID: "identity_two"},
|
||||
}}
|
||||
if unresolvedProfileForLocalName(duplicateCfg, "duplicate-blank") != nil {
|
||||
t.Fatal("duplicate unresolved local name selected an arbitrary profile")
|
||||
}
|
||||
|
||||
oldLoadCorp := tokenLoadKeychainForCorpID
|
||||
t.Cleanup(func() { tokenLoadKeychainForCorpID = oldLoadCorp })
|
||||
tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil }
|
||||
if _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_token_fixture"}); !errors.Is(err, ErrTokenDataNotFound) {
|
||||
t.Fatalf("nil organization token error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyProfileLifecycleErrorEdges(t *testing.T) {
|
||||
oldEnsure := profilesEnsureMigration
|
||||
oldLoad := profilesLoad
|
||||
oldSave := profilesSave
|
||||
oldLoadCorp := profilesLoadCorp
|
||||
oldLoadLegacy := profilesLoadLegacy
|
||||
oldLoadIdentity := profilesLoadIdentity
|
||||
oldSaveCorp := profilesSaveCorp
|
||||
oldDeleteCorp := profilesDeleteCorp
|
||||
oldDeleteLegacy := profilesDeleteLegacy
|
||||
oldDeleteMarker := profilesDeleteMarker
|
||||
t.Cleanup(func() {
|
||||
profilesEnsureMigration = oldEnsure
|
||||
profilesLoad = oldLoad
|
||||
profilesSave = oldSave
|
||||
profilesLoadCorp = oldLoadCorp
|
||||
profilesLoadLegacy = oldLoadLegacy
|
||||
profilesLoadIdentity = oldLoadIdentity
|
||||
profilesSaveCorp = oldSaveCorp
|
||||
profilesDeleteCorp = oldDeleteCorp
|
||||
profilesDeleteLegacy = oldDeleteLegacy
|
||||
profilesDeleteMarker = oldDeleteMarker
|
||||
})
|
||||
|
||||
identityFailure := errors.New("identity load failure")
|
||||
profilesEnsureMigration = func(string) error { return nil }
|
||||
profilesSave = func(string, *ProfilesConfig) error { return nil }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, identityFailure }
|
||||
profilesSaveCorp = func(string, *TokenData) error { return nil }
|
||||
profilesDeleteCorp = func(string) error { return nil }
|
||||
profilesDeleteLegacy = func() error { return nil }
|
||||
profilesDeleteMarker = func(string) error { return nil }
|
||||
|
||||
setCurrentCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: "corp_set_fixture:identity_set_fixture",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_set_fixture": "corp_set_fixture:identity_set_fixture",
|
||||
},
|
||||
Profiles: []Profile{{
|
||||
Name: "Set Account",
|
||||
CorpID: "corp_set_fixture",
|
||||
UserID: "identity_set_fixture",
|
||||
}},
|
||||
}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return setCurrentCfg, nil }
|
||||
if _, err := setCurrentProfileLocked(t.TempDir(), "corp_set_fixture:identity_set_fixture"); !errors.Is(err, identityFailure) {
|
||||
t.Fatalf("setCurrentProfileLocked sync error = %v", err)
|
||||
}
|
||||
|
||||
usePreviousCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: "corp_previous_fixture:identity_current_fixture",
|
||||
PreviousProfile: "corp_previous_fixture:identity_previous_fixture",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_previous_fixture": "corp_previous_fixture:identity_current_fixture",
|
||||
},
|
||||
Profiles: []Profile{
|
||||
{Name: "Current", CorpID: "corp_previous_fixture", UserID: "identity_current_fixture"},
|
||||
{Name: "Previous", CorpID: "corp_previous_fixture", UserID: "identity_previous_fixture"},
|
||||
},
|
||||
}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return usePreviousCfg, nil }
|
||||
if _, err := usePreviousProfileLocked(t.TempDir()); !errors.Is(err, identityFailure) {
|
||||
t.Fatalf("usePreviousProfileLocked sync error = %v", err)
|
||||
}
|
||||
|
||||
snapshotFailure := errors.New("organization snapshot failure")
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, snapshotFailure }
|
||||
if _, err := snapshotProfileSelectionMirrors(t.TempDir(), "corp_snapshot_fixture", true); !errors.Is(err, snapshotFailure) {
|
||||
t.Fatalf("snapshot organization error = %v", err)
|
||||
}
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
|
||||
operationFailure := errors.New("selection operation failure")
|
||||
organizationRestoreFailure := errors.New("organization restore failure")
|
||||
profilesSaveCorp = func(string, *TokenData) error { return organizationRestoreFailure }
|
||||
withOrganization := profileSelectionMirrorSnapshot{
|
||||
organization: tokenSlotSnapshot{known: true, exists: true, token: &TokenData{CorpID: "corp_rollback_fixture"}},
|
||||
marker: tokenMarkerSnapshot{known: true},
|
||||
}
|
||||
if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationRestoreFailure) {
|
||||
t.Fatalf("rollback organization save error = %v", err)
|
||||
}
|
||||
|
||||
organizationDeleteFailure := errors.New("organization delete failure")
|
||||
profilesSaveCorp = func(string, *TokenData) error { return nil }
|
||||
profilesDeleteCorp = func(string) error { return organizationDeleteFailure }
|
||||
withoutOrganization := profileSelectionMirrorSnapshot{
|
||||
organization: tokenSlotSnapshot{known: true},
|
||||
marker: tokenMarkerSnapshot{known: true},
|
||||
}
|
||||
if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withoutOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationDeleteFailure) {
|
||||
t.Fatalf("rollback organization delete error = %v", err)
|
||||
}
|
||||
profilesDeleteCorp = func(string) error { return nil }
|
||||
|
||||
remainingCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: "corp_removed_fixture:identity_removed_fixture",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_removed_fixture": "corp_removed_fixture:identity_removed_fixture",
|
||||
},
|
||||
Profiles: []Profile{
|
||||
{Name: "Removed", CorpID: "corp_removed_fixture", UserID: "identity_removed_fixture"},
|
||||
{Name: "Remaining", CorpID: "corp_remaining_fixture", UserID: "identity_remaining_fixture"},
|
||||
},
|
||||
}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return remainingCfg, nil }
|
||||
if _, err := removeProfileLocked(t.TempDir(), "corp_removed_fixture:identity_removed_fixture"); err != nil {
|
||||
t.Fatalf("removeProfileLocked(single fallback) error = %v", err)
|
||||
}
|
||||
if remainingCfg.CurrentProfile != "corp_remaining_fixture:identity_remaining_fixture" {
|
||||
t.Fatalf("fallback current profile = %q", remainingCfg.CurrentProfile)
|
||||
}
|
||||
|
||||
blankCfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: "corp_blank_fixture:identity_exact_fixture",
|
||||
PreviousProfile: "legacy-blank-fixture",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_blank_fixture": "corp_blank_fixture:identity_exact_fixture",
|
||||
},
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy-blank-fixture", CorpID: "corp_blank_fixture"},
|
||||
{Name: "Exact", CorpID: "corp_blank_fixture", UserID: "identity_exact_fixture"},
|
||||
},
|
||||
}
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return blankCfg, nil }
|
||||
if _, err := removeProfileLocked(t.TempDir(), "corp_blank_fixture:identity_exact_fixture"); err != nil {
|
||||
t.Fatalf("removeProfileLocked(blank fallback) error = %v", err)
|
||||
}
|
||||
if blankCfg.CurrentProfile != "corp_blank_fixture" || blankCfg.OrgCurrentProfiles["corp_blank_fixture"] != "" {
|
||||
t.Fatalf("blank fallback selection = current %q org %q", blankCfg.CurrentProfile, blankCfg.OrgCurrentProfiles["corp_blank_fixture"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// The fixture builders spell out the exact v1 profiles and token JSON keys.
|
||||
// They deliberately avoid the current TokenData and ProfilesConfig serializers
|
||||
// so that historical field omission and account names stay part of the upgrade
|
||||
// contract exercised by these tests.
|
||||
func TestCrossPlatformCoverageV1044GlobalSlotWithoutProfilesMigrates(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
userID string
|
||||
}{
|
||||
{name: "known user", userID: "legacy-user-v1044"},
|
||||
{name: "unresolved external worker"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
corpID := "ding_v1044_" + strings.ReplaceAll(tc.name, " ", "_")
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "global-v1044-"+tc.name, corpID, "V1044 Org", tc.userID, "",
|
||||
))
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != corpID || loaded.UserID != tc.userID {
|
||||
t.Fatalf("migrated v1.0.44 token = %#v", loaded)
|
||||
}
|
||||
if !TokenDataExistsKeychainForCorpID(corpID) {
|
||||
t.Fatal("v1.0.44 global token was not copied to its organization slot")
|
||||
}
|
||||
if tc.userID != "" && !TokenDataExistsKeychainForIdentity(corpID, tc.userID) {
|
||||
t.Fatal("v1.0.44 known identity slot was not created")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1050AndV1051GlobalSlotWithV1ProfilesMigratesIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
corpID string
|
||||
corpName string
|
||||
userID string
|
||||
userName string
|
||||
tokenHasUID bool
|
||||
}{
|
||||
{
|
||||
name: "v1.0.50 token and profile both carry userId",
|
||||
corpID: "ding_v1050",
|
||||
corpName: "V1050 Org",
|
||||
userID: "legacy-user-v1050",
|
||||
userName: "V1050 User",
|
||||
tokenHasUID: true,
|
||||
},
|
||||
{
|
||||
name: "v1.0.51 profile supplies omitted token userId",
|
||||
corpID: "ding_v1051",
|
||||
corpName: "V1051 Org",
|
||||
userID: "legacy-user-v1051",
|
||||
userName: "V1051 User",
|
||||
tokenHasUID: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{
|
||||
name: tc.corpName,
|
||||
corpID: tc.corpID,
|
||||
corpName: tc.corpName,
|
||||
userID: tc.userID,
|
||||
userName: tc.userName,
|
||||
clientID: "ding-client-" + tc.corpID,
|
||||
}}, tc.corpID, "", tc.corpID)
|
||||
|
||||
tokenUserID, tokenUserName := "", ""
|
||||
if tc.tokenHasUID {
|
||||
tokenUserID, tokenUserName = tc.userID, tc.userName
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"global-"+tc.corpID,
|
||||
tc.corpID,
|
||||
tc.corpName,
|
||||
tokenUserID,
|
||||
tokenUserName,
|
||||
))
|
||||
|
||||
// An ordinary first read is the upgrade trigger. A recoverable global
|
||||
// token must populate both the organization and exact-identity slots
|
||||
// even when a version-1 profiles registry already exists.
|
||||
if _, err := LoadTokenData(configDir); err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
migrated, err := LoadTokenDataKeychainForIdentity(tc.corpID, tc.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", tc.corpID, tc.userID, err)
|
||||
}
|
||||
if migrated.CorpID != tc.corpID || migrated.UserID != tc.userID {
|
||||
t.Fatalf("migrated identity token = %#v", migrated)
|
||||
}
|
||||
if migrated.AccessToken != "global-"+tc.corpID ||
|
||||
migrated.RefreshToken != "refresh-global-"+tc.corpID ||
|
||||
migrated.PersistentCode != "persistent-global-"+tc.corpID ||
|
||||
migrated.ClientID != "ding-client-historical" ||
|
||||
migrated.Source != "mcp" {
|
||||
t.Fatalf("migrated token fields were not preserved: %#v", migrated)
|
||||
}
|
||||
orgMirror, err := LoadTokenDataKeychainForCorpID(tc.corpID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID(%q) error = %v", tc.corpID, err)
|
||||
}
|
||||
if orgMirror.AccessToken != "global-"+tc.corpID {
|
||||
t.Fatalf("organization token slot %q = %#v", TokenAccountForCorpID(tc.corpID), orgMirror)
|
||||
}
|
||||
if !tc.tokenHasUID && orgMirror.UserID != "" {
|
||||
t.Fatalf("organization mirror inferred userId %q; want untouched historical blob", orgMirror.UserID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1052RawMultiOrganizationSlotsMigrateEveryIdentity(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
organizations := seedHistoricalV1052MultiOrganizationState(t, configDir)
|
||||
|
||||
// Reading only the current organization must upgrade the complete registry,
|
||||
// including inactive organizations that are not selected.
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != organizations[1].corpID || loaded.UserID != organizations[1].userID {
|
||||
t.Fatalf("current token after migration = %#v", loaded)
|
||||
}
|
||||
assertHistoricalV1052IdentitySlots(t, organizations, nil)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1052UnresolvedMultiOrganizationProfilesRemainUsable(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
organizations := []historicalV1052Organization{
|
||||
{corpID: "ding_v1052_external_a", corpName: "External Org A", accessToken: "external-access-a"},
|
||||
{corpID: "ding_v1052_external_b", corpName: "External Org B", accessToken: "external-access-b"},
|
||||
{corpID: "ding_v1052_external_c", corpName: "External Org C", accessToken: "external-access-c"},
|
||||
}
|
||||
profiles := make([]historicalV1Profile, 0, len(organizations))
|
||||
for _, organization := range organizations {
|
||||
profiles = append(profiles, historicalV1Profile{
|
||||
name: organization.corpName, corpID: organization.corpID, corpName: organization.corpName,
|
||||
})
|
||||
seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON(
|
||||
t, organization.accessToken, organization.corpID, organization.corpName, "", "",
|
||||
))
|
||||
}
|
||||
writeHistoricalV1Profiles(
|
||||
t,
|
||||
configDir,
|
||||
profiles,
|
||||
organizations[0].corpID,
|
||||
organizations[0].corpID,
|
||||
organizations[1].corpID,
|
||||
)
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
for _, organization := range organizations {
|
||||
loaded, err := LoadTokenDataForProfile(configDir, organization.corpID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(%q) error = %v", organization.corpID, err)
|
||||
}
|
||||
if loaded.AccessToken != organization.accessToken || loaded.CorpID != organization.corpID || loaded.UserID != "" {
|
||||
t.Fatalf("unresolved organization token for %q = %#v", organization.corpID, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1052FirstSaveMigratesAllOrganizationsBeforeV2Commit(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
organizations := seedHistoricalV1052MultiOrganizationState(t, configDir)
|
||||
|
||||
// A login or refresh can make SaveTokenData the first new-version action.
|
||||
// It must migrate every old organization before profiles.json becomes v2,
|
||||
// otherwise the remaining organization mirrors are stranded permanently.
|
||||
firstWrite := &TokenData{
|
||||
AccessToken: "new-first-action-access",
|
||||
RefreshToken: "new-first-action-refresh",
|
||||
PersistentCode: "new-first-action-persistent",
|
||||
CorpID: organizations[1].corpID,
|
||||
CorpName: organizations[1].corpName,
|
||||
UserID: organizations[1].userID,
|
||||
UserName: organizations[1].userName,
|
||||
ClientID: "ding-client-new-first-action",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := SaveTokenData(configDir, firstWrite); err != nil {
|
||||
t.Fatalf("SaveTokenData(first new-version action) error = %v", err)
|
||||
}
|
||||
|
||||
assertHistoricalV1052IdentitySlots(t, organizations, map[string]string{
|
||||
organizations[1].corpID: firstWrite.AccessToken,
|
||||
})
|
||||
migratedCurrent, err := LoadTokenDataKeychainForIdentity(firstWrite.CorpID, firstWrite.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity(first write) error = %v", err)
|
||||
}
|
||||
if migratedCurrent.RefreshToken != firstWrite.RefreshToken ||
|
||||
migratedCurrent.PersistentCode != firstWrite.PersistentCode ||
|
||||
migratedCurrent.ClientID != firstWrite.ClientID ||
|
||||
migratedCurrent.Source != firstWrite.Source {
|
||||
t.Fatalf("first-write identity token fields were not preserved: %#v", migratedCurrent)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.Version != profilesVersion || len(cfg.Profiles) != len(organizations) {
|
||||
t.Fatalf("profiles after first save = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalFallbackIsStrictlyScoped(t *testing.T) {
|
||||
t.Run("different organization is never reused", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{
|
||||
name: "Expected Org",
|
||||
corpID: "ding_expected",
|
||||
corpName: "Expected Org",
|
||||
userID: "expected-user",
|
||||
userName: "Expected User",
|
||||
clientID: "ding-client-expected",
|
||||
}}, "ding_expected", "", "ding_expected")
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"wrong-org-access",
|
||||
"ding_other",
|
||||
"Other Org",
|
||||
"other-user",
|
||||
"Other User",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID("ding_expected") ||
|
||||
TokenDataExistsKeychainForIdentity("ding_expected", "expected-user") {
|
||||
t.Fatal("global token from another organization was reused")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version 2 empty tombstone never imports global slot", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{Version: profilesVersion}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"stale-v2-global",
|
||||
"ding_v2",
|
||||
"V2 Org",
|
||||
"v2-user",
|
||||
"V2 User",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID("ding_v2") ||
|
||||
TokenDataExistsKeychainForIdentity("ding_v2", "v2-user") {
|
||||
t.Fatal("version 2 logout tombstone imported the stale global slot")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple same organization accounts never receive guessed identity", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{
|
||||
{
|
||||
name: "Shared Org One",
|
||||
corpID: "ding_shared",
|
||||
corpName: "Shared Org",
|
||||
userID: "shared-user-one",
|
||||
userName: "Shared User One",
|
||||
clientID: "ding-client-shared",
|
||||
},
|
||||
{
|
||||
name: "Shared Org Two",
|
||||
corpID: "ding_shared",
|
||||
corpName: "Shared Org",
|
||||
userID: "shared-user-two",
|
||||
userName: "Shared User Two",
|
||||
clientID: "ding-client-shared",
|
||||
},
|
||||
}, "ding_shared", "", "ding_shared")
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"shared-without-user",
|
||||
"ding_shared",
|
||||
"Shared Org",
|
||||
"",
|
||||
"",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if !TokenDataExistsKeychainForCorpID("ding_shared") {
|
||||
t.Fatal("matching organization mirror was not restored")
|
||||
}
|
||||
for _, userID := range []string{"shared-user-one", "shared-user-two"} {
|
||||
if TokenDataExistsKeychainForIdentity("ding_shared", userID) {
|
||||
t.Fatalf("ambiguous global token was copied to identity %q", userID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1053PartialV2RegistryRepairsFromMatchingGlobalSlot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profileUserID string
|
||||
tokenUserID string
|
||||
}{
|
||||
{
|
||||
name: "matching token identity",
|
||||
profileUserID: "user_v1053_matching",
|
||||
tokenUserID: "user_v1053_matching",
|
||||
},
|
||||
{name: "token omitted identity", profileUserID: "user_v1053_token_omitted"},
|
||||
{name: "sole unresolved profile"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
suffix := strings.ReplaceAll(tc.name, " ", "_")
|
||||
corpID := "ding_v1053_partial_" + suffix
|
||||
profile := Profile{
|
||||
Name: "V1053 Partial Org",
|
||||
CorpID: corpID,
|
||||
CorpName: "V1053 Partial Org",
|
||||
UserID: tc.profileUserID,
|
||||
UserName: "V1053 Partial User",
|
||||
}
|
||||
identityLoads := 0
|
||||
if profile.UserID == "" {
|
||||
originalLoadIdentity := profilesLoadIdentity
|
||||
profilesLoadIdentity = func(corpID, userID string) (*TokenData, error) {
|
||||
identityLoads++
|
||||
return originalLoadIdentity(corpID, userID)
|
||||
}
|
||||
t.Cleanup(func() { profilesLoadIdentity = originalLoadIdentity })
|
||||
}
|
||||
selector := ProfileSelector(profile)
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: selector,
|
||||
OrgCurrentProfiles: map[string]string{corpID: selector},
|
||||
Profiles: []Profile{profile},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"v1053-global-"+suffix,
|
||||
corpID,
|
||||
profile.CorpName,
|
||||
tc.tokenUserID,
|
||||
"",
|
||||
))
|
||||
|
||||
if TokenDataExistsKeychainForCorpID(corpID) {
|
||||
t.Fatal("partial v2 fixture unexpectedly contained an organization or identity slot")
|
||||
}
|
||||
if profile.UserID != "" && TokenDataExistsKeychainForIdentity(corpID, profile.UserID) {
|
||||
t.Fatal("partial v2 fixture unexpectedly contained an identity slot")
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(configDir, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "v1053-global-"+suffix ||
|
||||
loaded.CorpID != corpID || loaded.UserID != profile.UserID {
|
||||
t.Fatalf("repaired v2 token = %#v", loaded)
|
||||
}
|
||||
|
||||
if profile.UserID != "" {
|
||||
identityToken, identityErr := LoadTokenDataKeychainForIdentity(corpID, profile.UserID)
|
||||
if identityErr != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", identityErr)
|
||||
}
|
||||
if identityToken.AccessToken != loaded.AccessToken || identityToken.UserID != profile.UserID {
|
||||
t.Fatalf("repaired identity token = %#v", identityToken)
|
||||
}
|
||||
}
|
||||
orgMirror, err := LoadTokenDataKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgMirror.AccessToken != loaded.AccessToken || orgMirror.UserID != tc.tokenUserID {
|
||||
t.Fatalf("repaired organization mirror = %#v", orgMirror)
|
||||
}
|
||||
if identityLoads != 0 {
|
||||
t.Fatalf("unresolved profile caused %d identity-slot reads; want 0", identityLoads)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("matching organization among multiple organizations", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
profiles := []Profile{
|
||||
{Name: "Partial Org A", CorpID: "ding_v1053_partial_a", UserID: "user_v1053_partial_a"},
|
||||
{Name: "Partial Org B", CorpID: "ding_v1053_partial_b", UserID: "user_v1053_partial_b"},
|
||||
}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profiles[1]),
|
||||
Profiles: profiles,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
"v1053-global-multi-org",
|
||||
profiles[1].CorpID,
|
||||
profiles[1].Name,
|
||||
profiles[1].UserID,
|
||||
"",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(profiles[0].CorpID) ||
|
||||
TokenDataExistsKeychainForIdentity(profiles[0].CorpID, profiles[0].UserID) {
|
||||
t.Fatal("global token was copied into the non-matching organization")
|
||||
}
|
||||
repaired, err := LoadTokenDataKeychainForIdentity(profiles[1].CorpID, profiles[1].UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity(matching organization) error = %v", err)
|
||||
}
|
||||
if repaired.AccessToken != "v1053-global-multi-org" || repaired.UserID != profiles[1].UserID {
|
||||
t.Fatalf("multi-organization v2 repair token = %#v", repaired)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageV1053PartialV2RegistryRejectsUnsafeGlobalSlot(t *testing.T) {
|
||||
t.Run("global token organization differs", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
profile := Profile{Name: "Expected Org", CorpID: "ding_v2_expected", UserID: "user_v2_expected"}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profile),
|
||||
Profiles: []Profile{profile},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "cross-corp-global", "ding_v2_other", "Other Org", profile.UserID, "",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(profile.CorpID) ||
|
||||
TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) {
|
||||
t.Fatal("cross-organization global token was imported")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unresolved profile rejects global token identity", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
profile := Profile{Name: "Unresolved External", CorpID: "ding_v2_unresolved"}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profile),
|
||||
Profiles: []Profile{profile},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "unexpected-identity-global", profile.CorpID, profile.Name, "user_v2_unexpected", "",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(profile.CorpID) {
|
||||
t.Fatal("global token userId was attached to an unresolved profile")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global token identity differs", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
profile := Profile{Name: "Expected User", CorpID: "ding_v2_uid", UserID: "user_v2_expected"}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profile),
|
||||
Profiles: []Profile{profile},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "wrong-user-global", profile.CorpID, profile.Name, "user_v2_other", "",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(profile.CorpID) ||
|
||||
TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) {
|
||||
t.Fatal("global token with a different userId was imported")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple accounts in one organization stay unresolved", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
corpID := "ding_v2_shared"
|
||||
profiles := []Profile{
|
||||
{Name: "Shared User One", CorpID: corpID, UserID: "user_v2_shared_one"},
|
||||
{Name: "Shared User Two", CorpID: corpID, UserID: "user_v2_shared_two"},
|
||||
}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profiles[0]),
|
||||
Profiles: profiles,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "ambiguous-global", corpID, "Shared Org", profiles[0].UserID, "",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(corpID) {
|
||||
t.Fatal("multi-account organization imported the mutable global mirror")
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if TokenDataExistsKeychainForIdentity(corpID, profile.UserID) {
|
||||
t.Fatalf("multi-account global token was copied to identity %q", profile.UserID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing exact identity is not overwritten", func(t *testing.T) {
|
||||
cleanupHistoricalKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
profile := Profile{Name: "Existing User", CorpID: "ding_v2_existing", UserID: "user_v2_existing"}
|
||||
if err := SaveProfiles(configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
CurrentProfile: ProfileSelector(profile),
|
||||
Profiles: []Profile{profile},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
seedHistoricalTokenSlot(t, TokenAccountForIdentity(profile.CorpID, profile.UserID), historicalTokenJSON(
|
||||
t, "existing-exact", profile.CorpID, profile.Name, profile.UserID, "",
|
||||
))
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t, "stale-global", profile.CorpID, profile.Name, profile.UserID, "",
|
||||
))
|
||||
|
||||
if err := EnsureProfilesMigration(configDir); err != nil {
|
||||
t.Fatalf("EnsureProfilesMigration() error = %v", err)
|
||||
}
|
||||
if TokenDataExistsKeychainForCorpID(profile.CorpID) {
|
||||
t.Fatal("global mirror recreated an organization slot beside an existing exact identity")
|
||||
}
|
||||
exact, err := LoadTokenDataKeychainForIdentity(profile.CorpID, profile.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if exact.AccessToken != "existing-exact" {
|
||||
t.Fatalf("existing exact identity was overwritten: %#v", exact)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyMigrationPersistenceErrors(t *testing.T) {
|
||||
oldLoad := profilesLoad
|
||||
oldSave := profilesSave
|
||||
oldLoadLegacy := profilesLoadLegacy
|
||||
oldSaveCorp := profilesSaveCorp
|
||||
oldLoadCorp := profilesLoadCorp
|
||||
oldLoadIdentity := profilesLoadIdentity
|
||||
oldSaveIdentity := profilesSaveIdentity
|
||||
t.Cleanup(func() {
|
||||
profilesLoad = oldLoad
|
||||
profilesSave = oldSave
|
||||
profilesLoadLegacy = oldLoadLegacy
|
||||
profilesSaveCorp = oldSaveCorp
|
||||
profilesLoadCorp = oldLoadCorp
|
||||
profilesLoadIdentity = oldLoadIdentity
|
||||
profilesSaveIdentity = oldSaveIdentity
|
||||
})
|
||||
|
||||
fail := errors.New("legacy migration persistence failed")
|
||||
baseConfig := func(version int) *ProfilesConfig {
|
||||
return &ProfilesConfig{
|
||||
Version: version,
|
||||
Profiles: []Profile{{
|
||||
Name: "Legacy User", CorpID: "ding_legacy_error", UserID: "legacy-user",
|
||||
}},
|
||||
}
|
||||
}
|
||||
profilesSave = func(string, *ProfilesConfig) error { return nil }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return nil }
|
||||
|
||||
t.Run("global compatibility slot read", func(t *testing.T) {
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil }
|
||||
profilesLoadLegacy = func() (*TokenData, error) { return nil, fail }
|
||||
profilesSaveCorp = func(string, *TokenData) error { return nil }
|
||||
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
|
||||
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restored organization slot write", func(t *testing.T) {
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil }
|
||||
profilesLoadLegacy = func() (*TokenData, error) {
|
||||
return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil
|
||||
}
|
||||
profilesSaveCorp = func(string, *TokenData) error { return fail }
|
||||
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
|
||||
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repaired identity slot write", func(t *testing.T) {
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) {
|
||||
return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil
|
||||
}
|
||||
profilesSaveIdentity = func(string, string, *TokenData) error { return fail }
|
||||
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
|
||||
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial v2 identity slot read", func(t *testing.T) {
|
||||
profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil }
|
||||
profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }
|
||||
profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, fail }
|
||||
if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) {
|
||||
t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type historicalV1Profile struct {
|
||||
name string
|
||||
corpID string
|
||||
corpName string
|
||||
userID string
|
||||
userName string
|
||||
clientID string
|
||||
}
|
||||
|
||||
type historicalV1052Organization struct {
|
||||
corpID string
|
||||
corpName string
|
||||
userID string
|
||||
userName string
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func seedHistoricalV1052MultiOrganizationState(t *testing.T, configDir string) []historicalV1052Organization {
|
||||
t.Helper()
|
||||
organizations := []historicalV1052Organization{
|
||||
{corpID: "ding_v1052_a", corpName: "V1052 Org A", userID: "legacy-user-v1052-a", userName: "V1052 User A", accessToken: "v1052-access-a"},
|
||||
{corpID: "ding_v1052_b", corpName: "V1052 Org B", userID: "legacy-user-v1052-b", userName: "V1052 User B", accessToken: "v1052-access-b"},
|
||||
{corpID: "ding_v1052_c", corpName: "V1052 Org C", userID: "legacy-user-v1052-c", userName: "V1052 User C", accessToken: "v1052-access-c"},
|
||||
}
|
||||
profiles := make([]historicalV1Profile, 0, len(organizations))
|
||||
for _, organization := range organizations {
|
||||
profiles = append(profiles, historicalV1Profile{
|
||||
name: organization.corpName,
|
||||
corpID: organization.corpID,
|
||||
corpName: organization.corpName,
|
||||
userID: organization.userID,
|
||||
userName: organization.userName,
|
||||
clientID: "ding-client-v1052",
|
||||
})
|
||||
}
|
||||
writeHistoricalV1Profiles(
|
||||
t,
|
||||
configDir,
|
||||
profiles,
|
||||
organizations[0].corpID,
|
||||
organizations[0].corpID,
|
||||
organizations[1].corpID,
|
||||
)
|
||||
|
||||
for _, organization := range organizations {
|
||||
// v1.0.52 MCP responses commonly omitted userId while profiles.json
|
||||
// retained a uniquely known identity.
|
||||
seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON(
|
||||
t,
|
||||
organization.accessToken,
|
||||
organization.corpID,
|
||||
organization.corpName,
|
||||
"",
|
||||
"",
|
||||
))
|
||||
}
|
||||
// v1.0.52 also mirrored the selected organization into the global account.
|
||||
current := organizations[1]
|
||||
seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON(
|
||||
t,
|
||||
current.accessToken,
|
||||
current.corpID,
|
||||
current.corpName,
|
||||
"",
|
||||
"",
|
||||
))
|
||||
return organizations
|
||||
}
|
||||
|
||||
func assertHistoricalV1052IdentitySlots(
|
||||
t *testing.T,
|
||||
organizations []historicalV1052Organization,
|
||||
accessOverrides map[string]string,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, organization := range organizations {
|
||||
migrated, err := LoadTokenDataKeychainForIdentity(organization.corpID, organization.userID)
|
||||
if err != nil {
|
||||
t.Errorf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", organization.corpID, organization.userID, err)
|
||||
continue
|
||||
}
|
||||
wantAccess := organization.accessToken
|
||||
if override := accessOverrides[organization.corpID]; override != "" {
|
||||
wantAccess = override
|
||||
}
|
||||
if migrated.AccessToken != wantAccess ||
|
||||
migrated.CorpID != organization.corpID ||
|
||||
migrated.UserID != organization.userID {
|
||||
t.Errorf(
|
||||
"migrated token for %q = %#v, want access=%q userId=%q",
|
||||
organization.corpID,
|
||||
migrated,
|
||||
wantAccess,
|
||||
organization.userID,
|
||||
)
|
||||
}
|
||||
if accessOverrides[organization.corpID] == "" &&
|
||||
(migrated.RefreshToken != "refresh-"+organization.accessToken ||
|
||||
migrated.PersistentCode != "persistent-"+organization.accessToken ||
|
||||
migrated.ClientID != "ding-client-historical" ||
|
||||
migrated.Source != "mcp") {
|
||||
t.Errorf("historical token fields for %q were not preserved: %#v", organization.corpID, migrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func historicalTokenJSON(t *testing.T, accessToken, corpID, corpName, userID, userName string) string {
|
||||
t.Helper()
|
||||
fixture := map[string]any{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": "refresh-" + accessToken,
|
||||
"persistent_code": "persistent-" + accessToken,
|
||||
"expires_at": "2030-01-02T03:04:05Z",
|
||||
"refresh_expires_at": "2030-02-02T03:04:05Z",
|
||||
"corp_id": corpID,
|
||||
"corp_name": corpName,
|
||||
"client_id": "ding-client-historical",
|
||||
"source": "mcp",
|
||||
}
|
||||
if userID != "" {
|
||||
fixture["user_id"] = userID
|
||||
}
|
||||
if userName != "" {
|
||||
fixture["user_name"] = userName
|
||||
}
|
||||
data, err := json.MarshalIndent(fixture, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal historical token fixture: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func writeHistoricalV1Profiles(
|
||||
t *testing.T,
|
||||
configDir string,
|
||||
profiles []historicalV1Profile,
|
||||
primaryProfile string,
|
||||
previousProfile string,
|
||||
currentProfile string,
|
||||
) {
|
||||
t.Helper()
|
||||
rawProfiles := make([]map[string]any, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
rawProfiles = append(rawProfiles, map[string]any{
|
||||
"name": profile.name,
|
||||
"corpId": profile.corpID,
|
||||
"corpName": profile.corpName,
|
||||
"userId": profile.userID,
|
||||
"userName": profile.userName,
|
||||
"clientId": profile.clientID,
|
||||
"status": "active",
|
||||
"expiresAt": "2030-01-02T03:04:05Z",
|
||||
"refreshExpAt": "2030-02-02T03:04:05Z",
|
||||
})
|
||||
}
|
||||
fixture := map[string]any{
|
||||
"version": 1,
|
||||
"primaryProfile": primaryProfile,
|
||||
"currentProfile": currentProfile,
|
||||
"previousProfile": previousProfile,
|
||||
"profiles": rawProfiles,
|
||||
}
|
||||
data, err := json.MarshalIndent(fixture, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal historical profiles fixture: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("create historical config directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(ProfilesPath(configDir), append(data, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write historical profiles.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedHistoricalTokenSlot(t *testing.T, account, raw string) {
|
||||
t.Helper()
|
||||
if err := keychain.Set(keychain.Service, account, raw); err != nil {
|
||||
t.Fatalf("seed historical keychain account %q: %v", account, err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupHistoricalKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
cleanupKeychain(t)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -39,10 +38,6 @@ var (
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Use MCP mode if clientID is from MCP server
|
||||
if IsClientIDFromMCP() {
|
||||
return p.exchangeCodeViaMCP(ctx, code)
|
||||
@@ -79,13 +74,21 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
// the currently configured client credentials. This is a convenience wrapper
|
||||
// around OAuthProvider.exchangeCode for callers outside the auth package.
|
||||
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
|
||||
if err := prepareLoginPersistence(configDir); err != nil {
|
||||
return nil, fmt.Errorf("local login state cannot be safely updated before token exchange: %w", err)
|
||||
}
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
Output: io.Discard,
|
||||
httpClient: oauthHTTPClient,
|
||||
}
|
||||
return p.exchangeCode(ctx, code)
|
||||
data, err := p.exchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.FreshAuthorization = true
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
@@ -290,6 +293,24 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
const legacyMCPRefreshRejectedCode = "invalidParameter.authCode.notFound"
|
||||
|
||||
// MCPTokenExchangeError preserves the backend business code so refresh callers
|
||||
// can distinguish a legacy credential that requires a new authorization from
|
||||
// transient transport failures.
|
||||
type MCPTokenExchangeError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *MCPTokenExchangeError) Error() string {
|
||||
return fmt.Sprintf("MCP token exchange failed: %s - %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *MCPTokenExchangeError) requiresReauthorization() bool {
|
||||
return strings.TrimSpace(e.Code) == legacyMCPRefreshRejectedCode
|
||||
}
|
||||
|
||||
// parseMCPTokenResponse parses token response from MCP proxy.
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
||||
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
@@ -313,7 +334,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
}
|
||||
// Check for error response
|
||||
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
|
||||
return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
|
||||
return nil, &MCPTokenExchangeError{Code: resp.ErrorCode, Message: resp.ErrorMsg}
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -117,10 +118,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
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)
|
||||
// A damaged selected slot must not make browser reauthorization
|
||||
// impossible. The target identity is unknown until token exchange, so
|
||||
// continue into the full flow and let the target-only preflight reject
|
||||
// an unsafe overwrite after identity enrichment.
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("读取现有登录态失败,将尝试扫码登录"), "error", err)
|
||||
}
|
||||
return nil, fmt.Errorf("load existing access token: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
// Case 1: access_token still valid — no action needed.
|
||||
@@ -150,7 +154,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
if err := prepareLoginPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
@@ -662,6 +666,7 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
|
||||
}
|
||||
return nil, fmt.Errorf("load access token: %w", err)
|
||||
}
|
||||
profileSelector := StableTokenProfileSelector(p.configDir, data)
|
||||
|
||||
// Fast path: access_token still valid — no lock needed.
|
||||
if data.IsAccessTokenValid() {
|
||||
@@ -678,19 +683,40 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
|
||||
// refresh credential. Keep the profile active so a long-running source
|
||||
// can retry after backoff. Terminal and unknown failures remain fatal.
|
||||
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() {
|
||||
return nil, fmt.Errorf(
|
||||
"%s: %w",
|
||||
legacyRefreshReauthorizationGuidance(profileSelector),
|
||||
rErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
||||
} else {
|
||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
|
||||
}
|
||||
|
||||
func legacyRefreshReauthorizationGuidance(profileSelector string) string {
|
||||
guidance := "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请重新运行 dws auth login 完成一次重新授权"
|
||||
profileSelector = strings.TrimSpace(profileSelector)
|
||||
if profileSelector == "" {
|
||||
return guidance
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s;为保留原身份,请把 --profile 参数设置为下方 profile 标识(标识仅作数据展示,不是可执行命令):\nprofile: %s",
|
||||
guidance,
|
||||
strconv.Quote(profileSelector),
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -763,6 +789,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
// external host, exchanges it for tokens, and persists them.
|
||||
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
|
||||
if err := prepareLoginPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
tokenData, err := oauthExchange(p, ctx, authCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
||||
@@ -802,6 +831,9 @@ func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenD
|
||||
"user_id", strings.TrimSpace(tokenData.UserID),
|
||||
"user_name", strings.TrimSpace(tokenData.UserName),
|
||||
)
|
||||
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
if err := oauthSaveToken(p.configDir, tokenData); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -812,14 +844,18 @@ func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenD
|
||||
if tokenData == nil {
|
||||
return fmt.Errorf("token data is empty")
|
||||
}
|
||||
tokenData.FreshAuthorization = true
|
||||
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)
|
||||
}
|
||||
// v1.0.52 and earlier deliberately persisted the freshly exchanged token
|
||||
// before best-effort contact enrichment. External-worker accounts can have a
|
||||
// valid organization token while contact cannot return a userId, so rejecting
|
||||
// that shape here makes an otherwise successful reauthorization impossible.
|
||||
// SaveTokenData remains the safety boundary: an unresolved organization token
|
||||
// cannot overwrite an organization that already has exact account identities.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -830,6 +866,9 @@ func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error {
|
||||
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
||||
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
|
||||
}
|
||||
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
return oauthSaveToken(p.configDir, tokenData)
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,72 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthForcedLoginIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||
})
|
||||
if err := SaveProfiles(f.configDir, &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{{
|
||||
Name: "unrelated",
|
||||
CorpID: "corp-unrelated",
|
||||
UserID: "user-unrelated",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
oldGet := authKeychainGet
|
||||
oldValidate := authValidateEntries
|
||||
t.Cleanup(func() {
|
||||
authKeychainGet = oldGet
|
||||
authValidateEntries = oldValidate
|
||||
})
|
||||
var unrelatedReads atomic.Int32
|
||||
var inventoryCalls atomic.Int32
|
||||
authKeychainGet = func(service, account string) (string, error) {
|
||||
if account == TokenAccountForCorpID("corp-unrelated") ||
|
||||
account == TokenAccountForIdentity("corp-unrelated", "user-unrelated") {
|
||||
unrelatedReads.Add(1)
|
||||
return "", errors.New("unrelated profile ciphertext is unreadable")
|
||||
}
|
||||
return oldGet(service, account)
|
||||
}
|
||||
authValidateEntries = func(string) error {
|
||||
inventoryCalls.Add(1)
|
||||
return errors.New("unrelated orphan ciphertext is unreadable")
|
||||
}
|
||||
|
||||
loginDone := startOAuthLogin(t, context.Background(), f)
|
||||
callbackDone := make(chan oauthHTTPResult, 1)
|
||||
go func() {
|
||||
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=unrelated-safe")
|
||||
}()
|
||||
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
|
||||
closeOAuthRelease(f.exchangeRelease)
|
||||
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
|
||||
closeOAuthRelease(f.statusRelease)
|
||||
|
||||
select {
|
||||
case callback := <-callbackDone:
|
||||
if callback.err != nil || !strings.Contains(callback.body, "<html") {
|
||||
t.Fatalf("OAuth callback body = %q, %v", callback.body, callback.err)
|
||||
}
|
||||
case <-time.After(oauthTestWaitTimeout):
|
||||
t.Fatal("timed out waiting for OAuth callback")
|
||||
}
|
||||
result := awaitOAuthLogin(t, loginDone)
|
||||
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
||||
t.Fatalf("OAuthProvider.Login() = %#v, %v", result.token, result.err)
|
||||
}
|
||||
if got := unrelatedReads.Load(); got != 0 {
|
||||
t.Fatalf("unrelated profile token reads = %d, want 0", got)
|
||||
}
|
||||
if got := inventoryCalls.Load(); got != 0 {
|
||||
t.Fatalf("full inventory validation calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
|
||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||
@@ -685,7 +751,10 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
||||
resetAppConfigCache()
|
||||
oauthHTTPClient = mcpSrv.Client()
|
||||
mcpProvider := &OAuthProvider{configDir: configDir, httpClient: mcpSrv.Client()}
|
||||
mcpOriginal := &TokenData{ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh", CorpID: "corp"}
|
||||
mcpOriginal := &TokenData{
|
||||
ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh",
|
||||
CorpID: "corp", UserID: "user",
|
||||
}
|
||||
if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" {
|
||||
t.Fatalf("MCP refresh = %#v, %v", updated, err)
|
||||
}
|
||||
@@ -1020,6 +1089,10 @@ func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
|
||||
if _, err := p.exchangeCode(context.Background(), "code"); !errors.Is(err, fail) {
|
||||
t.Fatalf("direct exchange request error = %v", err)
|
||||
}
|
||||
oauthHTTPClient = networkClient
|
||||
if _, err := ExchangeCodeForToken(context.Background(), p.configDir, "code"); !errors.Is(err, fail) {
|
||||
t.Fatalf("exchange wrapper request error = %v", err)
|
||||
}
|
||||
p.httpClient = responseClient("{")
|
||||
if _, err := p.exchangeCode(context.Background(), "code"); err == nil {
|
||||
t.Fatal("malformed direct exchange succeeded")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLegacyRefreshReauthorizationGuidanceTreatsProfileAsDisplayData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{name: "command substitution", selector: `external-$(touch marker)`},
|
||||
{name: "backticks", selector: "external-`touch marker`"},
|
||||
{name: "newline", selector: "external\ndws auth reset"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
guidance := legacyRefreshReauthorizationGuidance(tt.selector)
|
||||
|
||||
if !strings.Contains(guidance, "dws auth login") ||
|
||||
!strings.Contains(guidance, "--profile") ||
|
||||
!strings.Contains(guidance, "profile 标识") {
|
||||
t.Fatalf("guidance lacks stable reauthorization instructions: %q", guidance)
|
||||
}
|
||||
if strings.Contains(guidance, "dws auth login --profile") ||
|
||||
strings.Contains(guidance, "--profile "+strconv.Quote(tt.selector)) {
|
||||
t.Fatalf("guidance embeds untrusted selector in an executable command: %q", guidance)
|
||||
}
|
||||
if !strings.Contains(guidance, "profile: "+strconv.Quote(tt.selector)) {
|
||||
t.Fatalf("guidance does not preserve selector as display data: %q", guidance)
|
||||
}
|
||||
if strings.Contains(tt.selector, "\n") && strings.Contains(guidance, tt.selector) {
|
||||
t.Fatalf("guidance retained a raw selector newline: %q", guidance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin(t *testing.T) {
|
||||
guidance := legacyRefreshReauthorizationGuidance("")
|
||||
if !strings.Contains(guidance, "dws auth login") {
|
||||
t.Fatalf("guidance = %q, want login instruction", guidance)
|
||||
}
|
||||
if strings.Contains(guidance, "--profile") || strings.Contains(guidance, "profile:") {
|
||||
t.Fatalf("guidance = %q, should not invent an empty profile value", guidance)
|
||||
}
|
||||
}
|
||||
+572
-68
@@ -15,6 +15,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -75,8 +76,11 @@ func withProfilesLock(configDir string, fn func() error) error {
|
||||
}
|
||||
|
||||
const (
|
||||
profilesJSONFile = "profiles.json"
|
||||
profilesVersion = 2
|
||||
profilesJSONFile = "profiles.json"
|
||||
profilesVersion = 2
|
||||
profilesUnresolvedSelectorVersion = 3
|
||||
profilesMaxVersion = profilesUnresolvedSelectorVersion
|
||||
unresolvedProfileSelectorPrefix = "@legacy/"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -169,6 +173,7 @@ func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
||||
return err
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
normalizeProfilesVersionForSelectors(cfg)
|
||||
if err := profilesMkdirAll(configDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
@@ -207,13 +212,16 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Version > profilesVersion {
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
// Version 2 with no profiles is an intentional logged-out tombstone.
|
||||
// Never resurrect a stale legacy mirror after logout/reset.
|
||||
if cfg.Version >= profilesVersion {
|
||||
if normalizeProfilesVersionForSelectors(cfg) {
|
||||
return profilesSave(configDir, cfg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !profilesTokenExists() {
|
||||
@@ -240,6 +248,9 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg.OrgCurrentProfiles = make(map[string]string)
|
||||
}
|
||||
orgTokens := make(map[string]*TokenData)
|
||||
var legacyToken *TokenData
|
||||
var legacyTokenErr error
|
||||
legacyTokenLoaded := false
|
||||
for i := range cfg.Profiles {
|
||||
p := &cfg.Profiles[i]
|
||||
corpID := strings.TrimSpace(p.CorpID)
|
||||
@@ -255,12 +266,75 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
if loadErr != nil {
|
||||
token = nil
|
||||
}
|
||||
var v2RepairProfile *Profile
|
||||
if !legacySelectionState && errors.Is(loadErr, ErrTokenDataNotFound) {
|
||||
v2RepairProfile = uniqueV2GlobalRepairProfile(cfg, corpID)
|
||||
if v2RepairProfile != nil && strings.TrimSpace(v2RepairProfile.UserID) != "" {
|
||||
_, identityErr := profilesLoadIdentity(corpID, v2RepairProfile.UserID)
|
||||
switch {
|
||||
case identityErr == nil:
|
||||
// The exact identity is already usable. Do not let a stale
|
||||
// global compatibility mirror recreate the organization slot.
|
||||
v2RepairProfile = nil
|
||||
case !errors.Is(identityErr, ErrTokenDataNotFound):
|
||||
return identityErr
|
||||
}
|
||||
}
|
||||
}
|
||||
if (legacySelectionState || v2RepairProfile != nil) && errors.Is(loadErr, ErrTokenDataNotFound) {
|
||||
// v1.0.50/1.0.51 installations can retain the selected
|
||||
// organization only in the global compatibility slot. Consult
|
||||
// that slot while migrating v1, or while repairing a non-empty
|
||||
// v2 registry left half-migrated by an earlier CLI. The v2 path
|
||||
// additionally requires one unambiguous profile, a missing exact
|
||||
// slot when its userId is known, and a non-conflicting token userId.
|
||||
// Persist the untouched organization mirror before identity
|
||||
// enrichment below.
|
||||
if !legacyTokenLoaded {
|
||||
legacyToken, legacyTokenErr = profilesLoadLegacy()
|
||||
legacyTokenLoaded = true
|
||||
}
|
||||
if legacyTokenErr != nil && !errors.Is(legacyTokenErr, ErrTokenDataNotFound) {
|
||||
return legacyTokenErr
|
||||
}
|
||||
legacyMatchesProfile := legacySelectionState ||
|
||||
legacyTokenMatchesV2RepairProfile(legacyToken, v2RepairProfile)
|
||||
if legacyTokenErr == nil &&
|
||||
legacyToken != nil &&
|
||||
strings.TrimSpace(legacyToken.CorpID) == corpID &&
|
||||
legacyMatchesProfile {
|
||||
token = legacyToken
|
||||
if err := profilesSaveCorp(corpID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
orgToken = token
|
||||
orgTokens[corpID] = orgToken
|
||||
}
|
||||
if orgToken == nil {
|
||||
continue
|
||||
}
|
||||
// v1.0.52 stored one token per organization. Some of those token blobs
|
||||
// predate userId persistence even though profiles.json already recorded
|
||||
// the account identity. Version 2 loads exact identities and therefore
|
||||
// cannot safely use an organization mirror with no userId. A single
|
||||
// profile with a known userId makes that association unambiguous,
|
||||
// including when an earlier migration already bumped profiles.json to v2
|
||||
// but failed before writing the identity slot. Enrich only the copy saved
|
||||
// to that exact slot; never infer an identity for an organization with
|
||||
// multiple accounts.
|
||||
identityToken := orgToken
|
||||
if strings.TrimSpace(orgToken.UserID) == "" &&
|
||||
strings.TrimSpace(p.UserID) != "" &&
|
||||
len(profilesForCorpID(cfg, corpID)) == 1 {
|
||||
enriched := *orgToken
|
||||
enriched.UserID = strings.TrimSpace(p.UserID)
|
||||
if strings.TrimSpace(enriched.UserName) == "" {
|
||||
enriched.UserName = strings.TrimSpace(p.UserName)
|
||||
}
|
||||
identityToken = &enriched
|
||||
}
|
||||
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" {
|
||||
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
|
||||
p.CorpID = ""
|
||||
@@ -273,12 +347,12 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(orgToken.UserID) != strings.TrimSpace(p.UserID) {
|
||||
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(identityToken.UserID) != strings.TrimSpace(p.UserID) {
|
||||
continue
|
||||
}
|
||||
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
|
||||
if errors.Is(identityErr, ErrTokenDataNotFound) {
|
||||
if err := profilesSaveIdentity(corpID, p.UserID, orgToken); err != nil {
|
||||
if err := profilesSaveIdentity(corpID, p.UserID, identityToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if identityErr != nil {
|
||||
@@ -324,6 +398,10 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg.CurrentProfile = exact
|
||||
changed = true
|
||||
}
|
||||
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile {
|
||||
cfg.PrimaryProfile = exact
|
||||
changed = true
|
||||
}
|
||||
if legacySelectionState && cfg.CurrentProfile == "" {
|
||||
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
|
||||
cfg.CurrentProfile = exact
|
||||
@@ -345,12 +423,38 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg.Version = profilesVersion
|
||||
changed = true
|
||||
}
|
||||
if normalizeProfilesVersionForSelectors(cfg) {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return profilesSave(configDir, cfg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueV2GlobalRepairProfile returns the only profile that may safely be
|
||||
// recovered from the legacy global token mirror. A v2 registry with multiple
|
||||
// accounts in one organization is deliberately ineligible, even if one token
|
||||
// happens to carry a matching userId: the global slot is a mutable compatibility
|
||||
// mirror and is not authoritative account-selection state. A sole unresolved
|
||||
// profile is eligible only for organization-slot repair; the token matcher below
|
||||
// rejects any global token that tries to attach a userId to it.
|
||||
func uniqueV2GlobalRepairProfile(cfg *ProfilesConfig, corpID string) *Profile {
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
return nil
|
||||
}
|
||||
return profiles[0]
|
||||
}
|
||||
|
||||
func legacyTokenMatchesV2RepairProfile(data *TokenData, profile *Profile) bool {
|
||||
if data == nil || strings.TrimSpace(data.CorpID) != strings.TrimSpace(profile.CorpID) {
|
||||
return false
|
||||
}
|
||||
tokenUserID := strings.TrimSpace(data.UserID)
|
||||
return tokenUserID == "" || tokenUserID == strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
|
||||
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
||||
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
||||
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
||||
@@ -384,7 +488,9 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
||||
return err
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
cfg.Version = profilesVersion
|
||||
if cfg.Version < profilesVersion {
|
||||
cfg.Version = profilesVersion
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
userID := strings.TrimSpace(data.UserID)
|
||||
var previousCurrent *Profile
|
||||
@@ -392,7 +498,11 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
||||
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||
}
|
||||
idx := profileIndexByIdentity(cfg, corpID, userID)
|
||||
if idx < 0 && userID != "" {
|
||||
if idx < 0 && userID != "" && len(profilesForCorpID(cfg, corpID)) == 1 {
|
||||
// Upgrade an organization-scoped v1 profile only when it is the sole
|
||||
// account in that organization. If exact identities already coexist
|
||||
// with a blank profile, consuming the blank profile here would silently
|
||||
// discard that unresolved historical account.
|
||||
idx = legacyProfileIndexByCorpID(cfg, corpID)
|
||||
}
|
||||
if idx < 0 {
|
||||
@@ -409,6 +519,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
||||
UpdatedAt: now,
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, profile)
|
||||
idx = len(cfg.Profiles) - 1
|
||||
} else {
|
||||
p := &cfg.Profiles[idx]
|
||||
if userID != "" {
|
||||
@@ -431,17 +542,33 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
storedProfile := &cfg.Profiles[idx]
|
||||
if userID == "" && len(profilesForCorpID(cfg, corpID)) > 1 &&
|
||||
(strings.TrimSpace(storedProfile.Name) == corpID || profileNameTakenByOtherIdentity(cfg, storedProfile.Name, corpID, "")) {
|
||||
// A blank profile needs a stable name when exact identities coexist in
|
||||
// the same organization; the corpId selector denotes the organization
|
||||
// as a whole and is therefore not an exact account selector.
|
||||
storedProfile.Name = chooseProfileName(cfg, data)
|
||||
}
|
||||
if makeCurrent {
|
||||
newSelector := profileSelector(corpID, userID)
|
||||
if previousCurrent != nil && ProfileSelector(*previousCurrent) != newSelector {
|
||||
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
|
||||
newSelector := storedProfileSelector(cfg, storedProfile)
|
||||
if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector {
|
||||
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
||||
}
|
||||
cfg.CurrentProfile = newSelector
|
||||
setOrgCurrentProfile(cfg, corpID, newSelector)
|
||||
if userID == "" {
|
||||
delete(cfg.OrgCurrentProfiles, corpID)
|
||||
} else {
|
||||
setOrgCurrentProfile(cfg, corpID, newSelector)
|
||||
}
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = profileSelector(corpID, userID)
|
||||
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
|
||||
cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile)
|
||||
if userID == "" {
|
||||
delete(cfg.OrgCurrentProfiles, corpID)
|
||||
} else {
|
||||
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
return profilesSave(configDir, cfg)
|
||||
}
|
||||
@@ -452,6 +579,87 @@ func ProfileSelector(profile Profile) string {
|
||||
return profileSelector(profile.CorpID, profile.UserID)
|
||||
}
|
||||
|
||||
// storedProfileSelector returns a selector that remains exact inside
|
||||
// profiles.json. A blank userId has only an organization selector in the
|
||||
// public compatibility surface; when other accounts share that organization,
|
||||
// use the profile's unique local name so current/previous pointers do not
|
||||
// accidentally resolve to an exact account through OrgCurrentProfiles.
|
||||
func storedProfileSelector(cfg *ProfilesConfig, profile *Profile) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(profile.UserID) != "" {
|
||||
return ProfileSelector(*profile)
|
||||
}
|
||||
if cfg == nil {
|
||||
return strings.TrimSpace(profile.CorpID)
|
||||
}
|
||||
if len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
|
||||
return strings.TrimSpace(profile.CorpID)
|
||||
}
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if localProfileSelectorIsSafe(cfg, profile, name) {
|
||||
return name
|
||||
}
|
||||
return unresolvedProfileSelector(profile.CorpID)
|
||||
}
|
||||
|
||||
// ProfileSelectionSelector returns the stable selector used for one profile.
|
||||
// Exact identities use corpId:userId. A historical profile without userId
|
||||
// keeps the organization selector while it is the only account, and otherwise
|
||||
// uses either an unambiguous local name or a reserved, reversible selector.
|
||||
func ProfileSelectionSelector(profile Profile, cfg *ProfilesConfig) string {
|
||||
return storedProfileSelector(cfg, &profile)
|
||||
}
|
||||
|
||||
func localProfileSelectorIsSafe(cfg *ProfilesConfig, profile *Profile, name string) bool {
|
||||
if cfg == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.Contains(name, ":") || strings.HasPrefix(name, unresolvedProfileSelectorPrefix) {
|
||||
return false
|
||||
}
|
||||
nameMatches := 0
|
||||
for i := range cfg.Profiles {
|
||||
candidate := &cfg.Profiles[i]
|
||||
if strings.TrimSpace(candidate.Name) == name {
|
||||
nameMatches++
|
||||
}
|
||||
// Organization selectors are resolved before ordinary local names.
|
||||
// Never persist a local selector that can be captured by that grammar.
|
||||
if strings.TrimSpace(candidate.CorpID) == name || strings.TrimSpace(candidate.CorpName) == name {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return nameMatches == 1
|
||||
}
|
||||
|
||||
func unresolvedProfileSelector(corpID string) string {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return ""
|
||||
}
|
||||
return unresolvedProfileSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(corpID))
|
||||
}
|
||||
|
||||
func parseUnresolvedProfileSelector(selector string) (string, bool) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if !strings.HasPrefix(selector, unresolvedProfileSelectorPrefix) {
|
||||
return "", false
|
||||
}
|
||||
encoded := strings.TrimPrefix(selector, unresolvedProfileSelectorPrefix)
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
corpID := strings.TrimSpace(string(decoded))
|
||||
if corpID == "" || unresolvedProfileSelector(corpID) != selector {
|
||||
return "", false
|
||||
}
|
||||
return corpID, true
|
||||
}
|
||||
|
||||
// TokenProfileSelector returns the exact identity selector for token data when
|
||||
// its userId is known, otherwise it returns the historical corpId selector.
|
||||
func TokenProfileSelector(data *TokenData) string {
|
||||
@@ -461,6 +669,34 @@ func TokenProfileSelector(data *TokenData) string {
|
||||
return profileSelector(data.CorpID, data.UserID)
|
||||
}
|
||||
|
||||
// StableTokenProfileSelector preserves the exact selector that loaded a token.
|
||||
// This matters for an unresolved historical account sharing an organization
|
||||
// with exact identities: reducing its selector to corpId would follow
|
||||
// OrgCurrentProfiles and could mark or reauthorize a different account.
|
||||
func StableTokenProfileSelector(configDir string, data *TokenData) string {
|
||||
if selector := strings.TrimSpace(RuntimeProfile()); selector != "" {
|
||||
return selector
|
||||
}
|
||||
fallback := TokenProfileSelector(data)
|
||||
if data == nil {
|
||||
return fallback
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil || cfg == nil || strings.TrimSpace(cfg.CurrentProfile) == "" {
|
||||
return fallback
|
||||
}
|
||||
selector := canonicalStoredSelector(cfg, cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
}
|
||||
profile, _, err := resolveProfileSelection(configDir, cfg, selector)
|
||||
if err != nil || profile == nil ||
|
||||
!sameProfileIdentity(profile.CorpID, profile.UserID, data.CorpID, data.UserID) {
|
||||
return fallback
|
||||
}
|
||||
return storedProfileSelector(cfg, profile)
|
||||
}
|
||||
|
||||
func profileSelector(corpID, userID string) string {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
@@ -609,7 +845,13 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
return nil, err
|
||||
}
|
||||
originalCfg := cloneProfilesConfig(cfg)
|
||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
|
||||
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
||||
if !syncOrganization {
|
||||
if err := validateIdentityOnlyProfileToken(*p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -617,20 +859,26 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||
}
|
||||
storedSelector := ProfileSelector(*p)
|
||||
storedSelector := storedProfileSelector(cfg, p)
|
||||
if cfg.CurrentProfile != storedSelector {
|
||||
if previousCurrent != nil {
|
||||
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
|
||||
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
||||
}
|
||||
cfg.CurrentProfile = storedSelector
|
||||
}
|
||||
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
|
||||
if strings.TrimSpace(p.UserID) == "" {
|
||||
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
|
||||
} else {
|
||||
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
|
||||
}
|
||||
touchProfileUsage(p)
|
||||
if err := profilesSave(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
if syncOrganization {
|
||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
}
|
||||
}
|
||||
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
@@ -669,7 +917,13 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
||||
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
|
||||
}
|
||||
originalCfg := cloneProfilesConfig(cfg)
|
||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
|
||||
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
||||
if !syncOrganization {
|
||||
if err := validateIdentityOnlyProfileToken(*p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -677,19 +931,25 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
||||
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||
}
|
||||
cfg.CurrentProfile = ProfileSelector(*p)
|
||||
cfg.CurrentProfile = storedProfileSelector(cfg, p)
|
||||
if current != nil {
|
||||
cfg.PreviousProfile = ProfileSelector(*current)
|
||||
cfg.PreviousProfile = storedProfileSelector(cfg, current)
|
||||
} else {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
|
||||
if strings.TrimSpace(p.UserID) == "" {
|
||||
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
|
||||
} else {
|
||||
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
|
||||
}
|
||||
touchProfileUsage(p)
|
||||
if err := profilesSave(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
if syncOrganization {
|
||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
}
|
||||
}
|
||||
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||
@@ -733,6 +993,21 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
return nil, err
|
||||
}
|
||||
removed := *p
|
||||
originalCurrentSelector := strings.TrimSpace(cfg.CurrentProfile)
|
||||
originalOrganizationCurrent := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)])
|
||||
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
|
||||
pointerMatches := make([]bool, len(pointers))
|
||||
for i, pointer := range pointers {
|
||||
selected, _, resolveErr := resolveProfileSelection(configDir, cfg, *pointer)
|
||||
if resolveErr == nil && selected != nil {
|
||||
if exact {
|
||||
pointerMatches[i] =
|
||||
sameProfileIdentity(selected.CorpID, selected.UserID, removed.CorpID, removed.UserID)
|
||||
} else {
|
||||
pointerMatches[i] = strings.TrimSpace(selected.CorpID) == strings.TrimSpace(removed.CorpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
kept := cfg.Profiles[:0]
|
||||
for _, profile := range cfg.Profiles {
|
||||
remove := profile.CorpID == removed.CorpID
|
||||
@@ -748,7 +1023,7 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
|
||||
remaining := profilesForCorpID(cfg, removed.CorpID)
|
||||
if len(remaining) == 1 {
|
||||
replacementSelector = ProfileSelector(*remaining[0])
|
||||
replacementSelector = storedProfileSelector(cfg, remaining[0])
|
||||
}
|
||||
}
|
||||
if exact {
|
||||
@@ -762,15 +1037,14 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
} else {
|
||||
delete(cfg.OrgCurrentProfiles, removed.CorpID)
|
||||
}
|
||||
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
|
||||
for _, pointer := range pointers {
|
||||
for i, pointer := range pointers {
|
||||
if exact {
|
||||
if selectorMatchesIdentity(*pointer, removed) {
|
||||
if pointerMatches[i] {
|
||||
*pointer = replacementSelector
|
||||
}
|
||||
continue
|
||||
}
|
||||
if selectorTargetsCorp(*pointer, removed.CorpID) {
|
||||
if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) {
|
||||
*pointer = ""
|
||||
}
|
||||
}
|
||||
@@ -779,7 +1053,35 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
cfg.CurrentProfile = previous
|
||||
cfg.PreviousProfile = ""
|
||||
} else if len(cfg.Profiles) == 1 {
|
||||
cfg.CurrentProfile = ProfileSelector(cfg.Profiles[0])
|
||||
cfg.CurrentProfile = storedProfileSelector(cfg, &cfg.Profiles[0])
|
||||
}
|
||||
}
|
||||
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector
|
||||
organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent
|
||||
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||
if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil {
|
||||
if (currentSelectionChanged || organizationCurrentChanged) &&
|
||||
strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) &&
|
||||
unresolvedProfileForCorp(cfg, removed.CorpID) != nil {
|
||||
if strings.TrimSpace(current.UserID) == "" {
|
||||
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID))
|
||||
} else {
|
||||
setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Removing the exact identity that forced a blank sibling to use the v3
|
||||
// reserved selector can make that blank profile the sole account in its
|
||||
// organization. Re-canonicalize every surviving pointer against the final
|
||||
// profile set before SaveProfiles derives the schema version, so the pointer
|
||||
// collapses back to the v2 corpId grammar instead of pinning the file at v3.
|
||||
for _, pointer := range pointers {
|
||||
if canonical := canonicalStoredSelector(cfg, *pointer); canonical != "" {
|
||||
*pointer = canonical
|
||||
}
|
||||
}
|
||||
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
||||
@@ -808,6 +1110,9 @@ func selectorTargetsCorp(selector, corpID string) bool {
|
||||
if selector == corpID {
|
||||
return true
|
||||
}
|
||||
if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
return selectedCorpID == corpID
|
||||
}
|
||||
selectedCorpID, _, exact := ParseIdentitySelector(selector)
|
||||
return exact && selectedCorpID == corpID
|
||||
}
|
||||
@@ -840,28 +1145,72 @@ func markProfileStatusLocked(configDir, selector, status string) error {
|
||||
}
|
||||
|
||||
func ensureProfilesWritable(cfg *ProfilesConfig) error {
|
||||
if cfg != nil && cfg.Version > profilesVersion {
|
||||
if cfg != nil && cfg.Version > profilesMaxVersion {
|
||||
return fmt.Errorf(
|
||||
"profiles.json version %d is newer than supported version %d; upgrade dws before changing profiles",
|
||||
cfg.Version,
|
||||
profilesVersion,
|
||||
profilesMaxVersion,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeProfilesVersionForSelectors derives the persisted schema version
|
||||
// from the final normalized selector grammar. Keep v3 only while a legal
|
||||
// reserved unresolved-identity selector remains on disk; once completion,
|
||||
// deletion, or canonicalization removes that grammar, the file is again safe
|
||||
// for v2 clients and should downgrade to v2.
|
||||
func normalizeProfilesVersionForSelectors(cfg *ProfilesConfig) bool {
|
||||
if cfg == nil || cfg.Version > profilesMaxVersion {
|
||||
return false
|
||||
}
|
||||
|
||||
target := cfg.Version
|
||||
if profilesConfigContainsUnresolvedSelector(cfg) {
|
||||
target = profilesUnresolvedSelectorVersion
|
||||
} else if cfg.Version >= profilesVersion {
|
||||
target = profilesVersion
|
||||
}
|
||||
if target == cfg.Version {
|
||||
return false
|
||||
}
|
||||
cfg.Version = target
|
||||
return true
|
||||
}
|
||||
|
||||
func profilesConfigContainsUnresolvedSelector(cfg *ProfilesConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, selector := range []string{cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile} {
|
||||
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, selector := range cfg.OrgCurrentProfiles {
|
||||
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type profileSelectionMirrorSnapshot struct {
|
||||
organization tokenSlotSnapshot
|
||||
legacy tokenSlotSnapshot
|
||||
marker tokenMarkerSnapshot
|
||||
}
|
||||
|
||||
func snapshotProfileSelectionMirrors(configDir, corpID string) (profileSelectionMirrorSnapshot, error) {
|
||||
organization, err := snapshotTokenSlot(func() (*TokenData, error) {
|
||||
return profilesLoadCorp(corpID)
|
||||
})
|
||||
if err != nil {
|
||||
return profileSelectionMirrorSnapshot{}, err
|
||||
func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) {
|
||||
var organization tokenSlotSnapshot
|
||||
if includeOrganization {
|
||||
var err error
|
||||
organization, err = snapshotTokenSlot(func() (*TokenData, error) {
|
||||
return profilesLoadCorp(corpID)
|
||||
})
|
||||
if err != nil {
|
||||
return profileSelectionMirrorSnapshot{}, err
|
||||
}
|
||||
}
|
||||
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
|
||||
if err != nil {
|
||||
@@ -889,12 +1238,14 @@ func rollbackProfileSelection(
|
||||
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
if mirrors.organization.exists {
|
||||
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
|
||||
if mirrors.organization.known {
|
||||
if mirrors.organization.exists {
|
||||
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
} else if err := profilesDeleteCorp(corpID); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
} else if err := profilesDeleteCorp(corpID); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
if mirrors.legacy.exists {
|
||||
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
|
||||
@@ -975,33 +1326,48 @@ func syncOrganizationTokenMirrorForProfile(profile Profile) error {
|
||||
}
|
||||
|
||||
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
|
||||
if strings.TrimSpace(profile.UserID) != "" {
|
||||
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
data, err := profilesLoadCorp(profile.CorpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgData, orgErr := profilesLoadCorp(profile.CorpID)
|
||||
if orgErr != nil {
|
||||
if errors.Is(orgErr, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, orgErr
|
||||
if data == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
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(data.UserID) != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
|
||||
profile.CorpID,
|
||||
data.UserID,
|
||||
profile.Name,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
|
||||
return nil, err
|
||||
}
|
||||
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
return orgData, nil
|
||||
return data, nil
|
||||
}
|
||||
return profilesLoadCorp(profile.CorpID)
|
||||
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
orgData, orgErr := profilesLoadCorp(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 := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
return orgData, nil
|
||||
}
|
||||
|
||||
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
||||
@@ -1127,6 +1493,12 @@ func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*P
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("profile selector is empty")
|
||||
}
|
||||
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
||||
return profile, true, nil
|
||||
}
|
||||
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
|
||||
}
|
||||
|
||||
if organization, account, compound := ParseIdentitySelector(selector); compound {
|
||||
corpID, err := resolveOrganizationCorpID(cfg, organization)
|
||||
@@ -1218,6 +1590,12 @@ func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Pro
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("profile selector is empty")
|
||||
}
|
||||
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
||||
return profile, true, nil
|
||||
}
|
||||
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
|
||||
}
|
||||
if _, _, compound := ParseIdentitySelector(selector); compound {
|
||||
return resolveProfileSelection("", cfg, selector)
|
||||
}
|
||||
@@ -1303,6 +1681,12 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
|
||||
return p, false, nil
|
||||
}
|
||||
}
|
||||
if unresolved := unresolvedProfileForCorp(cfg, corpID); unresolved != nil {
|
||||
// With no exact organization-current selection, the organization slot
|
||||
// belongs to the sole unresolved historical account. Do not choose an
|
||||
// arbitrary exact identity merely because it shares the corpId.
|
||||
return unresolved, false, nil
|
||||
}
|
||||
if len(profiles) == 1 {
|
||||
return profiles[0], false, nil
|
||||
}
|
||||
@@ -1314,12 +1698,18 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
|
||||
}
|
||||
|
||||
func profileSelectorCandidates(profiles []*Profile) []string {
|
||||
cfg := &ProfilesConfig{Profiles: make([]Profile, 0, len(profiles))}
|
||||
for _, profile := range profiles {
|
||||
if profile != nil {
|
||||
cfg.Profiles = append(cfg.Profiles, *profile)
|
||||
}
|
||||
}
|
||||
candidates := make([]string, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, ProfileSelector(*p))
|
||||
candidates = append(candidates, storedProfileSelector(cfg, p))
|
||||
}
|
||||
sort.Strings(candidates)
|
||||
return candidates
|
||||
@@ -1341,18 +1731,39 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
|
||||
if selector == "" {
|
||||
return ""
|
||||
}
|
||||
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
|
||||
return storedProfileSelector(cfg, profile)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
||||
if p := findExactProfile(cfg, corpID, userID); p != nil {
|
||||
return ProfileSelector(*p)
|
||||
}
|
||||
// A colon-containing legacy local name is recoverable only when it does
|
||||
// not name a real exact identity. Exact corpId:userId always wins.
|
||||
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
|
||||
return storedProfileSelector(cfg, profile)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// Older multi-account writers stored the unresolved profile's local name.
|
||||
// Recover it only when no profile gives the same text organization-selector
|
||||
// meaning. CorpId and CorpName have always outranked local names in the
|
||||
// public resolver; migration must preserve that precedence instead of
|
||||
// silently redirecting one organization's selector to another blank profile.
|
||||
if !selectorConflictsWithOrganizationGrammar(cfg, selector) {
|
||||
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
|
||||
return storedProfileSelector(cfg, profile)
|
||||
}
|
||||
}
|
||||
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
||||
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
|
||||
return exact
|
||||
}
|
||||
if len(profiles) == 1 {
|
||||
return ProfileSelector(*profiles[0])
|
||||
return storedProfileSelector(cfg, profiles[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1360,7 +1771,21 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
|
||||
if err != nil || p == nil {
|
||||
return ""
|
||||
}
|
||||
return ProfileSelector(*p)
|
||||
return storedProfileSelector(cfg, p)
|
||||
}
|
||||
|
||||
func selectorConflictsWithOrganizationGrammar(cfg *ProfilesConfig, selector string) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
for i := range cfg.Profiles {
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector ||
|
||||
strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
|
||||
@@ -1445,6 +1870,79 @@ func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile {
|
||||
return result
|
||||
}
|
||||
|
||||
func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for i := range cfg.Profiles {
|
||||
profile := &cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var match *Profile
|
||||
for i := range cfg.Profiles {
|
||||
profile := &cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name ||
|
||||
len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
|
||||
continue
|
||||
}
|
||||
if match != nil {
|
||||
return nil
|
||||
}
|
||||
match = profile
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// When a blank profile coexists with exact accounts, the organization slot is
|
||||
// that unresolved profile's only canonical credential. Exact identities must
|
||||
// remain in their identity slots and may still become global current without
|
||||
// overwriting the organization slot.
|
||||
func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool {
|
||||
return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil
|
||||
}
|
||||
|
||||
// validateIdentityOnlyProfileToken verifies the canonical token slot before a
|
||||
// profile selection is persisted. This path is used only when an unresolved
|
||||
// profile owns the organization slot, so an exact identity must not fall back
|
||||
// to that slot. It is deliberately read-only: a rejected switch leaves every
|
||||
// selection pointer and compatibility mirror untouched.
|
||||
func validateIdentityOnlyProfileToken(profile Profile) error {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
if corpID == "" || userID == "" {
|
||||
return ErrTokenDataNotFound
|
||||
}
|
||||
data, err := profilesLoadIdentity(corpID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), err)
|
||||
}
|
||||
if data == nil {
|
||||
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), ErrTokenDataNotFound)
|
||||
}
|
||||
if !sameProfileIdentity(data.CorpID, data.UserID, corpID, userID) {
|
||||
return fmt.Errorf(
|
||||
"token in profile slot %q belongs to %q; identity does not match selected profile",
|
||||
ProfileSelector(profile),
|
||||
profileSelector(data.CorpID, data.UserID),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
@@ -1453,6 +1951,12 @@ func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
||||
if selector == "" {
|
||||
return false
|
||||
}
|
||||
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||
return unresolvedProfileForCorp(cfg, corpID) != nil
|
||||
}
|
||||
if unresolvedProfileForLocalName(cfg, selector) != nil {
|
||||
return true
|
||||
}
|
||||
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
||||
if findExactProfile(cfg, corpID, userID) != nil {
|
||||
return true
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -152,4 +153,103 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
if markCalls != 1 {
|
||||
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
|
||||
}
|
||||
|
||||
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{
|
||||
Code: legacyMCPRefreshRejectedCode,
|
||||
Message: "不合法的临时授权码",
|
||||
}
|
||||
}
|
||||
_, err := provider.GetTokenSnapshot(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
||||
!strings.Contains(err.Error(), "--profile") ||
|
||||
!strings.Contains(err.Error(), `profile: "corp:user"`) ||
|
||||
strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) ||
|
||||
!strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) {
|
||||
t.Fatalf("legacy MCP refresh guidance = %v", err)
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(err, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
t.Fatalf("legacy MCP refresh cause was not preserved: %v", err)
|
||||
}
|
||||
if markCalls != 2 {
|
||||
t.Fatalf("legacy MCP rejection marked profile expired %d times, want 2", markCalls)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("External Worker")
|
||||
_, err = provider.GetTokenSnapshot(context.Background())
|
||||
SetRuntimeProfile("")
|
||||
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
||||
!strings.Contains(err.Error(), `profile: "External Worker"`) ||
|
||||
strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) {
|
||||
t.Fatalf("legacy MCP refresh guidance did not isolate spaced selector as display data: %v", err)
|
||||
}
|
||||
if markCalls != 3 {
|
||||
t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
expired.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
expired.RefreshExpAt = time.Now().Add(time.Hour)
|
||||
|
||||
oldLoad := oauthLoadToken
|
||||
oldLoadLocked := oauthLoadTokenLocked
|
||||
oldAcquire := oauthAcquireLock
|
||||
oldRefresh := oauthRefreshToken
|
||||
oldMark := oauthMarkProfile
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
oauthLoadToken = oldLoad
|
||||
oauthLoadTokenLocked = oldLoadLocked
|
||||
oauthAcquireLock = oldAcquire
|
||||
oauthRefreshToken = oldRefresh
|
||||
oauthMarkProfile = oldMark
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
oauthLoadToken = func(string) (*TokenData, error) { return &expired, nil }
|
||||
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return &expired, nil }
|
||||
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
||||
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{
|
||||
Code: legacyMCPRefreshRejectedCode,
|
||||
Message: "legacy refresh rejected",
|
||||
}
|
||||
}
|
||||
var markedSelector string
|
||||
oauthMarkProfile = func(configDir, selector, status string) error {
|
||||
markedSelector = selector
|
||||
return MarkProfileStatus(configDir, selector, status)
|
||||
}
|
||||
|
||||
provider := NewOAuthProvider(fixture.configDir, nil)
|
||||
_, err := provider.GetTokenSnapshot(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
||||
!strings.Contains(err.Error(), "--profile") ||
|
||||
!strings.Contains(err.Error(), "profile: "+strconv.Quote(fixture.blankSelector)) ||
|
||||
strings.Contains(err.Error(), "dws auth login --profile") {
|
||||
t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector)
|
||||
}
|
||||
if markedSelector != fixture.blankSelector {
|
||||
t.Fatalf("marked selector = %q, want blank %q", markedSelector, fixture.blankSelector)
|
||||
}
|
||||
cfg, loadErr := LoadProfiles(fixture.configDir)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", loadErr)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
switch profile.UserID {
|
||||
case "":
|
||||
if profile.Status != ProfileStatusExpired {
|
||||
t.Fatalf("blank profile status = %q, want expired", profile.Status)
|
||||
}
|
||||
case fixture.exactUserID:
|
||||
if profile.Status != ProfileStatusActive {
|
||||
t.Fatalf("exact profile status = %q, want active", profile.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
|
||||
})
|
||||
|
||||
edition.Override(&edition.Hooks{SaveToken: func(string, []byte) error { return nil }})
|
||||
if err := prepareLoginPersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preflightTokenPersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -55,6 +58,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing
|
||||
authValidateEntries = func(string) error { return nil }
|
||||
profileFail := errors.New("profile load failed")
|
||||
profilesReadFile = func(string) ([]byte, error) { return nil, profileFail }
|
||||
if err := prepareLoginPersistence(t.TempDir()); !errors.Is(err, profileFail) {
|
||||
t.Fatalf("schema preflight profile load error = %v", err)
|
||||
}
|
||||
if err := preflightTokenPersistence(t.TempDir()); !errors.Is(err, profileFail) {
|
||||
t.Fatalf("profile load error = %v", err)
|
||||
}
|
||||
|
||||
+313
-75
@@ -59,27 +59,28 @@ var (
|
||||
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}
|
||||
tokenResolveDeletion = resolveProfileDeletionSelection
|
||||
tokenResolveSelection = resolveProfileSelection
|
||||
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
|
||||
tokenRemoveProfile = removeProfileLocked
|
||||
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
||||
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
|
||||
tokenLoadProfiles = LoadProfiles
|
||||
tokenEnsureProfilesMigration = ensureProfilesMigrationLocked
|
||||
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.
|
||||
@@ -96,6 +97,96 @@ type TokenData struct {
|
||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||
// matched historical profile whose userId was never resolved. It is never
|
||||
// persisted as token material.
|
||||
LegacyOrgScopedProfile string `json:"-"`
|
||||
// FreshAuthorization distinguishes a new OAuth/device exchange from a
|
||||
// refresh of the credential already selected locally. Persistence uses this
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
// credential slots a token publication can touch. Both the write path and its
|
||||
// read-only preflight must use this plan so a slot cannot be newly written
|
||||
// without first being checked for unreadable data.
|
||||
//
|
||||
// The plan is intentionally pure: callers supply the already-loaded profile
|
||||
// registry and process-local runtime selector, and no storage is read or
|
||||
// mutated while the decision is made.
|
||||
type tokenPersistenceWritePlan struct {
|
||||
CorpID string
|
||||
UserID string
|
||||
RuntimeSelector string
|
||||
PersistenceSelector string
|
||||
ExactSelector string
|
||||
MakeCurrent bool
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
}
|
||||
|
||||
func planTokenPersistenceWrites(
|
||||
cfg *ProfilesConfig,
|
||||
data *TokenData,
|
||||
runtimeSelector string,
|
||||
) tokenPersistenceWritePlan {
|
||||
plan := tokenPersistenceWritePlan{
|
||||
RuntimeSelector: strings.TrimSpace(runtimeSelector),
|
||||
// Manual and organization-bound publications both snapshot the global
|
||||
// compatibility slot before they can replace or resynchronize it.
|
||||
WriteGlobal: true,
|
||||
}
|
||||
if data == nil {
|
||||
return plan
|
||||
}
|
||||
|
||||
plan.CorpID = strings.TrimSpace(data.CorpID)
|
||||
plan.UserID = strings.TrimSpace(data.UserID)
|
||||
if plan.CorpID == "" {
|
||||
return plan
|
||||
}
|
||||
|
||||
plan.PersistenceSelector = plan.RuntimeSelector
|
||||
if plan.PersistenceSelector == "" && plan.UserID == "" {
|
||||
plan.PersistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
|
||||
}
|
||||
plan.MakeCurrent = plan.PersistenceSelector == ""
|
||||
plan.ExactSelector = profileSelector(plan.CorpID, plan.UserID)
|
||||
plan.ExistingIdentity = profileIndexByIdentity(cfg, plan.CorpID, plan.UserID) >= 0
|
||||
plan.UpgradesLegacyProfile = !plan.ExistingIdentity &&
|
||||
plan.UserID != "" &&
|
||||
len(profilesForCorpID(cfg, plan.CorpID)) == 1 &&
|
||||
legacyProfileIndexByCorpID(cfg, plan.CorpID) >= 0
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
orgCurrentSelector = cfg.OrgCurrentProfiles[plan.CorpID]
|
||||
}
|
||||
|
||||
// A sole unresolved profile is being completed in place during explicit
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
exactProfileSelectorForCorp(
|
||||
cfg,
|
||||
plan.CorpID,
|
||||
orgCurrentSelector,
|
||||
) == plan.ExactSelector))
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// IsAccessTokenValid returns true if the access token has not expired.
|
||||
@@ -219,6 +310,30 @@ func SaveTokenData(configDir string, data *TokenData) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SaveLoginTokenData is the safe persistence boundary for credentials produced
|
||||
// by a new login entry point (OAuth/device/PAT authorization or --token). It
|
||||
// repairs a uniquely recoverable half-migrated legacy login before any global
|
||||
// mirror can be replaced, marks the incoming credential as a fresh
|
||||
// authorization for UID-less account isolation, and preflights every slot the
|
||||
// write plan can touch.
|
||||
//
|
||||
// Refresh paths must continue to use SaveTokenData after their refresh-specific
|
||||
// preflight; treating a refresh as a fresh authorization would incorrectly
|
||||
// reject the selected unresolved account in a multi-account organization.
|
||||
func SaveLoginTokenData(configDir string, data *TokenData) error {
|
||||
if data == nil {
|
||||
return fmt.Errorf("token data is empty")
|
||||
}
|
||||
if err := prepareLoginPersistence(configDir); err != nil {
|
||||
return fmt.Errorf("local login state cannot be safely updated: %w", err)
|
||||
}
|
||||
data.FreshAuthorization = true
|
||||
if err := preflightTokenWritePersistence(configDir, data); err != nil {
|
||||
return fmt.Errorf("local login state cannot be safely updated: %w", err)
|
||||
}
|
||||
return SaveTokenData(configDir, data)
|
||||
}
|
||||
|
||||
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
|
||||
// writes assuming the auth dual-layer lock is already held. Callers that
|
||||
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
|
||||
@@ -235,35 +350,56 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A login may be the first operation after upgrading. Finish a v1
|
||||
// registry migration before an upsert can raise profiles.json to v2;
|
||||
// otherwise untouched organizations would permanently lose their chance
|
||||
// to receive exact identity token slots. Do not re-run v2 repair here:
|
||||
// refresh has already rotated the remote credential at this point, and an
|
||||
// unrelated damaged identity slot must not prevent the new token from
|
||||
// being committed. Normal load/preflight paths repair v2 before exchange.
|
||||
if cfg.Version < profilesVersion {
|
||||
if err := tokenEnsureProfilesMigration(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err = tokenLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile())
|
||||
if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
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,
|
||||
"identity_selector", plan.ExactSelector,
|
||||
"existing_identity", plan.ExistingIdentity,
|
||||
"upgrades_legacy_profile", plan.UpgradesLegacyProfile,
|
||||
"profiles_before", len(cfg.Profiles),
|
||||
"runtime_profile", runtimeSelector,
|
||||
"write_identity_slot", userID != "",
|
||||
"write_org_mirror", mirrorOrg,
|
||||
"write_global_mirror", makeCurrent,
|
||||
"runtime_profile", plan.RuntimeSelector,
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
snapshot, err := snapshotTokenPersistence(
|
||||
configDir,
|
||||
cfg,
|
||||
plan.CorpID,
|
||||
plan.UserID,
|
||||
plan.WriteOrganization,
|
||||
)
|
||||
snapshot, err := snapshotTokenPersistence(configDir, cfg, corpID, userID, mirrorOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preserveManualDefault := !makeCurrent &&
|
||||
preserveManualDefault := !plan.MakeCurrent &&
|
||||
snapshot.marker.known &&
|
||||
snapshot.marker.exists &&
|
||||
snapshot.marker.manual
|
||||
@@ -273,32 +409,28 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
if userID != "" {
|
||||
if plan.WriteIdentity {
|
||||
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 {
|
||||
if err := tokenUpsertProfile(configDir, data, plan.MakeCurrent); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if mirrorOrg {
|
||||
if plan.WriteOrganization {
|
||||
if err := tokenSaveKeychainForCorpID(corpID, data); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
if makeCurrent {
|
||||
if err := tokenSaveKeychain(data); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if !preserveManualDefault {
|
||||
if err := tokenSyncLegacyMirror(configDir); err != nil {
|
||||
return rollback(err)
|
||||
if plan.WriteGlobal {
|
||||
if plan.MakeCurrent {
|
||||
if err := tokenSaveKeychain(data); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if !preserveManualDefault {
|
||||
if err := tokenSyncLegacyMirror(configDir); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if preserveManualDefault {
|
||||
@@ -313,10 +445,11 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"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,
|
||||
"identity_selector", plan.ExactSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"write_global_mirror", plan.WriteGlobal && !preserveManualDefault,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -428,7 +561,7 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error
|
||||
// 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(selected.UserID) == "" || strings.TrimSpace(legacy.UserID) == 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
|
||||
@@ -458,35 +591,129 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error
|
||||
}
|
||||
|
||||
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
userID := strings.TrimSpace(profile.UserID)
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
return tokenLoadKeychainForCorpID(profile.CorpID)
|
||||
data, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(data.CorpID) != corpID {
|
||||
return nil, fmt.Errorf(
|
||||
"organization token mirror for corpId %q contains token for corpId %q; cannot use it for unresolved profile %q",
|
||||
corpID,
|
||||
data.CorpID,
|
||||
profile.Name,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(data.UserID) != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
|
||||
corpID,
|
||||
data.UserID,
|
||||
profile.Name,
|
||||
)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
|
||||
data, err := tokenLoadKeychainIdentity(corpID, userID)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(data.CorpID) != corpID || strings.TrimSpace(data.UserID) != userID {
|
||||
return nil, fmt.Errorf(
|
||||
"identity token slot %q contains token for %q; cannot use it for profile %q",
|
||||
TokenAccountForIdentity(corpID, userID),
|
||||
profileSelector(data.CorpID, data.UserID),
|
||||
ProfileSelector(profile),
|
||||
)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
orgData, orgErr := tokenLoadKeychainForCorpID(profile.CorpID)
|
||||
orgData, orgErr := tokenLoadKeychainForCorpID(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) {
|
||||
if orgData == nil {
|
||||
return nil, err
|
||||
}
|
||||
if saveErr := tokenSaveKeychainForIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf(
|
||||
"organization token mirror for corpId %q contains token for corpId %q; cannot use it for profile %q",
|
||||
corpID,
|
||||
orgData.CorpID,
|
||||
ProfileSelector(profile),
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(orgData.UserID) == "" {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", corpID, ProfileSelector(profile))
|
||||
}
|
||||
if strings.TrimSpace(orgData.UserID) != userID {
|
||||
return nil, err
|
||||
}
|
||||
if saveErr := tokenSaveKeychainForIdentity(corpID, userID, orgData); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
return orgData, nil
|
||||
}
|
||||
|
||||
// validateTokenPersistenceWritePlan prevents a freshly authorized UID-less
|
||||
// credential from being attached to an existing unresolved sibling merely
|
||||
// because both accounts share a corpId. An explicit selector is a storage
|
||||
// boundary, but it is not proof that an exact account completed OAuth. The only
|
||||
// safe overwrite is when that selector itself resolves to the existing
|
||||
// unresolved profile. A blank selector remains allowed for ordinary refreshes
|
||||
// of the already-selected unresolved token.
|
||||
func validateTokenPersistenceWritePlan(
|
||||
cfg *ProfilesConfig,
|
||||
data *TokenData,
|
||||
plan tokenPersistenceWritePlan,
|
||||
) error {
|
||||
if data == nil || plan.CorpID == "" || plan.UserID != "" {
|
||||
return nil
|
||||
}
|
||||
unresolved := unresolvedProfileForCorp(cfg, plan.CorpID)
|
||||
if unresolved == nil {
|
||||
return nil
|
||||
}
|
||||
targetSelector := plan.RuntimeSelector
|
||||
if targetSelector == "" {
|
||||
targetSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
|
||||
}
|
||||
if targetSelector == "" {
|
||||
if data.FreshAuthorization && len(profilesForCorpID(cfg, plan.CorpID)) > 1 {
|
||||
return fmt.Errorf(
|
||||
"refusing to save a fresh UID-less token over existing unresolved profile %q in multi-account organization %q; retry with that unresolved profile selector or require server-provided userId",
|
||||
storedProfileSelector(cfg, unresolved),
|
||||
plan.CorpID,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
selected, _, err := resolveProfileSelection("", cfg, targetSelector)
|
||||
if err == nil && selected != nil &&
|
||||
strings.TrimSpace(selected.CorpID) == plan.CorpID &&
|
||||
strings.TrimSpace(selected.UserID) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"refusing to save UID-less token selected as profile %q over existing unresolved profile %q in organization %q; retry with the unresolved profile selector or require server-provided userId",
|
||||
targetSelector,
|
||||
storedProfileSelector(cfg, unresolved),
|
||||
plan.CorpID,
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -561,12 +788,20 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
removeSelector := removed.CorpID
|
||||
orgCurrent := false
|
||||
if exact {
|
||||
removeSelector = ProfileSelector(removed)
|
||||
orgCurrent = exactProfileSelectorForCorp(
|
||||
cfg,
|
||||
removed.CorpID,
|
||||
cfg.OrgCurrentProfiles[removed.CorpID],
|
||||
) == ProfileSelector(removed)
|
||||
if strings.TrimSpace(removed.UserID) == "" {
|
||||
// A blank profile is exact only when it was selected by its unique
|
||||
// local name. Converting it back to corpId here would turn a
|
||||
// one-profile logout into whole-organization deletion.
|
||||
removeSelector = effectiveSelector
|
||||
orgCurrent = true
|
||||
} else {
|
||||
removeSelector = ProfileSelector(removed)
|
||||
orgCurrent = exactProfileSelectorForCorp(
|
||||
cfg,
|
||||
removed.CorpID,
|
||||
cfg.OrgCurrentProfiles[removed.CorpID],
|
||||
) == ProfileSelector(removed)
|
||||
}
|
||||
}
|
||||
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
|
||||
return err
|
||||
@@ -592,7 +827,8 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
return rollback(loadErr)
|
||||
}
|
||||
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
|
||||
if exact && replacementSelector != "" {
|
||||
preserveUnresolvedOrg := unresolvedProfileForCorp(updated, removed.CorpID) != nil
|
||||
if exact && replacementSelector != "" && !preserveUnresolvedOrg {
|
||||
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
|
||||
if resolveErr != nil {
|
||||
return rollback(resolveErr)
|
||||
@@ -600,8 +836,10 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
} else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
|
||||
return rollback(err)
|
||||
} else if !preserveUnresolvedOrg {
|
||||
if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
preserveManualDefault := markerSnapshot.known &&
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -210,10 +211,48 @@ func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
fixture := seedLegacyBlankAndExactIdentitySlots(t)
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("")
|
||||
if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err)
|
||||
}
|
||||
refreshed := *fixture.beta
|
||||
refreshed.AccessToken = "at_identity_beta_refreshed"
|
||||
if err := SaveTokenData(fixture.configDir, &refreshed); err != nil {
|
||||
t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err)
|
||||
}
|
||||
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
||||
t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err)
|
||||
}
|
||||
|
||||
if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID {
|
||||
t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err)
|
||||
}
|
||||
if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID {
|
||||
t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err)
|
||||
}
|
||||
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
||||
t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err)
|
||||
}
|
||||
|
||||
blankRefresh := *fixture.blank
|
||||
blankRefresh.LegacyOrgScopedProfile = fixture.blankName
|
||||
SetRuntimeProfile(fixture.blankName)
|
||||
if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil ||
|
||||
!strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
|
||||
|
||||
@@ -230,21 +269,12 @@ func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
err := preflightTokenPersistence(configDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want orphan ciphertext inventory error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +334,7 @@ func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
func TestOAuthLoginUnreadableGlobalFailsClosedBeforeAuthorizationStart(t *testing.T) {
|
||||
setPreflightTestCredentials(t)
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
|
||||
@@ -312,33 +342,60 @@ func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
listenErr := errors.New("authorization listener reached")
|
||||
var calls atomic.Int32
|
||||
oldListen := oauthListen
|
||||
oauthListen = func(string, string) (net.Listener, error) {
|
||||
calls.Add(1)
|
||||
return nil, listenErr
|
||||
}
|
||||
t.Cleanup(func() { oauthListen = oldListen })
|
||||
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.NoBrowser = true
|
||||
_, err := provider.Login(ctx, force)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
|
||||
_, err := provider.Login(context.Background(), force)
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want unreadable-global protection", force, err)
|
||||
}
|
||||
if errors.Is(err, listenErr) {
|
||||
t.Fatalf("Login(force=%v) reached authorization listener: %v", force, err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("Login(force=%v) listener calls = %d, want 0", force, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
func TestExchangeAuthCodeRejectsUnreadableGlobalBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
|
||||
existing := testToken("at_exchange", "corp_exchange", "Exchange Org")
|
||||
seedUnreadableTokenStorage(t, configDir, existing)
|
||||
|
||||
var calls atomic.Int32
|
||||
var saveCalls atomic.Int32
|
||||
oldSave := oauthSaveToken
|
||||
oauthSaveToken = func(string, *TokenData) error {
|
||||
saveCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { oauthSaveToken = oldSave })
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
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_exchange"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", existing.UserID)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want target token persistence error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
@@ -346,9 +403,12 @@ func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
if got := saveCalls.Load(); got != 0 {
|
||||
t.Fatalf("SaveTokenData calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
func TestDeviceFlowLoginRejectsUnreadableGlobalBeforeDeviceCodeRequest(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
@@ -366,7 +426,7 @@ func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
provider.SetBaseURL(server.URL)
|
||||
_, err := provider.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want unreadable-global protection", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("device code requests = %d, want 0", got)
|
||||
@@ -418,7 +478,7 @@ func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
cfg.Version = profilesVersion + 1
|
||||
cfg.Version = profilesMaxVersion + 1
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
|
||||
+963
-20
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"source_hash": "sha256:f7d9911baa7c829febb0f461c1ae773d9bd4747b73a79c6fc4cd8a0e3956561c",
|
||||
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
|
||||
"coverage": {
|
||||
"surface_products": 22,
|
||||
"products_with_metadata": 22,
|
||||
"surface_tools": 572,
|
||||
"tools_with_metadata": 572,
|
||||
"tools_with_agent_summary": 572,
|
||||
"tools_with_use_when": 572,
|
||||
"tools_with_avoid_when": 572,
|
||||
"tools_with_examples": 572,
|
||||
"tools_with_interface_mode": 572,
|
||||
"unmatched_skill_tools": 120,
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 813,
|
||||
"tools_with_metadata": 813,
|
||||
"tools_with_agent_summary": 813,
|
||||
"tools_with_use_when": 813,
|
||||
"tools_with_avoid_when": 813,
|
||||
"tools_with_examples": 813,
|
||||
"tools_with_interface_mode": 813,
|
||||
"unmatched_skill_tools": 118,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
"products": {
|
||||
@@ -444,20 +444,20 @@
|
||||
]
|
||||
},
|
||||
"contact": {
|
||||
"agent_summary": "查询通讯录与花名册,并创建企业、企业账号或邀请员工",
|
||||
"agent_summary": "查询通讯录与花名册,并管理企业、部门、员工及企业账号",
|
||||
"agent_summary_source": "dws-agent-selection/contact",
|
||||
"avoid_when": [
|
||||
"职责/上级等语义找人优先 aisearch person;不要用 contact 发消息;写操作前确认当前企业和目标信息"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "查询通讯录与花名册,并创建企业、企业账号或邀请员工",
|
||||
"value": "查询通讯录与花名册,并管理企业、部门、员工及企业账号",
|
||||
"source": "internal/cli/schema_hints/selection/contact.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "查询通讯录与花名册,并创建企业、企业账号或邀请员工",
|
||||
"value": "查询通讯录与花名册,并管理企业、部门、员工及企业账号",
|
||||
"source": "internal/cli/schema_hints/selection/contact.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
@@ -585,6 +585,74 @@
|
||||
"创建/配置开放平台应用、机器人、权限、事件订阅或发布版本"
|
||||
]
|
||||
},
|
||||
"devapp": {
|
||||
"agent_summary": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"agent_summary_source": "dws-agent-selection/devapp",
|
||||
"avoid_when": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本",
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devapp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=devapp",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_hints/selection/devapp.json",
|
||||
"internal/shortcut/devapp"
|
||||
],
|
||||
"use_when": [
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理"
|
||||
]
|
||||
},
|
||||
"devdoc": {
|
||||
"agent_summary": "搜索钉钉开放平台开发文档与错误排查资料",
|
||||
"agent_summary_source": "dws-agent-selection/devdoc",
|
||||
@@ -941,6 +1009,78 @@
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
},
|
||||
"hrbrain": {
|
||||
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"agent_summary_source": "dws-agent-selection/hrbrain",
|
||||
"avoid_when": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=hrbrain",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md",
|
||||
"internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"skills/mono/SKILL.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
]
|
||||
},
|
||||
"live": {
|
||||
"agent_summary": "查询当前用户发起的直播列表",
|
||||
"agent_summary_source": "dws-agent-selection/live",
|
||||
@@ -1089,6 +1229,142 @@
|
||||
"查收、搜索、阅读、回复、发送或整理邮件"
|
||||
]
|
||||
},
|
||||
"markdown": {
|
||||
"agent_summary": "跨钉盘与文档空间创建、获取、覆盖和局部修补原生 Markdown 文件",
|
||||
"agent_summary_source": "dws-agent-selection/markdown",
|
||||
"avoid_when": [
|
||||
"在线文档正文操作使用 doc;普通二进制文件上传下载使用 drive"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "跨钉盘与文档空间创建、获取、覆盖和局部修补原生 Markdown 文件",
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "跨钉盘与文档空间创建、获取、覆盖和局部修补原生 Markdown 文件",
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"在线文档正文操作使用 doc;普通二进制文件上传下载使用 drive"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"在线文档正文操作使用 doc;普通二进制文件上传下载使用 drive"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"目标是原生 .md 文件,并需要在 Drive/Doc 路由间安全处理内容时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"目标是原生 .md 文件,并需要在 Drive/Doc 路由间安全处理内容时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/markdown.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=markdown",
|
||||
"Wukong-parity:3306c3307",
|
||||
"internal/cli/schema_hints/selection/markdown.json",
|
||||
"skills/mono/SKILL.md"
|
||||
],
|
||||
"use_when": [
|
||||
"目标是原生 .md 文件,并需要在 Drive/Doc 路由间安全处理内容时"
|
||||
]
|
||||
},
|
||||
"mcp": {
|
||||
"agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息",
|
||||
"agent_summary_source": "dws-agent-selection/mcp",
|
||||
"avoid_when": [
|
||||
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "解析和管理当前身份可用的 MCP 服务连接信息",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "解析和管理当前身份可用的 MCP 服务连接信息",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=mcp",
|
||||
"cobra-help:dws mcp --help",
|
||||
"internal/cli/schema_command_registry.json#mcp",
|
||||
"internal/cli/schema_hints/selection/mcp.json"
|
||||
],
|
||||
"use_when": [
|
||||
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端"
|
||||
]
|
||||
},
|
||||
"minutes": {
|
||||
"agent_summary": "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
"agent_summary_source": "dws-agent-selection/minutes",
|
||||
@@ -1450,20 +1726,20 @@
|
||||
]
|
||||
},
|
||||
"todo": {
|
||||
"agent_summary": "管理待办任务、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"agent_summary": "管理待办任务、标签、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"agent_summary_source": "dws-agent-selection/todo",
|
||||
"avoid_when": [
|
||||
"不要用于 OA 审批流转、工作日志提交或日历日程管理"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "管理待办任务、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"value": "管理待办任务、标签、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "管理待办任务、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"value": "管理待办任务、标签、子任务、执行人、参与人、评论、附件与提醒",
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
@@ -1598,13 +1874,17 @@
|
||||
"chat",
|
||||
"contact",
|
||||
"dev",
|
||||
"devapp",
|
||||
"devdoc",
|
||||
"ding",
|
||||
"doc",
|
||||
"drive",
|
||||
"event",
|
||||
"hrbrain",
|
||||
"live",
|
||||
"mail",
|
||||
"markdown",
|
||||
"mcp",
|
||||
"minutes",
|
||||
"oa",
|
||||
"pat",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"product_id": "mcp",
|
||||
"tools": {
|
||||
"mcp url get": {
|
||||
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
"agent_summary_source": "dws-agent-selection/mcp",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws mcp url get 10043 --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/mcp.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source_refs": [
|
||||
"cobra-help:dws mcp url get --help",
|
||||
"internal/app/mcp_url_command.go",
|
||||
"internal/cli/schema_command_registry.json#mcp.url_get",
|
||||
"internal/cli/schema_hints/metadata/mcp.json",
|
||||
"internal/cli/schema_hints/selection/mcp.json",
|
||||
"pkg/edition/default.go#openSupplementServers"
|
||||
],
|
||||
"use_when": [
|
||||
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,525 @@
|
||||
{
|
||||
"product_id": "report",
|
||||
"tools": {
|
||||
"report +inbox-list": {
|
||||
"agent_summary": "列出我收到的日报(按时间范围分页)",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "列出我收到的日报(按时间范围分页)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "列出我收到的日报(按时间范围分页)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:report +inbox-list",
|
||||
"cobra-help:dws report +inbox-list",
|
||||
"internal/cli/schema_command_registry.json#report.shortcut_inbox_list",
|
||||
"internal/cli/schema_hints/metadata/report.json",
|
||||
"internal/cli/schema_hints/selection/report.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"
|
||||
]
|
||||
},
|
||||
"report +outbox-list": {
|
||||
"agent_summary": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "列出我发出的日报(可选时间/模版名过滤)",
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/report.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:report +outbox-list",
|
||||
"cobra-help:dws report +outbox-list",
|
||||
"internal/cli/schema_command_registry.json#report.shortcut_outbox_list",
|
||||
"internal/cli/schema_hints/metadata/report.json",
|
||||
"internal/cli/schema_hints/selection/report.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"
|
||||
]
|
||||
},
|
||||
"report entry get": {
|
||||
"agent_summary": "获取指定一篇日志的详情信息",
|
||||
"agent_summary_source": "dws-agent-selection/report",
|
||||
|
||||
@@ -1,6 +1,522 @@
|
||||
{
|
||||
"product_id": "sheet",
|
||||
"tools": {
|
||||
"sheet +list-sheets": {
|
||||
"agent_summary": "获取表格文档中全部工作表列表",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "获取表格文档中全部工作表列表",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "获取表格文档中全部工作表列表",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet +list-sheets --node NODE_ID"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:sheet +list-sheets",
|
||||
"cobra-help:dws sheet +list-sheets",
|
||||
"internal/cli/schema_command_registry.json#sheet.shortcut_list_sheets",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"
|
||||
]
|
||||
},
|
||||
"sheet +read": {
|
||||
"agent_summary": "读取工作表指定范围的结构化单元格数据",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "读取工作表指定范围的结构化单元格数据",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "读取工作表指定范围的结构化单元格数据",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:sheet +read",
|
||||
"cobra-help:dws sheet +read",
|
||||
"internal/cli/schema_command_registry.json#sheet.shortcut_read",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"
|
||||
]
|
||||
},
|
||||
"sheet add-dimension": {
|
||||
"agent_summary": "在工作表末尾追加空行或空列。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,264 @@
|
||||
{
|
||||
"product_id": "wiki",
|
||||
"tools": {
|
||||
"wiki +space-search": {
|
||||
"agent_summary": "搜索知识库",
|
||||
"agent_summary_source": "dws-agent-selection/wiki",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索知识库",
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "搜索知识库",
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws wiki +space-search --query \"产品文档\""
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/wiki.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"ShortcutRegistry:wiki +space-search",
|
||||
"cobra-help:dws wiki +space-search",
|
||||
"internal/cli/schema_command_registry.json#wiki.shortcut_space_search",
|
||||
"internal/cli/schema_hints/metadata/wiki.json",
|
||||
"internal/cli/schema_hints/selection/wiki.json"
|
||||
],
|
||||
"use_when": [
|
||||
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
|
||||
]
|
||||
},
|
||||
"wiki member add": {
|
||||
"agent_summary": "为指定知识库添加一个或多个成员,并授予指定角色",
|
||||
"agent_summary_source": "dws-agent-selection/wiki",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+144277
-291
File diff suppressed because it is too large
Load Diff
@@ -48,223 +48,6 @@
|
||||
"version"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shortcut-catalog-surface",
|
||||
"reason": "Shortcut commands are high-level task entries discovered through dws shortcut list and product skills; they are intentionally separate from the runtime Schema catalog of base Agent tools.",
|
||||
"reviewed": true,
|
||||
"commands": [
|
||||
"aitable +base-get",
|
||||
"aitable +base-list",
|
||||
"aitable +base-search",
|
||||
"aitable +chart-get",
|
||||
"aitable +chart-widgets-example",
|
||||
"aitable +dashboard-config-example",
|
||||
"aitable +dashboard-get",
|
||||
"aitable +field-get",
|
||||
"aitable +find-record",
|
||||
"aitable +form-field-list",
|
||||
"aitable +form-list",
|
||||
"aitable +form-share-get",
|
||||
"aitable +list-tables",
|
||||
"aitable +record-history-list",
|
||||
"aitable +record-query",
|
||||
"aitable +record-query-empty",
|
||||
"aitable +record-share-links",
|
||||
"aitable +record-share-url",
|
||||
"aitable +resolve-base",
|
||||
"aitable +resolve-table",
|
||||
"aitable +role-list",
|
||||
"aitable +section-list-empty",
|
||||
"aitable +section-list-nodes",
|
||||
"aitable +table-get",
|
||||
"aitable +template-search",
|
||||
"aitable +view-get",
|
||||
"aitable +view-get-frozen-cols",
|
||||
"aitable +view-get-lock",
|
||||
"aitable +view-get-row-height",
|
||||
"attendance +check-record",
|
||||
"attendance +check-result",
|
||||
"attendance +get-adjustment-rule",
|
||||
"attendance +get-approve-template",
|
||||
"attendance +get-checkin-record",
|
||||
"attendance +get-leave-records",
|
||||
"attendance +get-overtime-rule",
|
||||
"attendance +get-schedule",
|
||||
"attendance +get-self-setting",
|
||||
"attendance +get-summary",
|
||||
"attendance +list-approve",
|
||||
"attendance +list-leave-types",
|
||||
"attendance +my-attendance",
|
||||
"attendance +query-report-data",
|
||||
"attendance +search-adjustment-rule",
|
||||
"attendance +search-class",
|
||||
"attendance +search-group",
|
||||
"attendance +search-overtime-rule",
|
||||
"attendance +this-month",
|
||||
"calendar +agenda",
|
||||
"calendar +attendee-list",
|
||||
"calendar +book",
|
||||
"calendar +book-list",
|
||||
"calendar +book-search",
|
||||
"calendar +cancel-event",
|
||||
"calendar +conflicts",
|
||||
"calendar +free",
|
||||
"calendar +free-slots",
|
||||
"calendar +freebusy",
|
||||
"calendar +invite",
|
||||
"calendar +my-free",
|
||||
"calendar +next-event",
|
||||
"calendar +reschedule",
|
||||
"calendar +room-groups",
|
||||
"calendar +room-search",
|
||||
"calendar +suggest-time",
|
||||
"calendar +today",
|
||||
"calendar +tomorrow",
|
||||
"calendar +week",
|
||||
"chat +at-me",
|
||||
"chat +bot-find",
|
||||
"chat +bot-search",
|
||||
"chat +broadcast",
|
||||
"chat +category-create",
|
||||
"chat +category-delete",
|
||||
"chat +category-list",
|
||||
"chat +category-rename",
|
||||
"chat +chat-bots",
|
||||
"chat +chat-dismiss",
|
||||
"chat +chat-invite-url",
|
||||
"chat +chat-list-all",
|
||||
"chat +chat-list-join-requests",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-mute",
|
||||
"chat +chat-role-add",
|
||||
"chat +chat-role-list",
|
||||
"chat +chat-role-query-user",
|
||||
"chat +chat-role-set-user",
|
||||
"chat +chat-role-update",
|
||||
"chat +chat-search",
|
||||
"chat +chat-set-admin",
|
||||
"chat +chat-set-history",
|
||||
"chat +chat-update-alias",
|
||||
"chat +chat-update-nick",
|
||||
"chat +conversation-clear-all-red-point",
|
||||
"chat +conversation-info",
|
||||
"chat +conversation-list",
|
||||
"chat +conversation-list-top",
|
||||
"chat +dm",
|
||||
"chat +group-members",
|
||||
"chat +messages-list-direct",
|
||||
"chat +messages-list-pin",
|
||||
"chat +messages-list-unread-conversations",
|
||||
"chat +messages-mget",
|
||||
"chat +messages-query-send-status",
|
||||
"chat +messages-read-status",
|
||||
"chat +messages-send-by-webhook",
|
||||
"chat +messages-update-card",
|
||||
"chat +my-groups",
|
||||
"chat +send-to-group",
|
||||
"chat +unread-chats",
|
||||
"contact +by-mobile",
|
||||
"contact +dept-members",
|
||||
"contact +list-dept-members",
|
||||
"contact +list-followings",
|
||||
"contact +list-role-members",
|
||||
"contact +list-roles",
|
||||
"contact +list-sub-depts",
|
||||
"contact +lookup",
|
||||
"contact +me",
|
||||
"contact +org",
|
||||
"contact +resolve-dept",
|
||||
"contact +search-mobile",
|
||||
"contact +search-user",
|
||||
"contact +team",
|
||||
"devapp +create",
|
||||
"devapp +delete",
|
||||
"devapp +disable",
|
||||
"devapp +enable",
|
||||
"devapp +event-list",
|
||||
"devapp +get",
|
||||
"devapp +list",
|
||||
"devapp +member-add",
|
||||
"devapp +member-list",
|
||||
"devapp +member-remove",
|
||||
"devapp +permission-list",
|
||||
"devapp +robot-get",
|
||||
"devapp +update",
|
||||
"devapp +version-check-approval",
|
||||
"devapp +version-get",
|
||||
"devapp +version-list",
|
||||
"devapp +version-status",
|
||||
"devapp +webapp-config",
|
||||
"devapp +webapp-get",
|
||||
"ding +list",
|
||||
"ding +recall-personal",
|
||||
"ding +receiver-status",
|
||||
"ding +send-personal",
|
||||
"doc +comment-create",
|
||||
"doc +comment-list",
|
||||
"doc +comment-reply",
|
||||
"doc +copy",
|
||||
"doc +doc-append",
|
||||
"doc +export-get",
|
||||
"doc +export-submit",
|
||||
"doc +find-doc",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc +search",
|
||||
"doc +share-doc",
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +version-list",
|
||||
"doc +version-revert",
|
||||
"doc +version-save",
|
||||
"drive +copy",
|
||||
"drive +find-file",
|
||||
"drive +info",
|
||||
"drive +move",
|
||||
"drive +recent",
|
||||
"drive +search",
|
||||
"drive +search-docs",
|
||||
"mail +contact-list",
|
||||
"mail +find-mail-user",
|
||||
"mail +folder-list",
|
||||
"mail +recent-mail",
|
||||
"mail +search-mail",
|
||||
"mail +tag-list",
|
||||
"mail +template-list",
|
||||
"mail +thread-list",
|
||||
"mail +unread-mail",
|
||||
"mail +user-search",
|
||||
"minutes +detail",
|
||||
"minutes +list-all",
|
||||
"minutes +list-mine",
|
||||
"minutes +list-shared",
|
||||
"minutes +record-start",
|
||||
"minutes +replace-batch",
|
||||
"oa +list-cc",
|
||||
"oa +list-executed",
|
||||
"oa +list-forms",
|
||||
"oa +list-pending",
|
||||
"oa +list-submitted",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"report +inbox-list",
|
||||
"report +outbox-list",
|
||||
"sheet +list-sheets",
|
||||
"sheet +read",
|
||||
"todo +assign",
|
||||
"todo +assign-multi",
|
||||
"todo +created-todos",
|
||||
"todo +get",
|
||||
"todo +get-my-tasks",
|
||||
"todo +list-attachment",
|
||||
"todo +list-comment",
|
||||
"todo +list-sub",
|
||||
"todo +overdue",
|
||||
"todo +remind",
|
||||
"todo +todo-done",
|
||||
"wiki +space-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agoal-out-of-surface",
|
||||
"reason": "The Agoal product remains executable for compatibility but is outside the currently reviewed open-source Agent command surface.",
|
||||
@@ -316,12 +99,10 @@
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat group update-nick",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat media upload",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
|
||||
@@ -34,7 +34,7 @@ var embeddedSchemaCommandRegistrySchemaJSON []byte
|
||||
var (
|
||||
commandRegistryProductIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
|
||||
commandRegistryCanonicalPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
commandRegistryCLIPathToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
|
||||
commandRegistryCLIPathToken = regexp.MustCompile(`^(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\+[A-Za-z0-9][A-Za-z0-9._:-]*)$`)
|
||||
)
|
||||
|
||||
type schemaCommandRegistrySnapshot struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"cliPath": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])[A-Za-z0-9][A-Za-z0-9._:-]*(?: [A-Za-z0-9][A-Za-z0-9._:-]*)*$",
|
||||
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace."
|
||||
"pattern": "^(?!dws(?:\\s|$))(?!.*(?:^|\\s)--)(?!.*[*?\\[\\]])(?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*)(?: (?:[A-Za-z0-9][A-Za-z0-9._:-]*|\\+[A-Za-z0-9][A-Za-z0-9._:-]*))*$",
|
||||
"description": "Exact Cobra command path without the leading 'dws', flags, wildcard characters, or surrounding/repeated whitespace. A token may use the reviewed '+' shortcut prefix."
|
||||
},
|
||||
"product": {
|
||||
"type": "object",
|
||||
|
||||
@@ -94,6 +94,14 @@ func TestDecodeCommandRegistryEnforcesReviewedSourceConstraints(t *testing.T) {
|
||||
if got := registry.ByCanonical["sample.run"].Visibility; got != SchemaVisibilityPublic {
|
||||
t.Fatalf("default visibility = %q, want public", got)
|
||||
}
|
||||
|
||||
shortcut, err := decodeCommandRegistry([]byte(wrap(`[{"id":"sample","tools":[{"canonical_path":"sample.shortcut_run","cli_path":"sample +run"}]}]`)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode shortcut registry path: %v", err)
|
||||
}
|
||||
if got := shortcut.ByCanonical["sample.shortcut_run"].PrimaryCLIPath; got != "sample +run" {
|
||||
t.Fatalf("shortcut primary path = %q, want sample +run", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRegistryHashCoversEveryStableCommandField(t *testing.T) {
|
||||
|
||||
@@ -35,6 +35,10 @@ var reviewedDryRunCapabilityGroups = []dryRunCapabilityGroup{
|
||||
"doc.upload",
|
||||
"drive.download_file",
|
||||
"drive.upload",
|
||||
"markdown.create",
|
||||
"markdown.fetch",
|
||||
"markdown.overwrite",
|
||||
"markdown.patch",
|
||||
"sheet.filter_view_get_criteria",
|
||||
"sheet.filter_view_info",
|
||||
"sheet.filter_view_list_criteria",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"chat": "metadata/chat.json",
|
||||
"contact": "metadata/contact.json",
|
||||
"dev": "metadata/dev.json",
|
||||
"devapp": "metadata/devapp.json",
|
||||
"devdoc": "metadata/devdoc.json",
|
||||
"ding": "metadata/ding.json",
|
||||
"doc": "metadata/doc.json",
|
||||
"drive": "metadata/drive.json",
|
||||
"event": "metadata/event.json",
|
||||
"hrbrain": "metadata/hrbrain.json",
|
||||
"live": "metadata/live.json",
|
||||
"mail": "metadata/mail.json",
|
||||
"markdown": "metadata/markdown.json",
|
||||
"mcp": "metadata/mcp.json",
|
||||
"minutes": "metadata/minutes.json",
|
||||
"oa": "metadata/oa.json",
|
||||
"pat": "metadata/pat.json",
|
||||
@@ -40,13 +44,17 @@
|
||||
"chat": "selection/chat.json",
|
||||
"contact": "selection/contact.json",
|
||||
"dev": "selection/dev.json",
|
||||
"devapp": "selection/devapp.json",
|
||||
"devdoc": "selection/devdoc.json",
|
||||
"ding": "selection/ding.json",
|
||||
"doc": "selection/doc.json",
|
||||
"drive": "selection/drive.json",
|
||||
"event": "selection/event.json",
|
||||
"hrbrain": "selection/hrbrain.json",
|
||||
"live": "selection/live.json",
|
||||
"mail": "selection/mail.json",
|
||||
"markdown": "selection/markdown.json",
|
||||
"mcp": "selection/mcp.json",
|
||||
"minutes": "selection/minutes.json",
|
||||
"oa": "selection/oa.json",
|
||||
"pat": "selection/pat.json",
|
||||
@@ -434,10 +442,6 @@
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"chat media upload": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"chat message list-emotion-replies": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
|
||||
@@ -1056,6 +1056,383 @@
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"cli_path": "aitable workflow update",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_base_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +base-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_base_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +base-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_base_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +base-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_table_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +table-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_field_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +field-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_record_query": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +record-query",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_record_query_empty": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +record-query-empty",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_record_history_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +record-history-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_record_share_url": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +record-share-url",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_template_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +template-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_view_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +view-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_view_get_lock": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +view-get-lock",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_view_get_frozen_cols": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +view-get-frozen-cols",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_view_get_row_height": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +view-get-row-height",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_form_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +form-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_form_field_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +form-field-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_form_share_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +form-share-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_dashboard_config_example": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +dashboard-config-example",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_dashboard_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +dashboard-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_chart_widgets_example": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +chart-widgets-example",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_chart_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +chart-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_role_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +role-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_section_list_empty": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +section-list-empty",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_section_list_nodes": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +section-list-nodes",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_find_record": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +find-record",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_list_tables": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +list-tables",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_record_share_links": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +record-share-links",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_resolve_base": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +resolve-base",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.shortcut_resolve_table": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "aitable +resolve-table",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +428,253 @@
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"attendance.shortcut_check_result": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +check-result",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_check_record": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +check-record",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_list_approve": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +list-approve",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_approve_template": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-approve-template",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_schedule": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-schedule",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_search_class": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +search-class",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_adjustment_rule": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-adjustment-rule",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_search_adjustment_rule": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +search-adjustment-rule",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_overtime_rule": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-overtime-rule",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_search_overtime_rule": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +search-overtime-rule",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_search_group": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +search-group",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_summary": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-summary",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_self_setting": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-self-setting",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_query_report_data": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +query-report-data",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_list_leave_types": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +list-leave-types",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_leave_records": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-leave-records",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_get_checkin_record": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +get-checkin-record",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_my_attendance": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +my-attendance",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"attendance.shortcut_this_month": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "attendance +this-month",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,266 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"calendar.shortcut_agenda": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +agenda",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_attendee_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +attendee-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_room_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +room-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_room_groups": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +room-groups",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_freebusy": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +freebusy",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_book_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +book-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_book_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +book-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_book": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +book",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"calendar.shortcut_cancel_event": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +cancel-event",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"calendar.shortcut_conflicts": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +conflicts",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_free_slots": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +free-slots",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_free": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +free",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_invite": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +invite",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"calendar.shortcut_my_free": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +my-free",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_next_event": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +next-event",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_reschedule": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +reschedule",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"calendar.shortcut_suggest_time": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +suggest-time",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_today": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +today",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_tomorrow": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +tomorrow",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"calendar.shortcut_week": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "calendar +week",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +649,703 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"chat.edit_message": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI builds or accepts message content and calls im/edit_message, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"conversation-id": {
|
||||
"property": "openConversationId",
|
||||
"required": true
|
||||
},
|
||||
"group": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"id": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"chat": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"msg-id": {
|
||||
"property": "openMessageId",
|
||||
"required": true
|
||||
},
|
||||
"text": {
|
||||
"property": "text",
|
||||
"required": false
|
||||
},
|
||||
"title": {
|
||||
"property": "title",
|
||||
"required": false
|
||||
},
|
||||
"content": {
|
||||
"property": "content",
|
||||
"required": false
|
||||
},
|
||||
"at-all": {
|
||||
"property": "atAll",
|
||||
"required": false,
|
||||
"interface_type": "boolean"
|
||||
},
|
||||
"at-open-dingtalk-ids": {
|
||||
"property": "atOpenDingTalkIds",
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires one conversation locator, a message ID, and exactly one of text or raw content; it optionally derives a title and maps mention controls before invoking edit_message.",
|
||||
"cli_path": "chat message edit",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.get_conv_categories_info": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI parses category IDs and calls im/get_conv_categories_info, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"category-ids": {
|
||||
"property": "categoryIds",
|
||||
"required": true,
|
||||
"interface_type": "array"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires a comma-separated category-ID list, parses it to integers, and invokes get_conv_categories_info without a mutation or confirmation gate.",
|
||||
"cli_path": "chat category batch-info",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_conv_categories_by_conv": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps a conversation locator to im/list_conv_categories_by_conv, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"group": {
|
||||
"property": "openConversationId",
|
||||
"required": true
|
||||
},
|
||||
"conversation-id": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
},
|
||||
"id": {
|
||||
"property": "openConversationId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler accepts one of three conversation locator aliases and invokes list_conv_categories_by_conv without a mutation or confirmation gate.",
|
||||
"cli_path": "chat category list-by-conv",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.update_group_nick": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps nickname update or clear semantics to im/update_group_nick, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"group": {
|
||||
"property": "openConversationId",
|
||||
"required": true
|
||||
},
|
||||
"nick": {
|
||||
"property": "nick",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires the target conversation while intentionally allowing omitted --nick to send an empty nickname and clear the current user's group nickname.",
|
||||
"cli_path": "chat group update-nick",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.upgrade_group_to_external": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI validates an optional string map and calls im/upgrade_group_to_external, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"group": {
|
||||
"property": "openConversationId",
|
||||
"required": true
|
||||
},
|
||||
"extension": {
|
||||
"property": "extension",
|
||||
"required": false,
|
||||
"interface_type": "object"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler validates the target group and optional string-valued extension object, permits caller-authoritative dry-run, and requires explicit --yes before the irreversible upgrade.",
|
||||
"cli_path": "chat group upgrade-to-external",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"chat.shortcut_bot_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +bot-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_bot_find": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +bot-find",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_conversation_info": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +conversation-info",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_conversation_clear_all_red_point": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +conversation-clear-all-red-point",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_conversation_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +conversation-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_conversation_list_top": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +conversation-list-top",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_category_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +category-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_category_create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +category-create",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_category_delete": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +category-delete",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_category_rename": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +category-rename",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_invite_url": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-invite-url",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_dismiss": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-dismiss",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_set_history": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-set-history",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_update_nick": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-update-nick",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_update_alias": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-update-alias",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_list_mine": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-list-mine",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_list_all": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-list-all",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_list_join_requests": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-list-join-requests",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_bots": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-bots",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_set_admin": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-set-admin",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_mute": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-mute",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_role_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-role-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_chat_role_add": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-role-add",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_role_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-role-update",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_role_set_user": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-role-set-user",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_chat_role_query_user": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +chat-role-query-user",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_send_by_webhook": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-send-by-webhook",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_messages_list_direct": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-list-direct",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_list_unread_conversations": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-list-unread-conversations",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_mget": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-mget",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_query_send_status": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-query-send-status",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_read_status": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-read-status",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_messages_update_card": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-update-card",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_messages_list_pin": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +messages-list-pin",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_at_me": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +at-me",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_broadcast": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +broadcast",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_dm": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +dm",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_group_members": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +group-members",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_my_groups": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +my-groups",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_send_to_group": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +send-to-group",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.shortcut_unread_chats": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "chat +unread-chats",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,405 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"contact.department_create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps department creation flags to contact/department_create, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"name": {
|
||||
"property": "deptName",
|
||||
"required": true
|
||||
},
|
||||
"dept-name": {
|
||||
"property": "deptName",
|
||||
"required": false
|
||||
},
|
||||
"parent": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"super-dept-id": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"super-dept": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"create-dept-group": {
|
||||
"property": "createDeptGroup",
|
||||
"required": true,
|
||||
"interface_type": "boolean"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires one department-name spelling and an explicit boolean group choice, optionally parses a parent department ID, confirms, then invokes department_create.",
|
||||
"cli_path": "contact dept create",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"contact.department_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps department update flags to contact/department_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"dept": {
|
||||
"property": "deptId",
|
||||
"required": true,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"property": "deptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"ids": {
|
||||
"property": "deptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"dept-id": {
|
||||
"property": "deptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"dept-ids": {
|
||||
"property": "deptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"property": "deptName",
|
||||
"required": true
|
||||
},
|
||||
"dept-name": {
|
||||
"property": "deptName",
|
||||
"required": false
|
||||
},
|
||||
"parent": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"super-dept-id": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"super-dept": {
|
||||
"property": "superDeptId",
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires one department-ID spelling and one department-name spelling, optionally parses a parent ID, confirms, then invokes department_update.",
|
||||
"cli_path": "contact dept update",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"contact.employee_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps employee update flags to contact/employee_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"user-id": {
|
||||
"property": "userId",
|
||||
"required": true
|
||||
},
|
||||
"id": {
|
||||
"property": "userId",
|
||||
"required": false
|
||||
},
|
||||
"userid": {
|
||||
"property": "userId",
|
||||
"required": false
|
||||
},
|
||||
"org-user-name": {
|
||||
"property": "orgUserName",
|
||||
"required": false
|
||||
},
|
||||
"depts": {
|
||||
"property": "depts",
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
},
|
||||
"master-user-id": {
|
||||
"property": "masterUserId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires one employee-ID spelling plus at least one update field, decodes optional department JSON, confirms, then invokes employee_update.",
|
||||
"cli_path": "contact user update",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"contact.exclusive_account_user_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps enterprise-account update flags to contact/exclusive_account_user_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"user-id": {
|
||||
"property": "userId",
|
||||
"required": true
|
||||
},
|
||||
"id": {
|
||||
"property": "userId",
|
||||
"required": false
|
||||
},
|
||||
"userid": {
|
||||
"property": "userId",
|
||||
"required": false
|
||||
},
|
||||
"org-user-name": {
|
||||
"property": "orgUserName",
|
||||
"required": false
|
||||
},
|
||||
"depts": {
|
||||
"property": "depts",
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
},
|
||||
"master-user-id": {
|
||||
"property": "masterUserId",
|
||||
"required": false
|
||||
},
|
||||
"nick": {
|
||||
"property": "nick",
|
||||
"required": false
|
||||
},
|
||||
"avatar-file-id": {
|
||||
"property": "avatarFileId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires one enterprise-account user ID plus at least one profile or organization update field, confirms, then invokes exclusive_account_user_update.",
|
||||
"cli_path": "contact account update",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"contact.self_user_profile_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the executable CLI maps self-profile update flags to contact/self_user_profile_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"nick": {
|
||||
"property": "nick",
|
||||
"required": false
|
||||
},
|
||||
"avatar-file-id": {
|
||||
"property": "avatarFileId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler requires at least one of nickname or avatar file ID, confirms, then invokes self_user_profile_update for the current user.",
|
||||
"cli_path": "contact user update-self",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"contact.shortcut_list_followings": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +list-followings",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_search_user": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +search-user",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_search_mobile": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +search-mobile",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_list_roles": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +list-roles",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_list_role_members": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +list-role-members",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_list_sub_depts": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +list-sub-depts",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_list_dept_members": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +list-dept-members",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_by_mobile": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +by-mobile",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_dept_members": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +dept-members",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_lookup": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +lookup",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_org": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +org",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_resolve_dept": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +resolve-dept",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_team": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +team",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"contact.shortcut_me": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "contact +me",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-tool-metadata/devapp",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"devapp.shortcut_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +create",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +update",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_delete": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +delete",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_enable": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +enable",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_disable": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +disable",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_webapp_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +webapp-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_webapp_config": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +webapp-config",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_permission_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +permission-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_member_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +member-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_member_add": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +member-add",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_member_remove": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +member-remove",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"devapp.shortcut_robot_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +robot-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_event_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +event-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_version_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +version-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_version_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +version-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_version_check_approval": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +version-check-approval",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"devapp.shortcut_version_status": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "devapp +version-status",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,58 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"ding.shortcut_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "ding +list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"ding.shortcut_receiver_status": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "ding +receiver-status",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"ding.shortcut_send_personal": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "ding +send-personal",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"ding.shortcut_recall_personal": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "ding +recall-personal",
|
||||
"runtime_gate": "typed_yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,26 @@
|
||||
"reviewed": true
|
||||
},
|
||||
"doc.create_comment": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_ref": {
|
||||
"product_id": "doc-comment",
|
||||
"rpc_name": "create_comment"
|
||||
},
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"mentioned-open-conversation-id": {
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler normalizes repeated or comma-separated group conversation IDs into a stable de-duplicated array while preserving existing user mentions.",
|
||||
"cli_path": "doc comment create",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.create_document": {
|
||||
"interface_mode": "mcp",
|
||||
@@ -91,9 +104,44 @@
|
||||
"reviewed": true
|
||||
},
|
||||
"doc.get_document_content": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_ref": {
|
||||
"product_id": "doc",
|
||||
"rpc_name": "get_document_content"
|
||||
},
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"content-format": {
|
||||
"property": "format",
|
||||
"required": false
|
||||
},
|
||||
"scope": {
|
||||
"required": false
|
||||
},
|
||||
"tags": {
|
||||
"required": false,
|
||||
"required_when": "--scope=tags"
|
||||
},
|
||||
"max-depth": {
|
||||
"required": false,
|
||||
"interface_type": "integer"
|
||||
},
|
||||
"start-block-id": {
|
||||
"required": false,
|
||||
"required_when": "--scope=range or --scope=section"
|
||||
},
|
||||
"end-block-id": {
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler preserves full Markdown/JSONML reads and adds validated outline, range, section, and tag-scoped JSONML fragment reads with optional file output.",
|
||||
"cli_path": "doc read",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.get_document_info": {
|
||||
"interface_mode": "mcp",
|
||||
@@ -173,13 +221,26 @@
|
||||
"reviewed": true
|
||||
},
|
||||
"doc.reply_comment": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_ref": {
|
||||
"product_id": "doc-comment",
|
||||
"rpc_name": "reply_comment"
|
||||
},
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"mentioned-open-conversation-id": {
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed handler normalizes group conversation IDs into a stable de-duplicated array and rejects group mentions for emoji replies, which the remote operation does not support.",
|
||||
"cli_path": "doc comment reply",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.search_documents": {
|
||||
"interface_mode": "mcp",
|
||||
@@ -220,6 +281,10 @@
|
||||
"review_reason": "migrated to reviewed metadata block"
|
||||
},
|
||||
"doc.update_comment": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
@@ -227,12 +292,16 @@
|
||||
"parameters": {
|
||||
"mention": {
|
||||
"interface_type": "array"
|
||||
},
|
||||
"mentioned-open-conversation-id": {
|
||||
"property": "mentionedOpenConversationIds",
|
||||
"required": false,
|
||||
"interface_type": "array"
|
||||
}
|
||||
},
|
||||
"review_reason": "Preserve the reviewed CLI-to-interface conversion: the comma-separated --mention string is normalized to the mentionedUserIds array.",
|
||||
"review_reason": "Preserve the reviewed user-mention conversion and normalize repeated or comma-separated group conversation IDs into mentionedOpenConversationIds.",
|
||||
"cli_path": "doc comment update",
|
||||
"runtime_gate": "none",
|
||||
"confirmation": "not_required"
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.update_document": {
|
||||
"effect": "write",
|
||||
@@ -297,6 +366,227 @@
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"review_reason": "migrated to reviewed metadata block"
|
||||
},
|
||||
"doc.shortcut_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_copy": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +copy",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_move": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +move",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_comment_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +comment-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_comment_create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +comment-create",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_comment_reply": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +comment-reply",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_export_submit": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +export-submit",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_export_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +export-get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_version_save": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +version-save",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_version_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +version-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_version_revert": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +version-revert",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_template_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +template-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_template_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +template-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_doc_append": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +doc-append",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"doc.shortcut_find_doc": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +find-doc",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"doc.shortcut_share_doc": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "doc +share-doc",
|
||||
"runtime_gate": "typed_yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +213,112 @@
|
||||
"drive.upload": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||||
"reviewed": true
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed helper preserves the stable command-level Schema contract for ordinary uploads; --node switches to overwrite mode and the runtime still requires explicit confirmation unless --yes is supplied.",
|
||||
"cli_path": "drive upload",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_info": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +info",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_search_docs": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +search-docs",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_copy": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +copy",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"drive.shortcut_move": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +move",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"drive.shortcut_recent": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +recent",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"drive.shortcut_find_file": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "drive +find-file",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-tool-metadata/hrbrain",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"hrbrain.list_talent_pools": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_talent_pools, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool list",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_talent_pools MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"keyword": {
|
||||
"property": "keyword",
|
||||
"required": false
|
||||
},
|
||||
"pool-type": {
|
||||
"property": "poolType",
|
||||
"required": false
|
||||
},
|
||||
"creator": {
|
||||
"property": "creator",
|
||||
"required": false
|
||||
},
|
||||
"labels": {
|
||||
"property": "labels",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_talent_pool_detail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_talent_pool_detail, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool detail",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --pool-code maps 1:1 to the poolCode MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.list_pool_employees": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_pool_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool employees",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_pool_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": true
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_profile_metadata": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_metadata, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile metadata",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.query_profile_data": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool query_profile_data, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile query",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the query_profile_data MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
},
|
||||
"data-queries": {
|
||||
"property": "dataQueries",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_profile_label": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_label, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile labels",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the get_profile_label MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"staff-ids": {
|
||||
"property": "staffIds",
|
||||
"required": true
|
||||
},
|
||||
"all-label": {
|
||||
"property": "allLabel",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_employee_career": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_career, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile career",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_employee_performance": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_performance, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile performance",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.search_employees": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain search employees",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"keyword": {
|
||||
"property": "keyword",
|
||||
"required": false
|
||||
},
|
||||
"dept-name": {
|
||||
"property": "deptName",
|
||||
"required": false
|
||||
},
|
||||
"position-name": {
|
||||
"property": "positionName",
|
||||
"required": false
|
||||
},
|
||||
"job-level": {
|
||||
"property": "jobLevel",
|
||||
"required": false
|
||||
},
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.search_employees_structured": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees_structured, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain search employees-structured",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees_structured MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"origin-json": {
|
||||
"property": "originJson",
|
||||
"required": true
|
||||
},
|
||||
"fields": {
|
||||
"property": "fields",
|
||||
"required": true
|
||||
},
|
||||
"order-by": {
|
||||
"property": "orderByClauses",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_search_fields": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_search_fields, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,136 @@
|
||||
"cli_path": "mail template update",
|
||||
"runtime_gate": "none",
|
||||
"confirmation": "not_required"
|
||||
},
|
||||
"mail.shortcut_thread_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +thread-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_folder_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +folder-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_tag_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +tag-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_user_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +user-search",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_template_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +template-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_contact_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +contact-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_find_mail_user": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +find-mail-user",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_recent_mail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +recent-mail",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_search_mail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +search-mail",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"mail.shortcut_unread_mail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "mail +unread-mail",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-tool-metadata/markdown",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"markdown.create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed cross-product adapter: this local workflow resolves content, validates a native .md file, and uploads through either Drive or Doc space; no single MCP interface represents the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"name": {
|
||||
"property": "fileName",
|
||||
"required": false,
|
||||
"required_when": "--content is used"
|
||||
},
|
||||
"content": {
|
||||
"property": "content",
|
||||
"required": false
|
||||
},
|
||||
"file": {
|
||||
"property": "filePath",
|
||||
"required": false
|
||||
},
|
||||
"folder": {
|
||||
"property": "folderId",
|
||||
"required": false
|
||||
},
|
||||
"workspace": {
|
||||
"property": "workspaceId",
|
||||
"required": false
|
||||
},
|
||||
"space-id": {
|
||||
"property": "spaceId",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed workflow requires exactly one content source, requires an .md name for literal content, keeps workspace and Drive space mutually exclusive, and routes the upload without a confirmation gate.",
|
||||
"cli_path": "markdown create",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"markdown.fetch": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed cross-product adapter: this local workflow resolves the file domain, downloads through Drive or Doc space, and optionally writes a sanitized local output path; no single MCP interface represents the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": true
|
||||
},
|
||||
"id": {
|
||||
"property": "nodeId",
|
||||
"required": false
|
||||
},
|
||||
"space-id": {
|
||||
"property": "spaceId",
|
||||
"required": false
|
||||
},
|
||||
"workspace": {
|
||||
"property": "workspaceId",
|
||||
"required": false
|
||||
},
|
||||
"output": {
|
||||
"property": "output",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed workflow requires one node locator, keeps explicit Drive and Doc routes mutually exclusive, treats remote content as untrusted data, and protects local output paths.",
|
||||
"cli_path": "markdown fetch",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"markdown.overwrite": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed cross-product adapter: this local workflow resolves and previews existing content, then replaces a Drive or Doc-space native .md file; no single MCP interface represents the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": true
|
||||
},
|
||||
"content": {
|
||||
"property": "content",
|
||||
"required": false
|
||||
},
|
||||
"file": {
|
||||
"property": "filePath",
|
||||
"required": false
|
||||
},
|
||||
"name": {
|
||||
"property": "fileName",
|
||||
"required": false
|
||||
},
|
||||
"space-id": {
|
||||
"property": "spaceId",
|
||||
"required": false
|
||||
},
|
||||
"workspace": {
|
||||
"property": "workspaceId",
|
||||
"required": false
|
||||
},
|
||||
"dry-run": {
|
||||
"property": "dryRun",
|
||||
"required": false,
|
||||
"interface_type": "boolean"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed workflow requires a target and exactly one content source, supports a command-owned diff preview, and confirms before replacing the remote file.",
|
||||
"cli_path": "markdown overwrite",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"markdown.patch": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed cross-product adapter: this local workflow downloads a Drive or Doc-space native .md file, applies literal or RE2 replacement, and reuploads it; no single MCP interface represents the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"node": {
|
||||
"property": "nodeId",
|
||||
"required": true
|
||||
},
|
||||
"pattern": {
|
||||
"property": "pattern",
|
||||
"required": true
|
||||
},
|
||||
"content": {
|
||||
"property": "replacement",
|
||||
"required": true
|
||||
},
|
||||
"regex": {
|
||||
"property": "regex",
|
||||
"required": false,
|
||||
"interface_type": "boolean"
|
||||
},
|
||||
"space-id": {
|
||||
"property": "spaceId",
|
||||
"required": false
|
||||
},
|
||||
"workspace": {
|
||||
"property": "workspaceId",
|
||||
"required": false
|
||||
},
|
||||
"dry-run": {
|
||||
"property": "dryRun",
|
||||
"required": false,
|
||||
"interface_type": "boolean"
|
||||
}
|
||||
},
|
||||
"review_reason": "The reviewed workflow requires a target, pattern, and replacement; it blocks zero-hit and empty-result writes, supports a command-owned diff preview, and confirms before reupload.",
|
||||
"cli_path": "markdown patch",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-tool-metadata/mcp",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"mcp.url_get": {
|
||||
"effect": "read",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
"runtime_gate": "none",
|
||||
"reviewed": true,
|
||||
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,89 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"minutes.shortcut_list_mine": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +list-mine",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"minutes.shortcut_list_shared": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +list-shared",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"minutes.shortcut_list_all": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +list-all",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"minutes.shortcut_record_start": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +record-start",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"minutes.shortcut_detail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +detail",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"minutes.shortcut_replace_batch": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"pair": {
|
||||
"description": "替换规则,格式 \"原文=>替换\",可重复传多组(必填);每组原文不能为空且不能重复"
|
||||
}
|
||||
},
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate, composite interface, and the custom --pair format/uniqueness validation without inventing a direct MCP identity.",
|
||||
"cli_path": "minutes +replace-batch",
|
||||
"runtime_gate": "typed_yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,97 @@
|
||||
"availability": "available",
|
||||
"reviewed": true,
|
||||
"runtime_gate": "confirm_delete"
|
||||
},
|
||||
"oa.shortcut_list_pending": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +list-pending",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_list_forms": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +list-forms",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_search_forms": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +search-forms",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_list_executed": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +list-executed",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_list_submitted": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +list-submitted",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_list_cc": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +list-cc",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"oa.shortcut_my_initiated": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "oa +my-initiated",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,32 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"report.shortcut_inbox_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "report +inbox-list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"report.shortcut_outbox_list": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "report +outbox-list",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,6 +708,32 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"sheet.shortcut_list_sheets": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "sheet +list-sheets",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"sheet.shortcut_read": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
|
||||
"cli_path": "sheet +read",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user