Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69911543c3 | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
df088573fb | ||
|
|
a0c64e5ef4 | ||
|
|
7a140e59c3 | ||
|
|
38832448d2 | ||
|
|
3d4e43f4fc | ||
|
|
155ce984c9 | ||
|
|
4b93a1cb28 | ||
|
|
1d384b9189 | ||
|
|
025287873d | ||
|
|
6ddda6f1bf | ||
|
|
dde5049454 | ||
|
|
a3f5a83527 | ||
|
|
5d7a66d4a3 | ||
|
|
aebb75371b | ||
|
|
0a0634cfc2 | ||
|
|
46aa0fe16d | ||
|
|
78165393e0 | ||
|
|
931af6af59 | ||
|
|
f6a4e0d5ad | ||
|
|
44311d0160 | ||
|
|
fb44601f21 | ||
|
|
83c64d31dd | ||
|
|
f81d09fb95 | ||
|
|
e437cf4bbb | ||
|
|
cd1ba34d96 | ||
|
|
2cc410db6c | ||
|
|
495a3b256f | ||
|
|
4210334f55 | ||
|
|
06ec207d17 | ||
|
|
30caba5dcb | ||
|
|
76316ef5f0 | ||
|
|
f5b1c2659f | ||
|
|
b4f0053bbe | ||
|
|
3593818a46 | ||
|
|
21bbf42ca7 | ||
|
|
edf1e58141 | ||
|
|
bd94c63de8 | ||
|
|
43b1936b65 | ||
|
|
9d8806927f | ||
|
|
dbe47d58fb | ||
|
|
8c2c94e0f1 | ||
|
|
b7b78f0c16 | ||
|
|
ec7593dabb | ||
|
|
1df4cc95a6 | ||
|
|
66468c703f | ||
|
|
74ef426064 | ||
|
|
5ce391b49b | ||
|
|
4a14f4b1e3 | ||
|
|
451a6fffe7 | ||
|
|
d052c104d9 | ||
|
|
e4e653d3b3 | ||
|
|
6b85867309 | ||
|
|
fdf3e8cc3b | ||
|
|
a5902ca233 | ||
|
|
0fb332c3f3 | ||
|
|
28669ffeee | ||
|
|
fa5bc65d66 | ||
|
|
49afa82d27 | ||
|
|
3eda3b5ce6 | ||
|
|
49ab7a46f4 | ||
|
|
d500f2fe5f | ||
|
|
bcc9e27da0 | ||
|
|
cf64f2ad02 | ||
|
|
5ab46921c5 | ||
|
|
f8a031564a | ||
|
|
2989c1db37 | ||
|
|
eaee7f1c6f | ||
|
|
103b188458 | ||
|
|
156d95e6d1 | ||
|
|
b1f5c67e9c | ||
|
|
037deefe67 | ||
|
|
06ed3aeeb3 | ||
|
|
b244df1634 | ||
|
|
eb3f7328bb | ||
|
|
3c445ce73a | ||
|
|
ef73257a69 | ||
|
|
461b9b773a | ||
|
|
bab7c8879b | ||
|
|
82b17ced32 | ||
|
|
7945f44c9a | ||
|
|
63dbf98cdf | ||
|
|
8034f0c2dc | ||
|
|
5b0e44290e | ||
|
|
4bd9f75231 | ||
|
|
8f8f64c391 | ||
|
|
ae9caa06af | ||
|
|
ee0c3507a5 | ||
|
|
72a9902254 | ||
|
|
5f337e0ce5 | ||
|
|
6fdd17d3b6 | ||
|
|
5c2181a31d | ||
|
|
956819663d | ||
|
|
670ab1fd5e | ||
|
|
b299400017 | ||
|
|
3afcabc41d | ||
|
|
a43e75e8df | ||
|
|
2aad96fa7b | ||
|
|
3fe2a7f5c0 | ||
|
|
24fd2d2573 | ||
|
|
90d99d9bbe | ||
|
|
a37f614be4 | ||
|
|
08cf334cc1 | ||
|
|
12088f2d44 | ||
|
|
d9c74fbe96 | ||
|
|
1522653844 | ||
|
|
357f31376d | ||
|
|
0f178f8382 | ||
|
|
910fb4a9b1 | ||
|
|
89feea7971 | ||
|
|
24b61b1c17 | ||
|
|
edbc8275b6 | ||
|
|
27afa806ca | ||
|
|
b6101bdbc3 | ||
|
|
538f2aba6f | ||
|
|
5004ed8ae6 | ||
|
|
2359de69fa | ||
|
|
b62f6c0c02 | ||
|
|
25b5e0b9fa | ||
|
|
03bda02e04 | ||
|
|
4da1e52b08 | ||
|
|
409ee0cb84 | ||
|
|
7cf7598ef2 | ||
|
|
9b220d0ee6 | ||
|
|
d7ae59753d | ||
|
|
72b2af1d1d | ||
|
|
bd41da8caf | ||
|
|
cc0e179a8d | ||
|
|
e0f66384e2 | ||
|
|
2dd067562e |
@@ -1,14 +1,17 @@
|
||||
name: PR Eval Dispatch
|
||||
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
|
||||
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
|
||||
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
|
||||
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
|
||||
# FETCH_HEAD 校验兜底派发后的变更。
|
||||
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
|
||||
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
|
||||
# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。
|
||||
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
|
||||
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
|
||||
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
|
||||
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -31,8 +34,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
@@ -66,12 +69,10 @@ jobs:
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
@@ -91,49 +92,205 @@ jobs:
|
||||
| python3 scripts/ci/eval_dispatch_guard.py head \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Trigger internal evaluation pipeline
|
||||
env:
|
||||
EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }}
|
||||
EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then
|
||||
echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
jq -n \
|
||||
--arg pr "$PR_NUMBER" \
|
||||
--arg sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases "$CASES_REF" \
|
||||
'{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "private-token: ${EVAL_TRIGGER_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- \
|
||||
"$EVAL_TRIGGER_URL"
|
||||
echo "Internal evaluation dispatched."
|
||||
|
||||
- name: Acknowledge on PR
|
||||
- name: Create dispatch placeholder
|
||||
id: placeholder
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
| jq -er \
|
||||
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
|
||||
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
|
||||
)"
|
||||
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dispatch request manifest
|
||||
env:
|
||||
REPOSITORY_ID: '1187709537'
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
ACTOR_ID: ${{ github.event.comment.user.id }}
|
||||
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
SOURCE_BODY: ${{ github.event.comment.body }}
|
||||
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
|
||||
echo "unexpected repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for value in \
|
||||
"$REPOSITORY_ID" \
|
||||
"$WORKFLOW_ID" \
|
||||
"$RUN_ID" \
|
||||
"$RUN_ATTEMPT" \
|
||||
"$SOURCE_COMMENT_ID" \
|
||||
"$DISPATCH_COMMENT_ID" \
|
||||
"$ACTOR_ID" \
|
||||
"$PR_NUMBER"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "dispatch manifest contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "dispatch manifest contains an invalid PR head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
|
||||
source_body_sha256="${hash_output%% *}"
|
||||
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "failed to hash source comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
|
||||
|
||||
umask 077
|
||||
jq -n \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg repository "$REPOSITORY" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg source_comment_id "$SOURCE_COMMENT_ID" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg actor_id "$ACTOR_ID" \
|
||||
--arg actor_login "$ACTOR_LOGIN" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg pr_head_sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases_ref "$CASES_REF" \
|
||||
--arg source_body_sha256 "$source_body_sha256" \
|
||||
--arg idempotency_key "$idempotency_key" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
repository: $repository,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
source_comment_id: $source_comment_id,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
actor_id: $actor_id,
|
||||
actor_login: $actor_login,
|
||||
pr_number: $pr_number,
|
||||
pr_head_sha: $pr_head_sha,
|
||||
products: $products,
|
||||
cases_ref: $cases_ref,
|
||||
source_body_sha256: $source_body_sha256,
|
||||
idempotency_key: $idempotency_key
|
||||
}' > "$MANIFEST_PATH"
|
||||
|
||||
- name: Upload dispatch request manifest
|
||||
id: artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
|
||||
path: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: false
|
||||
|
||||
- name: Finalize dispatch marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
REPOSITORY_ID: '1187709537'
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
|
||||
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
|
||||
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "artifact marker contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact_digest="${ARTIFACT_DIGEST,,}"
|
||||
if [[ "$artifact_digest" != sha256:* ]]; then
|
||||
artifact_digest="sha256:${artifact_digest}"
|
||||
fi
|
||||
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "artifact marker contains an invalid digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
marker_json="$(
|
||||
jq -nc \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg artifact_id "$ARTIFACT_ID" \
|
||||
--arg artifact_digest "$artifact_digest" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
artifact_id: $artifact_id,
|
||||
artifact_digest: $artifact_digest
|
||||
}'
|
||||
)"
|
||||
cases_note=""
|
||||
if [ -n "$CASES_REF" ]; then
|
||||
cases_note=",用例版本 \`${CASES_REF}\`"
|
||||
fi
|
||||
body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
--arg comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg body "$body" \
|
||||
'((.id | tostring) == $comment_id) and (.body == $body)' \
|
||||
> /dev/null
|
||||
|
||||
- name: Mark dispatch preparation failure
|
||||
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
@@ -464,6 +464,134 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Unified result Schema and performance
|
||||
|
||||
The unified runtime envelope and the per-command Schema result declaration are
|
||||
related but distinct contracts:
|
||||
|
||||
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
|
||||
`meta`) and derives it through `internal/output`. Business commands return a
|
||||
`CommandResult`; they must not hand-author the outer JSON shape.
|
||||
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
|
||||
business value inside `data`. It may declare `outcomes`, `data_schema`, and
|
||||
`sensitive_paths`. `Contract.Pagination` is a separate command capability
|
||||
because pagination is emitted under envelope `meta`, not inside `data`.
|
||||
- `outcomes` is the set of results a command may produce; it is not the outcome
|
||||
of the current invocation. `data_schema` is a JSON Schema object for business
|
||||
data and must not duplicate the framework envelope.
|
||||
- Result declarations are delivered in the full leaf and in the reviewed
|
||||
`--compact` Agent projection. Compact retains the normalized `result` object
|
||||
verbatim but still omits provenance, interface bindings, and other audit-only
|
||||
fields. Product/group summaries remain navigation views and need not repeat
|
||||
every leaf Result. When an Agent needs return-shape facts, query the compact
|
||||
leaf directly; do not load the whole full Catalog.
|
||||
- A missing `result` means “no reviewed return-value declaration is published
|
||||
for this leaf.” It does **not** prove that the runtime is legacy, and it must
|
||||
not be filled by inference from examples, MCP samples, or previous command
|
||||
output. Runtime rollout remains an internal per-command fact.
|
||||
- The public contract has no `contract_version`, no `--output-contract`, and no
|
||||
Agent-selectable protocol alias. Agents continue to request machine output
|
||||
with `--format json`; migrated commands use the unified result directly and
|
||||
unmigrated commands retain their current legacy output.
|
||||
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
|
||||
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
|
||||
currently has representative Result coverage. Do not describe that as
|
||||
repository-wide coverage. Any newly activated Agent-visible command should
|
||||
add and test its Result declaration; the remaining pilot gaps should shrink,
|
||||
not expand.
|
||||
|
||||
The compact/full leaf `result` object has one stable shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "pending", "partial_failure", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Stable resource ID"},
|
||||
"name": {"type": "string", "description": "Display name"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensitive_paths": ["credential.secret"]
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
| Field | Required | Contract |
|
||||
|---|---|---|
|
||||
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
|
||||
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
|
||||
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
|
||||
|
||||
Optional members are omitted, never emitted as `null`. A leaf without a
|
||||
reviewed Result omits the entire `result` key. Compact must preserve the same
|
||||
normalized Result value as the full leaf; it must not summarize, infer, rename,
|
||||
or independently rebuild any Result field. Product/group summaries do not
|
||||
aggregate child Result objects.
|
||||
|
||||
`pagination` is a sibling of `result`, not a child. It declares the canonical
|
||||
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
|
||||
Product response fields used to derive that metadata remain mapper internals;
|
||||
they are not part of `result.data_schema`. Do not execute a second request to
|
||||
derive pagination metadata.
|
||||
|
||||
Invalid result declarations fail closed during normalization: unknown or
|
||||
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
|
||||
sensitive paths, unsupported pagination kinds, attempts to override framework
|
||||
meta paths, and an invalid cursor parameter must be rejected rather than
|
||||
silently removed.
|
||||
Full-leaf wire round trips must
|
||||
preserve the normalized Result exactly. Do not commit generated Schema JSON as
|
||||
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
|
||||
from declarations.
|
||||
|
||||
### Performance model and rules
|
||||
|
||||
- Catalog construction is declaration-driven and cached through the existing
|
||||
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
|
||||
command invocation, per leaf lookup, or per renderer.
|
||||
- Normalizing one Result declaration is linear in the size of that declaration.
|
||||
Full `schema --all` is linear in tools + parameters + Result schema bytes and
|
||||
is an audit/compatibility export, not the normal Agent discovery path.
|
||||
Overview → compact product/group → compact leaf remains the normal route;
|
||||
only the final leaf carries its Result declaration.
|
||||
- Constructing a `CommandResult` defensively clones result data and validates
|
||||
invariants; rendering is buffer-first and then writes once. Both CPU cost and
|
||||
transient memory are O(payload size), with roughly one additional in-memory
|
||||
rendered copy. This buys immutability and prevents partial JSON leakage, but
|
||||
it is not free.
|
||||
- Large list/search commands must use bounded pages and publish continuation
|
||||
facts. The current emitter buffers one command result/page before publishing;
|
||||
pagination is the memory bound. Continuous event streams are a separate,
|
||||
command-specific protocol and are not described by `ResultSpec`.
|
||||
- A `dual_validate` command must execute the business request exactly once,
|
||||
validate a shadow unified result, and preserve legacy bytes. Never obtain
|
||||
validation by issuing a second network or write request.
|
||||
- Filters and alternate formats are render-time work over the same in-memory
|
||||
result. They must not rerun the business operation or rebuild Schema.
|
||||
- Performance changes must preserve the one-result, buffer-first, fail-closed,
|
||||
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
|
||||
improvement. For a material hot-path change, benchmark representative small
|
||||
and page-sized payloads and report allocations/bytes as well as latency.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
@@ -479,8 +607,9 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
constraints, safety/confirmation semantics, and any reviewed `result`
|
||||
contract. Full leaf fields such as `property`, `interface_ref`, and
|
||||
provenance are audit facts. A conflict is contract drift, not permission to
|
||||
guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
@@ -6,6 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.58-beta.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
|
||||
@@ -13,6 +15,9 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
- **Personal OA approval events** — personal event consumers now support task
|
||||
creation, completion, redirection, instance start, termination, and
|
||||
completion events, with typed output and matching usage documentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -22,6 +27,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
a successful local write. The result includes the saved path and byte size;
|
||||
document exports additionally report the node, requested format, job/task
|
||||
ID, and final status.
|
||||
- **IM search and card-write safety** — conversation-scoped search now fails
|
||||
closed when the target cannot be verified, and streaming-card updates require
|
||||
business evidence rather than a transport-only success response.
|
||||
- **Document shortcut reliability** — document write, readback verification,
|
||||
pagination, template/version discovery, export, media, and local-file
|
||||
workflows now preserve compatibility while rejecting ambiguous write results.
|
||||
- **Event runtime-token handoff** — personal `event consume`, `status`,
|
||||
`stop`, and `+listen-im` honor the root `--token` without falling back to a
|
||||
stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC
|
||||
credential channel; tokens are never placed in child argv, environment,
|
||||
profiles, logs, or run-state files.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
|
||||
@@ -31,6 +48,15 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
- **Minutes skill references** — document `permission apply` in both Minutes
|
||||
skill references: list it in the command trees, describe its policy values and
|
||||
how it differs from `permission add`, and add its intent routing.
|
||||
- **Chat paging guidance** — typed chat message commands now document
|
||||
`--page-all`, aggregate result shapes, and cursor behavior in CLI Help and
|
||||
Agent selection examples.
|
||||
- **Calendar skill parity** — mono and multi Calendar references are aligned to
|
||||
prevent documentation drift without changing CLI behavior.
|
||||
- **Release engineering** — CI now shards helper-package changes through the
|
||||
full race suite, widens a flaky stdio idempotency test budget, governs exact
|
||||
reviewed CLI/Schema type migrations, and lets authorized maintainers trigger
|
||||
internal MCP evaluation with a reviewed `/eval` PR comment.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
@@ -60,6 +86,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Event runtime-token handoff** — personal `event consume`, `status`, `stop`, and `+listen-im` now honor the existing root `--token` instead of falling back to a stale local OAuth profile. Detached personal-event buses negotiate the credential only after an additive capability handshake, receive and rotate it through owner-only local IPC, and keep it in memory; the token is never forwarded through child argv, environment variables, profiles, logs, or run-state files. Existing OAuth and multi-profile behavior is unchanged when `--token` is absent. A new client refuses to send a runtime token to an older bus and leaves its existing consumers and subscriptions untouched; the recovery message asks users to inspect `event status --as user`, preview `event stop --as user --all --dry-run`, and explicitly confirm `event stop --as user --all --yes` before retrying.
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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.58-beta.2"
|
||||
version "1.0.58-beta.3"
|
||||
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.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
|
||||
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
|
||||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||||
and machine-read why it exited.
|
||||
|
||||
Scope of this branch: the five **contract** items below. Reconnect
|
||||
Scope of this branch: the six **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
@@ -159,6 +159,46 @@ marker; reconnecting an established Stream remains a separate mechanism.
|
||||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||||
operational recovery instructions.
|
||||
|
||||
### 6. Host runtime-token handoff
|
||||
|
||||
When the root command carries an explicit host-supplied `--token`, personal
|
||||
event control requests and the foreground Stream use that token with higher
|
||||
priority than local OAuth. A detached bus receives it only through the
|
||||
owner-only local IPC transport:
|
||||
|
||||
1. The child starts in runtime-token mode with non-sensitive identity and
|
||||
ticket metadata only; neither its argv nor environment contains the token.
|
||||
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
|
||||
3. The bus advertises the additive `runtime_token_v1` capability and its
|
||||
in-memory credential generation in `HelloAck`.
|
||||
4. Only after that capability is confirmed does the consumer send a bounded
|
||||
`credential_update` frame. The bus applies it with generation CAS, replies
|
||||
with `credential_update_ack`, and registers the consumer only on success.
|
||||
|
||||
The bus blocks ticket acquisition until the first runtime credential arrives.
|
||||
A later invocation may rotate Token A to Token B on a compatible existing bus;
|
||||
the current WebSocket remains connected and the next ticket request or natural
|
||||
reconnect uses B. If a 401 rejects the current runtime token, only an already
|
||||
installed newer generation is retried; the runtime path never refreshes or
|
||||
falls back to a local OAuth profile and never suggests `dws auth login`.
|
||||
|
||||
Clients do not send a token to a bus that lacks the capability, do not stop
|
||||
other consumers automatically, and fail before printing the ready marker. With
|
||||
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
|
||||
new-bus protocol behavior remains unchanged.
|
||||
|
||||
**Verification**
|
||||
- T6a: a stale local Token A and root Token B produce control and ticket
|
||||
requests authenticated only with B.
|
||||
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
|
||||
401 retries only an already-installed newer runtime token.
|
||||
- T6c: an old bus receives no credential and remains running; the new consumer
|
||||
exits before its ready marker.
|
||||
- T6d: a canary credential is absent from child argv/environment, dry-run,
|
||||
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
|
||||
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
|
||||
continue to pass.
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# 独立 `meta.pagination` Schema 方案
|
||||
|
||||
## 1. 目标结构
|
||||
|
||||
业务结果与分页控制信息分层:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {
|
||||
"items": [{"id": "a"}]
|
||||
},
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"endpoint_exhausted": false,
|
||||
"next_token": "cursor-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应 compact/full leaf Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "当前页业务记录",
|
||||
"items": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
|
||||
|
||||
## 2. 分页状态
|
||||
|
||||
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|
||||
|---|---:|---|---|
|
||||
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
|
||||
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
|
||||
|
||||
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
|
||||
健康、数据全量覆盖或业务对象不存在。
|
||||
|
||||
## 3. 映射规则
|
||||
|
||||
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
|
||||
等字段,但统一 CLI 输出只公布 `meta.pagination`:
|
||||
|
||||
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
|
||||
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
|
||||
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
|
||||
`pagination_inconsistent`,禁止伪装终页。
|
||||
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
|
||||
|
||||
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
|
||||
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
|
||||
|
||||
## 4. Schema 规则
|
||||
|
||||
- `kind` 当前只允许 `cursor`。
|
||||
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
|
||||
同一 leaf 的 `parameters`。
|
||||
- 三个 meta path 由框架固定生成,产品不能覆盖。
|
||||
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
|
||||
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
|
||||
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
|
||||
|
||||
## 5. 渐进接入
|
||||
|
||||
1. **legacy_only**:保持原输出,不公布分页声明。
|
||||
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
|
||||
legacy 字节不变。
|
||||
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
|
||||
`pagination` 声明。
|
||||
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
|
||||
|
||||
不增加 `contract_version`、`--output-contract` 或分页协议别名。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
每个分页命令至少验证:
|
||||
|
||||
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
|
||||
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
|
||||
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
|
||||
4. `cursor_parameter` 在 Help/Schema 中真实存在。
|
||||
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
|
||||
6. `data_schema` 不包含分页控制字段。
|
||||
7. 运行时 `data` 不包含迁移后的分页控制字段。
|
||||
8. dual validate 与 active 都只消费一次上游响应。
|
||||
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
|
||||
|
||||
### DevApp 首批落地
|
||||
|
||||
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
|
||||
`meta.pagination` 返回分页控制信息:
|
||||
|
||||
- `dev app list`
|
||||
- `dev app permission list`
|
||||
- `dev app event list`
|
||||
- `dev app version list`
|
||||
- `devapp +list`
|
||||
- `devapp +permission-list`
|
||||
- `devapp +event-list`
|
||||
- `devapp +version-list`
|
||||
|
||||
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
|
||||
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
|
||||
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
|
||||
`hasMore/nextCursor`。
|
||||
|
||||
## 7. 对齐依据
|
||||
|
||||
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
|
||||
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
|
||||
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
|
||||
@@ -1,188 +0,0 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"generated_at": "2026-08-10T17:25:46.245552",
|
||||
"count": 378,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2162,7 +2162,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -3004,46 +3004,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+action-items",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+apply-permission",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+detail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+export-pack",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-all",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-mine",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-shared",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+mindmap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+prepare-asr",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-pause",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-resume",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-start",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-stop",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-wrap-up",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+replace-batch",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-insights",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-replace",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+summary",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+transcript",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+unshare",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload-and-analyze",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# DWS 统一命令框架设计概要
|
||||
|
||||
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
|
||||
|
||||
## 1. 产品裁决
|
||||
|
||||
1. 不公开 `--output-contract`,也不增加任何等价别名。
|
||||
2. Agent 继续只使用既有 `--format json`。
|
||||
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
|
||||
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
|
||||
5. 回滚是命令声明与发布行为,不改变消费者 argv。
|
||||
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
|
||||
|
||||
## 2. 渐进迁移
|
||||
|
||||
内部状态机:
|
||||
|
||||
```text
|
||||
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
|
||||
```
|
||||
|
||||
- `legacy_only`:只构造、输出 legacy。
|
||||
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
|
||||
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
|
||||
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
|
||||
- `unified_only`:清理仅服务 legacy 的产品 renderer。
|
||||
|
||||
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
|
||||
|
||||
## 3. 统一结果
|
||||
|
||||
统一命令框架表达四类结果:
|
||||
|
||||
```text
|
||||
success 请求完成且命令认为操作已完成
|
||||
pending 请求被受理,但异步操作尚未终结
|
||||
partial_failure 批量操作有成功项,也有失败或未知项
|
||||
failure 请求或操作失败
|
||||
```
|
||||
|
||||
JSON 基本形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
硬不变量:
|
||||
|
||||
```text
|
||||
ok == (outcome in {success, pending})
|
||||
process rc == 0 <=> ok == true
|
||||
top-level error present <=> outcome == failure
|
||||
one invocation emits exactly one primary result
|
||||
```
|
||||
|
||||
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
|
||||
|
||||
## 4. 输出与错误纪律
|
||||
|
||||
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
|
||||
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
|
||||
自己声明专用流协议。
|
||||
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
|
||||
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
|
||||
混入分页控制字段。
|
||||
- 日志不得污染 stdout。
|
||||
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
|
||||
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
|
||||
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
|
||||
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
|
||||
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
|
||||
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
|
||||
|
||||
## 5. 重试与超时边界
|
||||
|
||||
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
|
||||
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
|
||||
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
|
||||
|
||||
## 6. 集成范围
|
||||
|
||||
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
|
||||
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
|
||||
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
|
||||
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
|
||||
|
||||
## 7. 对齐原则
|
||||
|
||||
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
|
||||
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
|
||||
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
|
||||
|
||||
## 8. 发布门禁
|
||||
|
||||
命令晋级 `unified_active` 前至少满足:
|
||||
|
||||
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
|
||||
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
|
||||
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
|
||||
4. Help、Schema 和全仓示例不存在协议选择参数。
|
||||
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
|
||||
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
|
||||
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
|
||||
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
@@ -74,7 +75,7 @@ var (
|
||||
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/event.json
|
||||
// products.event). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
@@ -99,12 +100,12 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListenIMCommand(globalFlags...),
|
||||
newEventConsumeCommand(globalFlags...),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
newEventStatusCommand(),
|
||||
newEventStopCommand(),
|
||||
newEventStatusCommandWithFlags(globalFlags...),
|
||||
newEventStopCommandWithFlags(globalFlags...),
|
||||
newEventBusCommand(),
|
||||
)
|
||||
return cmd
|
||||
@@ -114,7 +115,7 @@ func newEventCommand() *cobra.Command {
|
||||
// event consume
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newEventConsumeCommand() *cobra.Command {
|
||||
func newEventConsumeCommand(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
eventTypes []string
|
||||
filter string
|
||||
@@ -170,6 +171,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
personalOpts.EventKeys = dedupePersonalEventKeys(args)
|
||||
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
|
||||
personalOpts.Flatten = flatten
|
||||
@@ -563,6 +566,8 @@ func newEventBusCommand() *cobra.Command {
|
||||
clientIDOverride string
|
||||
idleTimeout time.Duration
|
||||
sourceKindRaw string
|
||||
runtimeTokenMode bool
|
||||
identityHashFlag string
|
||||
streamOpts eventStreamTicketOptions
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
@@ -599,23 +604,45 @@ func newEventBusCommand() *cobra.Command {
|
||||
sourceKind = dwsevent.SourceKindAppStream
|
||||
}
|
||||
if sourceKind == dwsevent.SourceKindPersonalStream {
|
||||
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
var (
|
||||
identity personal.Identity
|
||||
identityHash string
|
||||
)
|
||||
if runtimeTokenMode {
|
||||
identityHash = strings.TrimSpace(identityHashFlag)
|
||||
if !validPersonalIdentityHash(identityHash) {
|
||||
return failEarly(errors.New("event _bus: --identity-hash must be a 16-character hexadecimal identity hash in runtime token mode"))
|
||||
}
|
||||
if strings.TrimSpace(clientIDOverride) == "" {
|
||||
return failEarly(errors.New("event _bus: --client-id is required in runtime token mode"))
|
||||
}
|
||||
identity = personal.Identity{
|
||||
ClientID: strings.TrimSpace(clientIDOverride),
|
||||
SourceID: personalEventStreamSourceID(streamOpts.SourceID),
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
identity, err = eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
}
|
||||
if clientIDOverride != "" {
|
||||
identity.ClientID = clientIDOverride
|
||||
}
|
||||
identityHash = dwsevent.IdentityHash(identity.Key())
|
||||
}
|
||||
if clientIDOverride != "" {
|
||||
identity.ClientID = clientIDOverride
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
credentialBroker := newPersonalCredentialBroker(configDir, runtimeTokenMode, runtimeTokenMode)
|
||||
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: streamOpts.Mode,
|
||||
TicketURL: streamOpts.TicketURL,
|
||||
ClientIDOverride: clientIDOverride,
|
||||
CredentialBroker: credentialBroker,
|
||||
RuntimeTokenMode: runtimeTokenMode,
|
||||
})
|
||||
if err != nil {
|
||||
return failEarly(err)
|
||||
@@ -628,17 +655,18 @@ func newEventBusCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
IdleTimeout: idleTimeout,
|
||||
ReadyPipe: readyPipe,
|
||||
Logger: slog.Default(),
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
IdleTimeout: idleTimeout,
|
||||
ReadyPipe: readyPipe,
|
||||
Logger: slog.Default(),
|
||||
CredentialBroker: credentialBroker,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return eventBusRun(ctx, busCfg)
|
||||
@@ -698,12 +726,18 @@ func newEventBusCommand() *cobra.Command {
|
||||
"exit after this long with zero consumers (0 = disabled)")
|
||||
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
|
||||
"event source kind: app_stream|personal_stream")
|
||||
cmd.Flags().BoolVar(&runtimeTokenMode, "runtime-token-mode", false,
|
||||
"use an owner-injected in-memory runtime credential")
|
||||
cmd.Flags().StringVar(&identityHashFlag, "identity-hash", "",
|
||||
"pre-resolved non-sensitive personal identity hash")
|
||||
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
|
||||
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
|
||||
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"用户 Stream sourceId;personal_stream 开源版默认 open")
|
||||
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
|
||||
_ = cmd.Flags().MarkHidden("runtime-token-mode")
|
||||
_ = cmd.Flags().MarkHidden("identity-hash")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -822,6 +856,10 @@ func newEventListCommand() *cobra.Command {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newEventStatusCommand() *cobra.Command {
|
||||
return newEventStatusCommandWithFlags()
|
||||
}
|
||||
|
||||
func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
all bool
|
||||
allEditions bool
|
||||
@@ -847,6 +885,8 @@ func newEventStatusCommand() *cobra.Command {
|
||||
return fmt.Errorf("event status: %w", err)
|
||||
}
|
||||
personalOpts.Format = formatRaw
|
||||
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
return eventRunPersonalStatus(c, personalOpts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
@@ -1138,6 +1178,10 @@ func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
|
||||
}
|
||||
|
||||
func newEventStopCommand() *cobra.Command {
|
||||
return newEventStopCommandWithFlags()
|
||||
}
|
||||
|
||||
func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var asIdentity string
|
||||
var opts personalStopOptions
|
||||
cmd := &cobra.Command{
|
||||
@@ -1158,6 +1202,8 @@ func newEventStopCommand() *cobra.Command {
|
||||
}
|
||||
if as == "user" {
|
||||
opts.SubscribeID = firstArg(args)
|
||||
opts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
opts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
@@ -1261,6 +1307,20 @@ func eventStopDryRun(cmd *cobra.Command) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func eventExplicitToken(globalFlags []*GlobalFlags) string {
|
||||
if len(globalFlags) == 0 || globalFlags[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(globalFlags[0].Token)
|
||||
}
|
||||
|
||||
func eventExplicitClientID(globalFlags []*GlobalFlags) string {
|
||||
if len(globalFlags) == 0 || globalFlags[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(globalFlags[0].ClientID)
|
||||
}
|
||||
|
||||
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
|
||||
payload := map[string]any{
|
||||
"dry_run": true,
|
||||
|
||||
@@ -65,7 +65,7 @@ func (eventTargetReader) CallMCPData(product, tool string, params map[string]any
|
||||
|
||||
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
|
||||
|
||||
func newEventListenIMCommand() *cobra.Command {
|
||||
func newEventListenIMCommand(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var opts listenIMOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "+listen-im",
|
||||
@@ -91,6 +91,8 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
StreamTicketMode: opts.StreamTicketMode,
|
||||
StreamTicketURL: opts.StreamTicketURL,
|
||||
StreamSourceID: opts.StreamSourceID,
|
||||
ExplicitToken: eventExplicitToken(globalFlags),
|
||||
ClientIDOverride: eventExplicitClientID(globalFlags),
|
||||
Common: commonConsumeOptions{
|
||||
FormatRaw: "ndjson",
|
||||
MaxEvents: opts.MaxEvents,
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -184,6 +185,25 @@ func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseRuntimeTokenFailure releases the in-flight claim without recording a
|
||||
// cross-invocation hold. A host may supply a fresh token on the very next
|
||||
// command, which must be allowed to retry immediately.
|
||||
func (r *personalSubscriptionAttemptReservation) releaseRuntimeTokenFailure() error {
|
||||
if r == nil {
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
runtimecred.ErrRuntimeTokenRejected,
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
))
|
||||
}
|
||||
if err := r.store.Release(r.claim); err != nil {
|
||||
return personalSubscriptionGuardError(errors.Join(runtimecred.ErrRuntimeTokenRejected, err))
|
||||
}
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeFailure(
|
||||
ctx context.Context,
|
||||
failedIndex int,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -40,6 +41,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -82,6 +84,8 @@ type personalConsumeOptions struct {
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalListOptions struct {
|
||||
@@ -92,19 +96,23 @@ type personalListOptions struct {
|
||||
}
|
||||
|
||||
type personalStatusOptions struct {
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalStopOptions struct {
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalStreamSourceOptions struct {
|
||||
@@ -113,6 +121,8 @@ type personalStreamSourceOptions struct {
|
||||
TicketMode string
|
||||
TicketURL string
|
||||
ClientIDOverride string
|
||||
CredentialBroker *runtimecred.Broker
|
||||
RuntimeTokenMode bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -142,10 +152,19 @@ var (
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
|
||||
personalLoadTokenData = authpkg.LoadTokenData
|
||||
personalLoadProfiles = authpkg.LoadProfiles
|
||||
personalClientID = authpkg.ClientID
|
||||
personalRuntimeEventClientID = runtimePersonalEventClientID
|
||||
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
|
||||
)
|
||||
|
||||
func runtimePersonalEventClientID() string {
|
||||
if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" {
|
||||
return clientID
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID"))
|
||||
}
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
@@ -283,7 +302,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -291,13 +310,24 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
spawnProfileSelector := ""
|
||||
if strings.TrimSpace(opts.ExplicitToken) == "" {
|
||||
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
|
||||
}
|
||||
spawnArgs := personalBusSpawnArgsForToken(
|
||||
identity,
|
||||
identityHash,
|
||||
opts.StreamTicketMode,
|
||||
opts.StreamTicketURL,
|
||||
spawnProfileSelector,
|
||||
opts.ExplicitToken,
|
||||
)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
@@ -314,7 +344,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -341,7 +371,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
SpawnExtraArgs: spawnArgs,
|
||||
RuntimeToken: strings.TrimSpace(opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -370,13 +401,25 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundSource *source.PersonalSource
|
||||
var (
|
||||
foregroundSource *source.PersonalSource
|
||||
foregroundBroker *runtimecred.Broker
|
||||
)
|
||||
if opts.Common.Foreground {
|
||||
explicitToken := strings.TrimSpace(opts.ExplicitToken)
|
||||
foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false)
|
||||
if explicitToken != "" {
|
||||
if _, err := foregroundBroker.Update(0, explicitToken); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
CredentialBroker: foregroundBroker,
|
||||
RuntimeTokenMode: explicitToken != "",
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
@@ -398,6 +441,10 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) {
|
||||
err = attempt.releaseRuntimeTokenFailure()
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
err = attempt.completeFailure(ctx, 0, 0, err, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -476,14 +523,15 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: foregroundSource,
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: foregroundSource,
|
||||
CredentialBroker: foregroundBroker,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return personalBusRun(ctx, busCfg)
|
||||
@@ -517,7 +565,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
|
||||
ctx := c.Context()
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -525,7 +573,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
spawnProfileSelector := ""
|
||||
if strings.TrimSpace(opts.ExplicitToken) == "" {
|
||||
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
|
||||
}
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
@@ -534,7 +585,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -560,8 +611,9 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
|
||||
return nil
|
||||
}
|
||||
baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken)
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
attempt, err := reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
@@ -598,6 +650,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) {
|
||||
cleanup(cleanupCtx)
|
||||
return attempt.releaseRuntimeTokenFailure()
|
||||
}
|
||||
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
|
||||
// Persist the hold (or release a canceled claim) before any potentially
|
||||
// slow remote rollback. Otherwise the attempt lease can expire while
|
||||
@@ -985,7 +1041,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
@@ -1017,7 +1073,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
@@ -1038,6 +1094,15 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalRuntimeTokenControlRejection(err error) bool {
|
||||
var apiErr *personal.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr == nil {
|
||||
return false
|
||||
}
|
||||
return apiErr.HTTPStatus == http.StatusUnauthorized ||
|
||||
strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED")
|
||||
}
|
||||
|
||||
func ensurePublicPersonalEvent(eventKey string) error {
|
||||
eventKey = strings.TrimSpace(eventKey)
|
||||
if eventKey == "" {
|
||||
@@ -1120,7 +1185,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
@@ -1132,7 +1197,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
@@ -1248,6 +1313,138 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
|
||||
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
|
||||
explicitToken = strings.TrimSpace(explicitToken)
|
||||
if explicitToken == "" {
|
||||
return personalResolveEventIdentity(ctx, configDir, sourceIDOverride)
|
||||
}
|
||||
clientIDOverride := ""
|
||||
if len(clientIDOverrides) > 0 {
|
||||
clientIDOverride = strings.TrimSpace(clientIDOverrides[0])
|
||||
}
|
||||
return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride)
|
||||
}
|
||||
|
||||
// resolvePersonalEventIdentityWithToken resolves only non-sensitive identity
|
||||
// metadata around a caller-supplied bearer token. It intentionally does not
|
||||
// call LoadTokenData or any refresh-capable token resolver: an explicit root
|
||||
// --token must never be replaced with, persisted into, or used to refresh a
|
||||
// local OAuth profile.
|
||||
func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
|
||||
explicitToken = strings.TrimSpace(explicitToken)
|
||||
if explicitToken == "" {
|
||||
return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride)
|
||||
}
|
||||
if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") {
|
||||
return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile")
|
||||
}
|
||||
|
||||
corpID := resolveRuntimeDefault(ctx, "$corpId")
|
||||
userID := resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
clientID := ""
|
||||
if len(clientIDOverrides) > 0 {
|
||||
clientID = strings.TrimSpace(clientIDOverrides[0])
|
||||
}
|
||||
if clientID == "" {
|
||||
// An edition hook or explicit environment value is runtime identity,
|
||||
// not persisted app state. Resolve it before profiles.json so a complete
|
||||
// host context never depends on local OAuth metadata health.
|
||||
clientID = strings.TrimSpace(personalRuntimeEventClientID())
|
||||
}
|
||||
explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != ""
|
||||
if explicitProfile || corpID == "" || userID == "" || clientID == "" {
|
||||
profile, err := personalEventProfileMetadata(configDir)
|
||||
if err != nil {
|
||||
// A user-selected --profile remains a strict contract. Without an
|
||||
// explicit selector, profiles.json is optional metadata for a
|
||||
// host-managed bearer: malformed or stale persisted state must not
|
||||
// override complete runtime defaults or prevent the later global
|
||||
// client-id fallback.
|
||||
if explicitProfile {
|
||||
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
|
||||
}
|
||||
profile = nil
|
||||
}
|
||||
if profile != nil {
|
||||
if corpID == "" {
|
||||
corpID = strings.TrimSpace(profile.CorpID)
|
||||
}
|
||||
if userID == "" {
|
||||
userID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = strings.TrimSpace(profile.ClientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
// Persisted/global app credentials are only a fallback after the
|
||||
// selected profile, so an old app config cannot override profile.ClientID.
|
||||
clientID = strings.TrimSpace(personalClientID())
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil {
|
||||
clientID = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
|
||||
}
|
||||
|
||||
sourceID := strings.TrimSpace(sourceIDOverride)
|
||||
if sourceID == "" {
|
||||
sourceID = personalEventStreamSourceID("")
|
||||
}
|
||||
localSubject := ""
|
||||
if corpID == "" || userID == "" {
|
||||
localSubject = personalTokenSubject("access", explicitToken)
|
||||
}
|
||||
return personal.Identity{
|
||||
LocalSubject: localSubject,
|
||||
CorpID: corpID,
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
SourceID: sourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) {
|
||||
cfg, err := personalLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
explicitSelector := selector != ""
|
||||
if strings.Contains(selector, ",") {
|
||||
return nil, fmt.Errorf("personal events require exactly one --profile")
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
if explicitSelector {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
}
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{}))
|
||||
if err != nil && !explicitSelector {
|
||||
// A stale persisted CurrentProfile must not make a host-provided bearer
|
||||
// unusable. Runtime defaults and the one-way local subject are sufficient
|
||||
// to isolate the event bus without consulting local OAuth credentials.
|
||||
return nil, nil
|
||||
}
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) {
|
||||
_ = visited // retained for the focused compatibility seam used by app tests.
|
||||
return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector))
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
@@ -1302,7 +1499,11 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client {
|
||||
explicitToken := ""
|
||||
if len(explicitTokens) > 0 {
|
||||
explicitToken = strings.TrimSpace(explicitTokens[0])
|
||||
}
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
version := strings.TrimSpace(RawVersion())
|
||||
@@ -1311,12 +1512,146 @@ func newPersonalEventControlClient(configDir, baseURL string, identity personal.
|
||||
}
|
||||
client.ClientVersion = version
|
||||
client.UserAgent = "dws-cli/" + version
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if explicitToken != "" {
|
||||
client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil }
|
||||
client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken}
|
||||
client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy
|
||||
} else {
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying
|
||||
// DWS's custom x-user-access-token header to another authority. Returning
|
||||
// ErrUseLastResponse keeps the 3xx response available to the caller without a
|
||||
// url.Error that could echo an attacker-controlled Location value.
|
||||
func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
origin := via[0].URL
|
||||
if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}`
|
||||
|
||||
// runtimeTokenControlTransport scrubs an explicit bearer from every response
|
||||
// body and diagnostic header before the control client decodes or logs it. A
|
||||
// 401 is replaced with a fixed rejection envelope so untrusted response text
|
||||
// can never escape through stderr or debug logs.
|
||||
type runtimeTokenControlTransport struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
resp, err := base.RoundTrip(req)
|
||||
if err != nil {
|
||||
if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) {
|
||||
return nil, errors.New("personal event: runtime-token control request failed")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return resp, err
|
||||
}
|
||||
token := strings.TrimSpace(t.token)
|
||||
for key, values := range resp.Header {
|
||||
for i := range values {
|
||||
if token != "" {
|
||||
values[i] = strings.ReplaceAll(values[i], token, "<redacted-runtime-token>")
|
||||
}
|
||||
}
|
||||
resp.Header[key] = values
|
||||
}
|
||||
var responseBody []byte
|
||||
if resp.Body != nil {
|
||||
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, errors.New("personal event: read runtime-token control response")
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
responseBody = []byte(runtimeTokenControlErrorBody)
|
||||
} else if token != "" {
|
||||
responseBody = redactRuntimeTokenResponseBody(responseBody, token)
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
|
||||
resp.ContentLength = int64(len(responseBody))
|
||||
if resp.Header == nil {
|
||||
resp.Header = make(http.Header)
|
||||
}
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody)))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func redactRuntimeTokenResponseBody(data []byte, token string) []byte {
|
||||
token = strings.TrimSpace(token)
|
||||
if len(data) == 0 || token == "" {
|
||||
return data
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
var decoded any
|
||||
if err := decoder.Decode(&decoded); err == nil {
|
||||
var trailing any
|
||||
if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) {
|
||||
if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed {
|
||||
if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil {
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes.ReplaceAll(data, []byte(token), []byte("<redacted-runtime-token>"))
|
||||
}
|
||||
|
||||
func redactRuntimeTokenJSONValue(value any, token string) (any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
redacted := strings.ReplaceAll(typed, token, "<redacted-runtime-token>")
|
||||
return redacted, redacted != typed
|
||||
case []any:
|
||||
changed := false
|
||||
for i := range typed {
|
||||
var itemChanged bool
|
||||
typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token)
|
||||
changed = changed || itemChanged
|
||||
}
|
||||
return typed, changed
|
||||
case map[string]any:
|
||||
changed := false
|
||||
redactedMap := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
redactedKey := strings.ReplaceAll(key, token, "<redacted-runtime-token>")
|
||||
redacted, itemChanged := redactRuntimeTokenJSONValue(item, token)
|
||||
redactedMap[redactedKey] = redacted
|
||||
changed = changed || itemChanged || redactedKey != key
|
||||
}
|
||||
if !changed {
|
||||
return typed, false
|
||||
}
|
||||
return redactedMap, true
|
||||
default:
|
||||
return value, false
|
||||
}
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
@@ -1326,6 +1661,15 @@ func personalTokenSubject(kind, token string) string {
|
||||
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validPersonalIdentityHash(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != 16 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func resolveRuntimeDefault(ctx context.Context, key string) string {
|
||||
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
|
||||
if fn := fnMap()[key]; fn != nil {
|
||||
@@ -1363,20 +1707,42 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
}
|
||||
clientSecret = secret
|
||||
}
|
||||
credentialBroker := opts.CredentialBroker
|
||||
if credentialBroker == nil {
|
||||
credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if opts.RuntimeTokenMode {
|
||||
httpClient.CheckRedirect = runtimeTokenRedirectPolicy
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
|
||||
return credentialBroker.Resolve(ctx)
|
||||
},
|
||||
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
|
||||
return credentialBroker.RefreshRejected(ctx, rejectedToken)
|
||||
},
|
||||
ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
}
|
||||
|
||||
func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker {
|
||||
return runtimecred.New(runtimecred.Config{
|
||||
RequireSeed: requireSeed,
|
||||
RequireActivation: requireActivation,
|
||||
LocalResolve: func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
},
|
||||
LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken)
|
||||
},
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1441,6 +1807,25 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
return args
|
||||
}
|
||||
|
||||
func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string {
|
||||
if strings.TrimSpace(explicitToken) == "" {
|
||||
return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector)
|
||||
}
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--runtime-token-mode",
|
||||
"--identity-hash", strings.TrimSpace(identityHash),
|
||||
"--stream-source-id", strings.TrimSpace(identity.SourceID),
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode))
|
||||
}
|
||||
if strings.TrimSpace(ticketURL) != "" {
|
||||
args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func personalEventTypes(eventKey string, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
return explicit
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// 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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeTokenDetachedChildEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_CHILD"
|
||||
runtimeTokenDetachedWorkDirEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_WORKDIR"
|
||||
runtimeTokenDetachedEndpointEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_ENDPOINT"
|
||||
runtimeTokenDetachedEvidenceEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_EVIDENCE"
|
||||
runtimeTokenDetachedCanaryA = "dws-runtime-e2e-A-9f34c8d10b7e"
|
||||
runtimeTokenDetachedCanaryB = "dws-runtime-e2e-B-2ad761e5c490"
|
||||
runtimeTokenDetachedClientID = "runtime-e2e-client"
|
||||
runtimeTokenDetachedIdentityHash = "90abcdef12345678"
|
||||
runtimeTokenDetachedSourceID = "runtime-e2e-source"
|
||||
)
|
||||
|
||||
// runRuntimeTokenDetachedE2EChild is called at the very start of TestMain.
|
||||
// busctl.Spawn executes this test binary with production-style `event _bus`
|
||||
// arguments; the env marker lets the child run a real bus daemon before the Go
|
||||
// test runner attempts to parse those CLI arguments.
|
||||
func runRuntimeTokenDetachedE2EChild() (int, bool) {
|
||||
if os.Getenv(runtimeTokenDetachedChildEnv) != "1" {
|
||||
return 0, false
|
||||
}
|
||||
workDir := strings.TrimSpace(os.Getenv(runtimeTokenDetachedWorkDirEnv))
|
||||
endpoint := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEndpointEnv))
|
||||
evidence := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEvidenceEnv))
|
||||
if workDir == "" || endpoint == "" || evidence == "" {
|
||||
return 91, true
|
||||
}
|
||||
|
||||
argvClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Args, "\x00"))
|
||||
envClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Environ(), "\x00"))
|
||||
if err := appendRuntimeTokenDetachedEvidence(evidence,
|
||||
fmt.Sprintf("child_start argv_clean=%t env_clean=%t", argvClean, envClean)); err != nil {
|
||||
return 92, true
|
||||
}
|
||||
if !argvClean || !envClean {
|
||||
return 93, true
|
||||
}
|
||||
|
||||
logFile, err := os.OpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return 94, true
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
err = bus.Run(ctx, bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: runtimeTokenDetachedIdentityHash,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
Edition: "open",
|
||||
SDKVersion: "runtime-e2e",
|
||||
Source: &runtimeTokenDetachedSource{broker: broker, evidence: evidence},
|
||||
CredentialBroker: broker,
|
||||
ReadyPipe: busctl.ReadyFDFromEnv(),
|
||||
Logger: slog.New(slog.NewTextHandler(logFile, nil)),
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=false")
|
||||
return 95, true
|
||||
}
|
||||
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=true")
|
||||
return 0, true
|
||||
}
|
||||
|
||||
type runtimeTokenDetachedSource struct {
|
||||
broker *runtimecred.Broker
|
||||
evidence string
|
||||
}
|
||||
|
||||
// Start models the credential-sensitive part of a reconnecting Stream source
|
||||
// without network access. It resolves A for the first connection, waits until a
|
||||
// second consumer rotates the broker to B, then exercises the exact 401 path:
|
||||
// RefreshRejected(A) must return B and must not fall back to local OAuth.
|
||||
func (s *runtimeTokenDetachedSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
|
||||
first, err := s.broker.Resolve(ctx)
|
||||
if err != nil {
|
||||
return errors.New("runtime e2e: initial credential unavailable")
|
||||
}
|
||||
if first != runtimeTokenDetachedCanaryA || s.broker.Generation() != 1 {
|
||||
return errors.New("runtime e2e: initial credential mismatch")
|
||||
}
|
||||
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "resolved_a=true generation=1"); err != nil {
|
||||
return errors.New("runtime e2e: record initial connection")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for s.broker.Generation() < 2 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
rotated, err := s.broker.RefreshRejected(ctx, first)
|
||||
if err != nil || rotated != runtimeTokenDetachedCanaryB {
|
||||
return errors.New("runtime e2e: rotated credential unavailable")
|
||||
}
|
||||
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "rejected_a=true resolved_b=true reconnect=true generation=2"); err != nil {
|
||||
return errors.New("runtime e2e: record reconnect")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func appendRuntimeTokenDetachedEvidence(path, line string) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintln(f, line)
|
||||
return err
|
||||
}
|
||||
|
||||
func runtimeTokenDetachedContainsCanary(value string) bool {
|
||||
return strings.Contains(value, runtimeTokenDetachedCanaryA) ||
|
||||
strings.Contains(value, runtimeTokenDetachedCanaryB)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnixDetachedRuntimeTokenLifecycleAndCanaryLeakScan(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("real detached-process lifecycle is Unix-only; Windows named-pipe code is cross-compiled separately")
|
||||
}
|
||||
|
||||
root, err := os.MkdirTemp("/tmp", "dws-runtime-token-e2e-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
workDir := filepath.Join(root, "events", "open", string(dwsevent.SourceKindPersonalStream), runtimeTokenDetachedIdentityHash)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindPersonalStream, runtimeTokenDetachedIdentityHash)
|
||||
evidencePath := filepath.Join(workDir, "runtime-e2e.evidence")
|
||||
|
||||
identity := personal.Identity{
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
CorpID: "runtime-e2e-corp",
|
||||
UserID: "runtime-e2e-user",
|
||||
}
|
||||
spawnArgs := personalBusSpawnArgsForToken(identity, runtimeTokenDetachedIdentityHash, "", "", "corp:user", runtimeTokenDetachedCanaryA)
|
||||
assertRuntimeTokenDetachedClean(t, "spawn argv", []byte(strings.Join(spawnArgs, "\x00")))
|
||||
|
||||
childEnv := append([]string{}, os.Environ()...)
|
||||
childEnv = append(childEnv,
|
||||
runtimeTokenDetachedChildEnv+"=1",
|
||||
runtimeTokenDetachedWorkDirEnv+"="+workDir,
|
||||
runtimeTokenDetachedEndpointEnv+"="+endpoint,
|
||||
runtimeTokenDetachedEvidenceEnv+"="+evidencePath,
|
||||
)
|
||||
assertRuntimeTokenDetachedClean(t, "spawn environment", []byte(strings.Join(childEnv, "\x00")))
|
||||
|
||||
pid, err := busctl.Spawn(busctl.SpawnConfig{
|
||||
ExecPath: os.Args[0],
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
ExtraArgs: spawnArgs,
|
||||
Env: childEnv,
|
||||
})
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "spawn detached runtime bus", err)
|
||||
}
|
||||
stopped := false
|
||||
t.Cleanup(func() {
|
||||
if !stopped {
|
||||
_ = busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 2 * time.Second})
|
||||
if proc, findErr := os.FindProcess(pid); findErr == nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "child_start argv_clean=true env_clean=true", 3*time.Second)
|
||||
|
||||
var stdoutA, stderrA bytes.Buffer
|
||||
err = consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
|
||||
workDir, endpoint, "sub-runtime-a", runtimeTokenDetachedCanaryA, 500*time.Millisecond, &stdoutA, &stderrA,
|
||||
))
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "consume token A", err)
|
||||
}
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "resolved_a=true generation=1", 3*time.Second)
|
||||
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: "sub-runtime-b",
|
||||
EventKey: personal.EventMention,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
IdentityHash: runtimeTokenDetachedIdentityHash,
|
||||
}); err != nil {
|
||||
failRuntimeTokenDetachedError(t, "persist non-sensitive run state", err)
|
||||
}
|
||||
|
||||
var stdoutB, stderrB bytes.Buffer
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
|
||||
workDir, endpoint, "sub-runtime-b", runtimeTokenDetachedCanaryB, 5*time.Second, &stdoutB, &stderrB,
|
||||
))
|
||||
}()
|
||||
|
||||
status := waitRuntimeTokenDetachedStatus(t, endpoint, "sub-runtime-b", 3*time.Second)
|
||||
if status.Bus.PID != pid || status.Bus.IdentityHash != runtimeTokenDetachedIdentityHash {
|
||||
t.Fatalf("status bus identity = %#v, want pid=%d identity=%s", status.Bus, pid, runtimeTokenDetachedIdentityHash)
|
||||
}
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "rejected_a=true resolved_b=true reconnect=true generation=2", 3*time.Second)
|
||||
|
||||
stopResp, err := busctl.StopConsumers(endpoint, []string{"sub-runtime-b"})
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "targeted consumer stop", err)
|
||||
}
|
||||
if len(stopResp.Stopped) != 1 || stopResp.Stopped[0] != "sub-runtime-b" {
|
||||
t.Fatalf("targeted stop response = %#v", stopResp)
|
||||
}
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "consume token B after targeted stop", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("token B consumer did not exit after targeted stop")
|
||||
}
|
||||
status = waitRuntimeTokenDetachedStatus(t, endpoint, "", 3*time.Second)
|
||||
if len(status.Consumers) != 0 {
|
||||
t.Fatalf("status consumers after stop = %#v", status.Consumers)
|
||||
}
|
||||
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 4 * time.Second}); err != nil {
|
||||
failRuntimeTokenDetachedError(t, "stop detached bus", err)
|
||||
}
|
||||
stopped = true
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "bus_exit clean=true", 3*time.Second)
|
||||
|
||||
statusJSON, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopJSON, err := json.Marshal(stopResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, artifact := range map[string][]byte{
|
||||
"consume A stdout": stdoutA.Bytes(),
|
||||
"consume A stderr": stderrA.Bytes(),
|
||||
"consume B stdout": stdoutB.Bytes(),
|
||||
"consume B stderr": stderrB.Bytes(),
|
||||
"status response": statusJSON,
|
||||
"stop response": stopJSON,
|
||||
} {
|
||||
assertRuntimeTokenDetachedClean(t, name, artifact)
|
||||
}
|
||||
assertRuntimeTokenDetachedTreeClean(t, root)
|
||||
|
||||
for _, required := range []string{
|
||||
filepath.Join(workDir, bus.MetaFileName),
|
||||
filepath.Join(workDir, "bus.log"),
|
||||
filepath.Join(workDir, personal.StateFileName),
|
||||
evidencePath,
|
||||
} {
|
||||
if info, statErr := os.Stat(required); statErr != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("expected runtime artifact %s: info=%v err=%v", required, info, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeTokenDetachedConsumeConfig(workDir, endpoint, subscribeID, token string, duration time.Duration, stdout, stderr *bytes.Buffer) consume.Config {
|
||||
return consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
RuntimeToken: token,
|
||||
EventTypes: []string{personal.EventMention},
|
||||
EventKey: personal.EventMention,
|
||||
SubscribeID: subscribeID,
|
||||
ReadySubscribeID: subscribeID,
|
||||
Duration: duration,
|
||||
Format: consume.FormatNDJSON,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func waitRuntimeTokenDetachedFile(t *testing.T, path, want string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && strings.Contains(string(data), want) {
|
||||
return string(data)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if runtimeTokenDetachedContainsCanary(string(data)) {
|
||||
t.Fatalf("runtime credential leaked into child evidence while waiting for %q", want)
|
||||
}
|
||||
t.Fatalf("evidence %s missing %q: data=%q err=%v", path, want, data, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitRuntimeTokenDetachedStatus(t *testing.T, endpoint, subscribeID string, timeout time.Duration) *transport.StatusResp {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
status, err := busctl.QueryStatus(endpoint)
|
||||
if err == nil {
|
||||
if subscribeID == "" && len(status.Consumers) == 0 {
|
||||
return status
|
||||
}
|
||||
for _, consumer := range status.Consumers {
|
||||
if consumer.SubscribeID == subscribeID {
|
||||
return status
|
||||
}
|
||||
}
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("status never reached subscribe_id=%q: %v", subscribeID, lastErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertRuntimeTokenDetachedTreeClean(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assertRuntimeTokenDetachedClean(t, path, data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("scan runtime artifacts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeTokenDetachedClean(t *testing.T, name string, artifact []byte) {
|
||||
t.Helper()
|
||||
if runtimeTokenDetachedContainsCanary(string(artifact)) {
|
||||
t.Fatalf("runtime credential leaked into %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func failRuntimeTokenDetachedError(t *testing.T, step string, err error) {
|
||||
t.Helper()
|
||||
if err != nil && runtimeTokenDetachedContainsCanary(err.Error()) {
|
||||
t.Fatalf("%s failed and exposed a runtime credential", step)
|
||||
}
|
||||
t.Fatalf("%s: %v", step, err)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenBusRejectsIncompleteIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid identity hash",
|
||||
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "not-a-hash", "--client-id", "client"},
|
||||
want: "16-character hexadecimal identity hash",
|
||||
},
|
||||
{
|
||||
name: "missing client id",
|
||||
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "0123456789abcdef"},
|
||||
want: "--client-id is required",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventBusCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeTokenReleaseErrorStore struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error) {
|
||||
return personal.AttemptHold{}, nil
|
||||
}
|
||||
|
||||
func (s *eventRuntimeTokenReleaseErrorStore) Release(*personal.AttemptClaim) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenAttemptReleaseGuardEdges(t *testing.T) {
|
||||
var nilReservation *personalSubscriptionAttemptReservation
|
||||
if err := nilReservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("nil reservation error = %v", err)
|
||||
}
|
||||
|
||||
incomplete := &personalSubscriptionAttemptReservation{}
|
||||
if err := incomplete.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
|
||||
!strings.Contains(err.Error(), "reservation is incomplete") {
|
||||
t.Fatalf("incomplete reservation error = %v", err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("release failed")
|
||||
reservation := &personalSubscriptionAttemptReservation{
|
||||
store: &eventRuntimeTokenReleaseErrorStore{err: wantErr},
|
||||
claim: &personal.AttemptClaim{AttemptID: "attempt"},
|
||||
}
|
||||
if err := reservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
|
||||
!errors.Is(err, wantErr) {
|
||||
t.Fatalf("release failure error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenConsumeRejectionAndOversizeEdges(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldEnsure := personalEnsureSubscription
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalEnsureSubscription = oldEnsure
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
oversized := strings.Repeat("x", runtimecred.DefaultMaxTokenBytes+1)
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
ExplicitToken: oversized,
|
||||
ClientIDOverride: "runtime-client",
|
||||
Common: commonConsumeOptions{Foreground: true},
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrTokenTooLarge) {
|
||||
t.Fatalf("oversized foreground token error = %v", err)
|
||||
}
|
||||
|
||||
rejection := &personal.APIError{
|
||||
Code: "RUNTIME_TOKEN_REJECTED",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
if personalRuntimeTokenControlRejection(errors.New("ordinary failure")) {
|
||||
t.Fatal("ordinary error classified as runtime-token rejection")
|
||||
}
|
||||
|
||||
singleStore := &personalRecordingAttemptStore{}
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return singleStore }
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return nil, "", "", rejection
|
||||
}
|
||||
err = runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
ExplicitToken: "runtime-token-single",
|
||||
ClientIDOverride: "runtime-client",
|
||||
ControlBaseURL: "https://control.example.test",
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || singleStore.releaseCalls != 1 || singleStore.failureCalls != 0 {
|
||||
t.Fatalf("single rejection = %v, release=%d failure=%d", err, singleStore.releaseCalls, singleStore.failureCalls)
|
||||
}
|
||||
|
||||
manyStore := &personalRecordingAttemptStore{}
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return manyStore }
|
||||
err = runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
ExplicitToken: "runtime-token-many",
|
||||
ClientIDOverride: "runtime-client",
|
||||
ControlBaseURL: "https://control.example.test",
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || manyStore.releaseCalls != 1 || manyStore.failureCalls != 0 {
|
||||
t.Fatalf("multi rejection = %v, release=%d failure=%d", err, manyStore.releaseCalls, manyStore.failureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenIdentityFallbackEdges(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
oldEdition := edition.Get()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
oldResolveIdentity := personalResolveEventIdentity
|
||||
oldResolveAuxiliary := personalResolveAuxiliaryAccessToken
|
||||
oldLoadTokenData := personalLoadTokenData
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
oldResolveCredentials := personalResolveAppCredentialsStrict
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
personalResolveEventIdentity = oldResolveIdentity
|
||||
personalResolveAuxiliaryAccessToken = oldResolveAuxiliary
|
||||
personalLoadTokenData = oldLoadTokenData
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
personalResolveAppCredentialsStrict = oldResolveCredentials
|
||||
})
|
||||
|
||||
legacy := personal.Identity{ClientID: "legacy-client", SourceID: "legacy-source"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return legacy, nil }
|
||||
identity, err := resolvePersonalEventIdentityForToken(context.Background(), configDir, "", " ")
|
||||
if err != nil || identity.ClientID != legacy.ClientID {
|
||||
t.Fatalf("wrapper empty-token fallback = %#v, %v", identity, err)
|
||||
}
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "legacy-access", nil
|
||||
}
|
||||
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
CorpID: "legacy-corp", UserID: "legacy-user", ClientID: "direct-client",
|
||||
}, nil
|
||||
}
|
||||
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", " ")
|
||||
if err != nil || identity.ClientID != "direct-client" {
|
||||
t.Fatalf("direct empty-token fallback = %#v, %v", identity, err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{})
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "" }
|
||||
wantMetadataErr := errors.New("profiles unreadable")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, wantMetadataErr }
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "token", "runtime-client"); !errors.Is(err, wantMetadataErr) {
|
||||
t.Fatalf("explicit profile metadata error = %v", err)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "app-client", "", "", "", nil
|
||||
}
|
||||
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token")
|
||||
if err != nil || identity.ClientID != "app-client" || !strings.HasPrefix(identity.LocalSubject, "access:") {
|
||||
t.Fatalf("app-credential fallback identity = %#v, %v", identity, err)
|
||||
}
|
||||
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", "", "", errors.New("missing app credentials")
|
||||
}
|
||||
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token"); err == nil || !strings.Contains(err.Error(), "cannot resolve OAuth client_id") {
|
||||
t.Fatalf("missing client ID error = %v", err)
|
||||
}
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
CurrentProfile: "stale",
|
||||
Profiles: []authpkg.Profile{{Name: "other", CorpID: "corp", UserID: "user"}},
|
||||
}, nil
|
||||
}
|
||||
profile, err := personalEventProfileMetadata(configDir)
|
||||
if err != nil || profile != nil {
|
||||
t.Fatalf("stale implicit current profile = %#v, %v", profile, err)
|
||||
}
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{Name: "other"}}}, nil
|
||||
}
|
||||
profile, err = personalEventProfileMetadata(configDir)
|
||||
if err != nil || profile != nil {
|
||||
t.Fatalf("empty implicit selector = %#v, %v", profile, err)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
if _, err := personalEventProfileMetadata(configDir); err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
|
||||
t.Fatalf("multi-profile metadata error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeTokenReadErrorBody struct{}
|
||||
|
||||
func (eventRuntimeTokenReadErrorBody) Read([]byte) (int, error) {
|
||||
return 0, errors.New("body read failed")
|
||||
}
|
||||
|
||||
func (eventRuntimeTokenReadErrorBody) Close() error { return nil }
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenControlTransportErrorEdges(t *testing.T) {
|
||||
const token = "runtime-control-edge-canary"
|
||||
|
||||
unsupported, err := http.NewRequest(http.MethodGet, "unsupported://control.example.test/path", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (runtimeTokenControlTransport{}).RoundTrip(unsupported); err == nil {
|
||||
t.Fatal("nil base unexpectedly accepted an unsupported protocol")
|
||||
}
|
||||
|
||||
wantTransportErr := errors.New("ordinary transport failure")
|
||||
ordinary := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, wantTransportErr
|
||||
})}
|
||||
if _, err := ordinary.RoundTrip(unsupported); !errors.Is(err, wantTransportErr) {
|
||||
t.Fatalf("ordinary transport error = %v", err)
|
||||
}
|
||||
|
||||
leaking := runtimeTokenControlTransport{
|
||||
token: token,
|
||||
base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("reflected " + token)
|
||||
}),
|
||||
}
|
||||
if _, err := leaking.RoundTrip(unsupported); err == nil || strings.Contains(err.Error(), token) ||
|
||||
err.Error() != "personal event: runtime-token control request failed" {
|
||||
t.Fatalf("redacted transport error = %v", err)
|
||||
}
|
||||
|
||||
nilResponse := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, nil
|
||||
})}
|
||||
if resp, err := nilResponse.RoundTrip(unsupported); resp != nil || err != nil {
|
||||
t.Fatalf("nil response = %#v, %v", resp, err)
|
||||
}
|
||||
|
||||
readFailure := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Header: make(http.Header),
|
||||
Body: eventRuntimeTokenReadErrorBody{},
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
if _, err := readFailure.RoundTrip(unsupported); err == nil || !strings.Contains(err.Error(), "read runtime-token control response") {
|
||||
t.Fatalf("body read error = %v", err)
|
||||
}
|
||||
|
||||
nilHeader := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
|
||||
})}
|
||||
resp, err := nilHeader.RoundTrip(unsupported)
|
||||
if err != nil || resp == nil || resp.Header == nil || resp.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("nil-header response = %#v, %v", resp, err)
|
||||
}
|
||||
|
||||
if got := redactRuntimeTokenResponseBody(nil, token); len(got) != 0 {
|
||||
t.Fatalf("empty response redaction = %q", got)
|
||||
}
|
||||
value, changed := redactRuntimeTokenJSONValue([]any{"plain", "prefix-" + token}, token)
|
||||
items, ok := value.([]any)
|
||||
if !ok || !changed || len(items) != 2 || strings.Contains(items[1].(string), token) {
|
||||
t.Fatalf("array redaction = %#v changed=%t", value, changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
// 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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandsWireTrimmedRootRuntimeToken(t *testing.T) {
|
||||
oldConsume := eventRunPersonalConsume
|
||||
oldStatus := eventRunPersonalStatus
|
||||
oldStop := eventRunPersonalStop
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalConsume = oldConsume
|
||||
eventRunPersonalStatus = oldStatus
|
||||
eventRunPersonalStop = oldStop
|
||||
})
|
||||
|
||||
flags := &GlobalFlags{Token: " runtime-canary ", ClientID: " root-client "}
|
||||
assertIdentity := func(token, clientID string) {
|
||||
t.Helper()
|
||||
if token != "runtime-canary" || clientID != "root-client" {
|
||||
t.Fatalf("runtime identity = token %q client %q", token, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
consumeCmd := newEventConsumeCommand(flags)
|
||||
if err := consumeCmd.RunE(consumeCmd, []string{personal.EventMention}); err != nil {
|
||||
t.Fatalf("consume RunE() error = %v", err)
|
||||
}
|
||||
|
||||
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
statusCmd := newEventStatusCommandWithFlags(flags)
|
||||
if err := statusCmd.RunE(statusCmd, nil); err != nil {
|
||||
t.Fatalf("status RunE() error = %v", err)
|
||||
}
|
||||
|
||||
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
stopCmd := newEventStopCommandWithFlags(flags)
|
||||
stopRoot := &cobra.Command{Use: "dws"}
|
||||
stopRoot.PersistentFlags().Bool("yes", true, "")
|
||||
stopRoot.AddCommand(stopCmd)
|
||||
if err := stopCmd.RunE(stopCmd, []string{"sub-runtime"}); err != nil {
|
||||
t.Fatalf("stop RunE() error = %v", err)
|
||||
}
|
||||
|
||||
listenCmd := newEventListenIMCommand(flags)
|
||||
if err := listenCmd.RunE(listenCmd, nil); err != nil {
|
||||
t.Fatalf("listen-im RunE() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventConsumeParsesRootRuntimeTokenBeforeAndAfterSubcommand(t *testing.T) {
|
||||
oldConsume := eventRunPersonalConsume
|
||||
t.Cleanup(func() { eventRunPersonalConsume = oldConsume })
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "before", args: []string{"--token", "runtime-before", "event", "consume", personal.EventMention}},
|
||||
{name: "after", args: []string{"event", "consume", personal.EventMention, "--token", "runtime-after"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
flags := &GlobalFlags{}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
bindPersistentFlags(root, flags)
|
||||
root.AddCommand(newEventCommand(flags))
|
||||
var got string
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
got = opts.ExplicitToken
|
||||
return nil
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
want := "runtime-" + tc.name
|
||||
if got != want {
|
||||
t.Fatalf("ExplicitToken = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenUsesMetadataOnly(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldLoadToken := personalLoadTokenData
|
||||
oldAux := personalResolveAuxiliaryAccessToken
|
||||
oldClientID := personalClientID
|
||||
oldCredentials := personalResolveAppCredentialsStrict
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalLoadTokenData = oldLoadToken
|
||||
personalResolveAuxiliaryAccessToken = oldAux
|
||||
personalClientID = oldClientID
|
||||
personalResolveAppCredentialsStrict = oldCredentials
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
|
||||
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
t.Fatal("explicit token identity read sensitive TokenData")
|
||||
return nil, nil
|
||||
}
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
|
||||
t.Fatal("explicit token identity resolved local OAuth")
|
||||
return "", nil
|
||||
}
|
||||
personalClientID = func() string {
|
||||
t.Fatal("explicit root client ID was not preferred")
|
||||
return ""
|
||||
}
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
t.Fatal("explicit root client ID unexpectedly fell back to app credentials")
|
||||
return "", "", "", "", nil
|
||||
}
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Runtime profile",
|
||||
CorpID: "profile-corp",
|
||||
CorpName: "Runtime Org",
|
||||
UserID: "profile-user",
|
||||
UserName: "Runtime User",
|
||||
ClientID: "profile-client",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("Runtime Org:Runtime User")
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "", false },
|
||||
}
|
||||
}})
|
||||
|
||||
identity, err := resolvePersonalEventIdentityWithToken(
|
||||
context.Background(), "unused", "runtime-source", " runtime-canary ", "root-client",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "profile-user" || identity.ClientID != "root-client" {
|
||||
t.Fatalf("identity metadata = %#v", identity)
|
||||
}
|
||||
if identity.AccessToken != "" {
|
||||
t.Fatalf("identity retained raw runtime token: %q", identity.AccessToken)
|
||||
}
|
||||
if identity.LocalSubject != "" {
|
||||
t.Fatalf("complete identity LocalSubject = %q, want empty", identity.LocalSubject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithCompleteRuntimeMetadataSkipsProfiles(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("complete host metadata unexpectedly read profiles.json")
|
||||
return nil, nil
|
||||
}
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
personalRuntimeEventClientID = func() string { return "edition-client" }
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "root-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeEventClientIDPrefersEditionBeforeEnvironment(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
t.Setenv("DWS_CLIENT_ID", "environment-client")
|
||||
|
||||
edition.Override(&edition.Hooks{AuthClientID: "edition-client"})
|
||||
if got := runtimePersonalEventClientID(); got != "edition-client" {
|
||||
t.Fatalf("runtime client ID = %q, want edition hook", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if got := runtimePersonalEventClientID(); got != "environment-client" {
|
||||
t.Fatalf("runtime client ID = %q, want environment fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompleteRuntimeIdentityUsesEditionClientBeforeProfiles(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("complete host metadata unexpectedly read profiles.json")
|
||||
return nil, errors.New("unreachable")
|
||||
}
|
||||
personalRuntimeEventClientID = func() string { return "edition-client" }
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "edition-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectedProfileClientPrecedesPersistedGlobalClient(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "stale-global-client" }
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
Version: 3,
|
||||
CurrentProfile: "corp:user",
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Selected", CorpID: "corp", UserID: "user", ClientID: "profile-client",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.ClientID != "profile-client" {
|
||||
t.Fatalf("ClientID = %q, want selected profile client", identity.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMalformedPersistedProfilesDoNotBlockRuntimeDefaultsAndGlobalClient(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "global-client" }
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return nil, errors.New("malformed persisted profiles")
|
||||
}
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "global-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenRejectsMultipleProfilesBeforeMetadata(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("multiple runtime profiles unexpectedly reached metadata loading")
|
||||
return nil, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
_, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
|
||||
t.Fatalf("multiple-profile error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitProfileRequiresMetadataRegistry(t *testing.T) {
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
defer func() { personalLoadProfiles = oldLoadProfiles }()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile("missing-profile")
|
||||
defer authpkg.SetRuntimeProfile(oldProfile)
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
_, err := personalEventProfileMetadata(t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
|
||||
t.Fatalf("personalEventProfileMetadata() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompleteRuntimeIdentityStillValidatesExplicitProfile(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
})
|
||||
|
||||
authpkg.SetRuntimeProfile("missing-profile")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
personalRuntimeEventClientID = func() string { return "runtime-client" }
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
_, err := resolvePersonalEventIdentityWithToken(context.Background(), t.TempDir(), "source", "canary")
|
||||
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalProfileMetadataOrganizationCurrentBeatsUnresolved(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 3,
|
||||
Profiles: []authpkg.Profile{
|
||||
{Name: "Historical", CorpID: "corp-1"},
|
||||
{Name: "Exact", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
|
||||
}
|
||||
profile, err := selectPersonalEventProfileMetadata(cfg, "corp-1", make(map[string]struct{}))
|
||||
if err != nil {
|
||||
t.Fatalf("selectPersonalEventProfileMetadata() error = %v", err)
|
||||
}
|
||||
if profile == nil || profile.UserID != "user-1" {
|
||||
t.Fatalf("selected profile = %#v, want organization current account", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsReflected401(t *testing.T) {
|
||||
const token = "runtime-control-canary"
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
if !ok {
|
||||
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
|
||||
}
|
||||
var authorization string
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
authorization = req.Header.Get("Authorization")
|
||||
body := `{"code":"UNAUTHORIZED","message":"rejected ` + token + `"}`
|
||||
header := make(http.Header)
|
||||
header.Set("X-Request-Id", "request-"+token)
|
||||
header.Set("X-Trace-Id", "trace-"+token)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
|
||||
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if authorization != "Bearer "+token {
|
||||
t.Fatalf("Authorization = %q", authorization)
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RUNTIME_TOKEN_REJECTED") {
|
||||
t.Fatalf("error = %q, want fixed runtime token rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenRedirectGuardDoesNotForwardCustomHeader(t *testing.T) {
|
||||
const token = "runtime-redirect-canary"
|
||||
var controlTargetHits, ticketTargetHits atomic.Int32
|
||||
controlTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
controlTargetHits.Add(1)
|
||||
if r.Header.Get("x-user-access-token") == token {
|
||||
t.Error("control redirect forwarded runtime token")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer controlTarget.Close()
|
||||
controlOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != token {
|
||||
t.Error("control origin did not receive runtime token")
|
||||
}
|
||||
http.Redirect(w, r, controlTarget.URL, http.StatusFound)
|
||||
}))
|
||||
defer controlOrigin.Close()
|
||||
|
||||
client := newPersonalEventControlClient("unused", controlOrigin.URL, personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
_, controlErr := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if controlErr == nil {
|
||||
t.Fatal("cross-host control redirect unexpectedly succeeded")
|
||||
}
|
||||
if controlTargetHits.Load() != 0 || strings.Contains(controlErr.Error(), token) {
|
||||
t.Fatalf("control redirect hits=%d error=%q", controlTargetHits.Load(), controlErr)
|
||||
}
|
||||
|
||||
ticketTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ticketTargetHits.Add(1)
|
||||
if r.Header.Get("x-user-access-token") == token {
|
||||
t.Error("ticket redirect forwarded runtime token")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ticketTarget.Close()
|
||||
ticketOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != token {
|
||||
t.Error("ticket origin did not receive runtime token")
|
||||
}
|
||||
http.Redirect(w, r, ticketTarget.URL, http.StatusFound)
|
||||
}))
|
||||
defer ticketOrigin.Close()
|
||||
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true})
|
||||
if _, err := broker.Update(0, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
|
||||
ConfigDir: "unused",
|
||||
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
|
||||
TicketURL: ticketOrigin.URL,
|
||||
CredentialBroker: broker,
|
||||
RuntimeTokenMode: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
|
||||
if err == nil {
|
||||
t.Fatal("cross-host ticket redirect unexpectedly succeeded")
|
||||
}
|
||||
if ticketTargetHits.Load() != 0 || strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("ticket redirect hits=%d error=%q", ticketTargetHits.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenRedirectPolicyBranches(t *testing.T) {
|
||||
origin, _ := http.NewRequest(http.MethodGet, "https://control.example/start", nil)
|
||||
sameHost, _ := http.NewRequest(http.MethodGet, "https://control.example/next", nil)
|
||||
if err := runtimeTokenRedirectPolicy(sameHost, []*http.Request{origin}); err != nil {
|
||||
t.Fatalf("same-host HTTPS redirect rejected: %v", err)
|
||||
}
|
||||
for name, request := range map[string]*http.Request{
|
||||
"cross-host": func() *http.Request {
|
||||
r, _ := http.NewRequest(http.MethodGet, "https://other.example/next", nil)
|
||||
return r
|
||||
}(),
|
||||
"downgrade": func() *http.Request {
|
||||
r, _ := http.NewRequest(http.MethodGet, "http://control.example/next", nil)
|
||||
return r
|
||||
}(),
|
||||
} {
|
||||
if err := runtimeTokenRedirectPolicy(request, []*http.Request{origin}); !errors.Is(err, http.ErrUseLastResponse) {
|
||||
t.Fatalf("%s redirect policy error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := runtimeTokenRedirectPolicy(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
|
||||
t.Fatalf("empty redirect chain error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsEveryErrorEnvelope(t *testing.T) {
|
||||
const token = "runtime-control-all-status-canary"
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{name: "bad-request", status: http.StatusBadRequest, body: `{"code":"BAD_REQUEST","message":"` + token + `"}`},
|
||||
{name: "server-error", status: http.StatusInternalServerError, body: `{"code":"INTERNAL","message":"` + token + `"}`},
|
||||
{name: "success-false", status: http.StatusOK, body: `{"success":false,"errorCode":"DENIED","errorMsg":"` + token + `"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
header := make(http.Header)
|
||||
header.Set("X-Request-Id", "request-"+token)
|
||||
header.Set("X-Trace-Id", "trace-"+token)
|
||||
return &http.Response{
|
||||
StatusCode: tc.status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(tc.body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
|
||||
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlTransportPreservesSuccessfulResponse(t *testing.T) {
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, "runtime-success-canary")
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[],"total":0}}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
if _, err := client.ListSubscriptions(context.Background(), personal.ListOptions{}); err != nil {
|
||||
t.Fatalf("ListSubscriptions() successful response error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsJSONEscapedToken(t *testing.T) {
|
||||
const token = "runtime<escaped>&canary"
|
||||
body, err := json.Marshal(map[string]any{"code": "BAD_REQUEST", "message": "rejected " + token})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body, []byte(token)) {
|
||||
t.Fatalf("fixture was not JSON-escaped: %s", body)
|
||||
}
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
_, err = client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("escaped runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenBusModeSkipsLocalOAuthIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldResolve := eventResolvePersonal
|
||||
oldSource := eventNewPersonalSource
|
||||
oldRun := eventBusRun
|
||||
t.Cleanup(func() {
|
||||
eventResolvePersonal = oldResolve
|
||||
eventNewPersonalSource = oldSource
|
||||
eventBusRun = oldRun
|
||||
})
|
||||
|
||||
resolvedLocal := false
|
||||
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
|
||||
resolvedLocal = true
|
||||
return personal.Identity{}, nil
|
||||
}
|
||||
var sourceOpts personalStreamSourceOptions
|
||||
eventNewPersonalSource = func(_ context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
sourceOpts = opts
|
||||
return nil, nil
|
||||
}
|
||||
var busCfg bus.Config
|
||||
eventBusRun = func(_ context.Context, cfg bus.Config) error {
|
||||
busCfg = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventBusCommand()
|
||||
cmd.SetArgs([]string{
|
||||
"--source-kind", "personal_stream",
|
||||
"--runtime-token-mode",
|
||||
"--identity-hash", "0123456789abcdef",
|
||||
"--client-id", "runtime-client",
|
||||
"--stream-source-id", "runtime-source",
|
||||
"--idle-timeout", "0",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("event _bus runtime mode error = %v", err)
|
||||
}
|
||||
if resolvedLocal {
|
||||
t.Fatal("runtime token bus resolved local OAuth identity")
|
||||
}
|
||||
if sourceOpts.CredentialBroker == nil || busCfg.CredentialBroker != sourceOpts.CredentialBroker {
|
||||
t.Fatal("personal source and bus did not share one credential broker")
|
||||
}
|
||||
if busCfg.IdentityHash != "0123456789abcdef" || busCfg.ClientID != "runtime-client" || busCfg.SourceID != "runtime-source" {
|
||||
t.Fatalf("bus identity = %#v", busCfg)
|
||||
}
|
||||
generation, err := sourceOpts.CredentialBroker.Update(0, "detached-activation-canary")
|
||||
if err != nil {
|
||||
t.Fatalf("seed detached broker: %v", err)
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := sourceOpts.CredentialBroker.Resolve(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("detached broker resolved before consumer activation: %v", err)
|
||||
}
|
||||
if _, err := sourceOpts.CredentialBroker.Activate(generation); err != nil {
|
||||
t.Fatalf("activate detached broker: %v", err)
|
||||
}
|
||||
if resolved, err := sourceOpts.CredentialBroker.Resolve(context.Background()); err != nil || resolved == "" {
|
||||
t.Fatalf("detached broker did not resolve after activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForegroundRuntimeBrokerDoesNotRequireActivation(t *testing.T) {
|
||||
broker := newPersonalCredentialBroker(t.TempDir(), true, false)
|
||||
if _, err := broker.Update(0, "foreground-activation-canary"); err != nil {
|
||||
t.Fatalf("seed foreground broker: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if resolved, err := broker.Resolve(ctx); err != nil || resolved == "" {
|
||||
t.Fatalf("foreground broker unexpectedly waited for activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalRuntimeBusSpawnArgsContainNoSecretOrProfile(t *testing.T) {
|
||||
const token = "runtime-spawn-canary"
|
||||
args := personalBusSpawnArgsForToken(personal.Identity{
|
||||
ClientID: "client", SourceID: "source", CorpID: "corp", UserID: "user",
|
||||
}, "identity-hash", "normal", "https://ticket.invalid", "corp:user", token)
|
||||
joined := strings.Join(args, " ")
|
||||
for _, forbidden := range []string{token, "--profile", "corp:user"} {
|
||||
if strings.Contains(joined, forbidden) {
|
||||
t.Fatalf("spawn args leaked %q: %q", forbidden, joined)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"--runtime-token-mode", "--identity-hash", "identity-hash", "--stream-source-id", "source"} {
|
||||
if !strings.Contains(joined, required) {
|
||||
t.Fatalf("spawn args %q missing %q", joined, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnsupportedOldBusDoesNotDeleteReusedSubscription(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldConsume := personalConsumeRun
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalConsumeRun = oldConsume
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
})
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
deleteCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
if strings.TrimSpace(cfg.RuntimeToken) == "" {
|
||||
t.Fatal("runtime token was not wired to consume")
|
||||
}
|
||||
return &consume.RuntimeTokenUnsupportedError{BusPID: 72}
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-existing",
|
||||
ExplicitToken: "old-bus-cleanup-canary",
|
||||
ClientIDOverride: "runtime-client",
|
||||
})
|
||||
if !errors.Is(err, consume.ErrRuntimeTokenUnsupported) {
|
||||
t.Fatalf("consume error = %v", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("reused remote subscription was deleted %d time(s)", deleteCalls)
|
||||
}
|
||||
if len(removed) != 0 {
|
||||
t.Fatalf("reused local run-state was removed: %#v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenReusedDryRunUsesExplicitControlCredential(t *testing.T) {
|
||||
const token = "runtime-dry-run-control-canary"
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldConsume := personalConsumeRun
|
||||
oldBusRun := personalBusRun
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalConsumeRun = oldConsume
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
personalEnsureSubscription = func(ctx context.Context, client *personal.Client, _ personal.Identity, _ personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if _, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport); !ok {
|
||||
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
|
||||
}
|
||||
got, err := client.AccessTokenProvider(ctx)
|
||||
if err != nil || got != token {
|
||||
t.Fatalf("control token = %q, %v", got, err)
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
t.Fatal("dry-run unexpectedly persisted run state")
|
||||
return nil
|
||||
}
|
||||
consumeCalls := 0
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
consumeCalls++
|
||||
if !cfg.DryRun {
|
||||
t.Fatal("consume config is not dry-run")
|
||||
}
|
||||
if strings.Contains(strings.Join(cfg.SpawnExtraArgs, " "), token) {
|
||||
t.Fatal("dry-run spawn args leaked runtime token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
t.Fatal("dry-run unexpectedly started a bus")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-existing",
|
||||
ExplicitToken: token,
|
||||
ClientIDOverride: "runtime-client",
|
||||
Common: commonConsumeOptions{DryRun: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run consume error = %v", err)
|
||||
}
|
||||
if consumeCalls != 1 {
|
||||
t.Fatalf("dry-run consume calls = %d, want 1", consumeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenControlRejectionReleasesSubscriptionClaim(t *testing.T) {
|
||||
store := &personalRecordingAttemptStore{}
|
||||
reservation := &personalSubscriptionAttemptReservation{
|
||||
store: store,
|
||||
claim: &personal.AttemptClaim{AttemptID: "runtime-token-attempt"},
|
||||
items: []personalSubscriptionAttemptItem{{eventKey: personal.EventMention, fingerprint: strings.Repeat("a", 64)}},
|
||||
}
|
||||
cause := &personal.APIError{
|
||||
Code: "RUNTIME_TOKEN_REJECTED",
|
||||
Message: "event runtime token was rejected; retry with a fresh host credential",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
if !personalRuntimeTokenControlRejection(cause) {
|
||||
t.Fatal("runtime token control rejection was not classified")
|
||||
}
|
||||
err := reservation.releaseRuntimeTokenFailure()
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime token was rejected") {
|
||||
t.Fatalf("releaseRuntimeTokenFailure() error = %v", err)
|
||||
}
|
||||
if store.releaseCalls != 1 || store.failureCalls != 0 {
|
||||
t.Fatalf("attempt store release=%d failure=%d, want release only", store.releaseCalls, store.failureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f eventRuntimeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
|
||||
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
|
||||
retry := int64(4)
|
||||
started := true
|
||||
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
|
||||
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
|
||||
typed := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
|
||||
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
|
||||
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
|
||||
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
|
||||
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
|
||||
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
|
||||
Cause: call,
|
||||
}
|
||||
info := errorInfoFromExecutionError(typed)
|
||||
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
|
||||
t.Fatalf("projection=%+v", info)
|
||||
}
|
||||
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
|
||||
t.Fatalf("recovery metadata=%+v", info)
|
||||
}
|
||||
|
||||
innerOperation := &helpers.CLIError{Operation: "create"}
|
||||
outerWithoutOperation := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI,
|
||||
Message: "failed",
|
||||
Cause: innerOperation,
|
||||
}
|
||||
preserved := errorInfoFromExecutionError(outerWithoutOperation)
|
||||
if preserved.Operation != "create" {
|
||||
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
|
||||
}
|
||||
|
||||
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
|
||||
requestInfo := errorInfoFromExecutionError(requestCall)
|
||||
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
|
||||
t.Fatalf("request projection=%+v", requestInfo)
|
||||
}
|
||||
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
|
||||
if partial.Type != "internal" {
|
||||
t.Fatalf("partial error type=%s", partial.Type)
|
||||
}
|
||||
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
|
||||
if got := errorTypeForExitCode(code); got != want {
|
||||
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "leaf"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
leaf.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
leaf.SetOut(&stdout)
|
||||
}
|
||||
leaf.SetErr(&bytes.Buffer{})
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"active-no-result"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("ExecuteC error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
|
||||
originalRemove := rootRemoveFile
|
||||
t.Cleanup(func() { rootRemoveFile = originalRemove })
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootRemoveFile = func(string) error { return errors.New("remove failed") }
|
||||
cmd := &cobra.Command{Use: "abort"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
|
||||
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
|
||||
t.Fatalf("abort error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
|
||||
installOutputSinkRunBoundary(nil)
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
plain.SetContext(context.Background())
|
||||
installOutputSinkRunBoundary(plain)
|
||||
|
||||
// newBoundaryChild builds a leaf whose --output lives on the root's
|
||||
// persistent flag set, matching production wiring (a local --output flag
|
||||
// belongs to the leaf's own business contract and skips the sink).
|
||||
newBoundaryChild := func(outputPath string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().String("output", outputPath, "")
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
root.AddCommand(cmd)
|
||||
cmd.SetContext(context.Background())
|
||||
return cmd
|
||||
}
|
||||
|
||||
var calls int
|
||||
cmd := newBoundaryChild("")
|
||||
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PostRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runOnly := newBoundaryChild("")
|
||||
runOnly.Run = func(*cobra.Command, []string) { calls++ }
|
||||
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
|
||||
installOutputSinkRunBoundary(runOnly)
|
||||
if runOnly.Run != nil || runOnly.RunE == nil {
|
||||
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
|
||||
}
|
||||
if err := runOnly.RunE(runOnly, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runOnly.PostRun(runOnly, nil)
|
||||
if calls != 4 {
|
||||
t.Fatalf("hook calls=%d", calls)
|
||||
}
|
||||
|
||||
// A sink setup failure at Run entry returns before the business hook runs.
|
||||
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
|
||||
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
|
||||
businessRan := false
|
||||
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
|
||||
installOutputSinkRunBoundary(failCmd)
|
||||
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
|
||||
t.Fatalf("Run entry sink setup error=%v", err)
|
||||
}
|
||||
if businessRan {
|
||||
t.Fatal("business hook ran after sink setup failure")
|
||||
}
|
||||
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
|
||||
|
||||
// A Run entry business error aborts the open sink: the temporary file is
|
||||
// removed and the final target is never created.
|
||||
abortTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
abortCmd := newBoundaryChild(abortTarget)
|
||||
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
|
||||
installOutputSinkRunBoundary(abortCmd)
|
||||
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Run entry business error=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target exists after aborted run: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, abortTarget)
|
||||
|
||||
// A second configureOutputSink call on an already-open sink (a reused
|
||||
// command tree stacks one Run wrapper per ExecuteC) must not replace the
|
||||
// live sink with a second temporary file.
|
||||
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
repeatCmd := newBoundaryChild(repeatTarget)
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := outputSinkForCommand(repeatCmd)
|
||||
if first == nil {
|
||||
t.Fatal("first configureOutputSink did not open a sink")
|
||||
}
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second := outputSinkForCommand(repeatCmd); second != first {
|
||||
t.Fatal("configureOutputSink replaced an open sink")
|
||||
}
|
||||
if err := abortOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoOutputTemps(t, repeatTarget)
|
||||
|
||||
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorCmd := &cobra.Command{Use: "error"}
|
||||
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
|
||||
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
|
||||
t.Fatal("run error swallowed")
|
||||
}
|
||||
|
||||
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panicCmd := &cobra.Command{Use: "panic"}
|
||||
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("panic swallowed")
|
||||
}
|
||||
}()
|
||||
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
|
||||
}()
|
||||
|
||||
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
|
||||
t.Fatal("nil sink guards failed")
|
||||
}
|
||||
finished := &outputSinkState{finished: true, file: file3}
|
||||
finishedCmd := &cobra.Command{Use: "finished"}
|
||||
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
|
||||
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
|
||||
t.Fatal("finished sink was processed twice")
|
||||
}
|
||||
}
|
||||
|
||||
type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
cmd.SetOut(&stdout)
|
||||
}
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
return cmd
|
||||
})
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
|
||||
t.Run("preparse interrupted", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return errors.New("preparse failed")
|
||||
})
|
||||
if code := Execute(); code != 130 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil executed after emission attempt", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return nil, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure after emission", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failure envelope cannot be written", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late output publication warning", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, nil
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
unified bool
|
||||
original io.Writer
|
||||
wantOutput bool
|
||||
}{
|
||||
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
|
||||
{name: "legacy late publication failure", original: io.Discard},
|
||||
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
var original io.Writer = tc.original
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
panic("after sink open")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("abort failure is diagnostic", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "unified"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, finished: true,
|
||||
}))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, emitErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
|
||||
t.Run("flag group validation", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().Bool("left", false, "")
|
||||
leaf.Flags().Bool("right", false, "")
|
||||
leaf.MarkFlagsMutuallyExclusive("left", "right")
|
||||
root.AddCommand(leaf)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"exclusive", "--left", "--right"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected mutually-exclusive flag error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edition pre-run error", func(t *testing.T) {
|
||||
old := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(old) })
|
||||
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
|
||||
return errors.New("edition hook failed")
|
||||
}})
|
||||
root := NewRootCommand(context.Background())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post-run emission panic", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
cmd := &cobra.Command{Use: "panic-output"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(frameworkPanicWriter{})
|
||||
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected post-run panic")
|
||||
}
|
||||
}()
|
||||
_ = root.PersistentPostRunE(cmd, nil)
|
||||
})
|
||||
}
|
||||
+498
-26
@@ -15,24 +15,24 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -58,8 +59,14 @@ var (
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
rootCreateFile = os.Create
|
||||
rootCreateTemp = os.CreateTemp
|
||||
rootSyncFile = (*os.File).Sync
|
||||
rootCloseFile = (*os.File).Close
|
||||
// os.Rename replaces an existing non-directory target on every supported
|
||||
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
|
||||
// the temporary file beside the target so publication stays on one volume.
|
||||
rootRenameFile = os.Rename
|
||||
rootRemoveFile = os.Remove
|
||||
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||
@@ -76,10 +83,53 @@ var (
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
resultStore *output.ResultStore
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
|
||||
}
|
||||
} else if target != nil && output.UsesUnifiedResult(target) {
|
||||
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
|
||||
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
if executed == nil {
|
||||
executed = target
|
||||
}
|
||||
}
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
|
||||
if emitErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -95,15 +145,17 @@ func Execute() (exitCode int) {
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Attach timing collector to context for use by child components
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
ctx := WithTimingCollector(context.Background(), timing)
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -111,16 +163,89 @@ func Execute() (exitCode int) {
|
||||
// and --limit100 → --limit 100.
|
||||
if err := rootRunPreParse(root, engine); err != nil {
|
||||
err = newPreParseValidationError(err)
|
||||
if interrupted, _ := signalState.outcome(); interrupted != nil {
|
||||
err = interrupted
|
||||
}
|
||||
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
executed, err := rootExecuteCommand(root)
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
// errors to a defer that runs after the process exit code is fixed.
|
||||
if executed != nil {
|
||||
if err == nil {
|
||||
if closeErr := closeOutputSink(executed); closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
} else if abortErr := abortOutputSink(executed); abortErr != nil {
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
|
||||
}
|
||||
}
|
||||
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
|
||||
if interrupted != nil && !primaryCompletedBeforeSignal {
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if err != nil && stderrors.As(err, &publicationErr) {
|
||||
// The successful result was written only to a transaction that did
|
||||
// not publish. Let the error path replace it with one observable
|
||||
// failure envelope on the restored original stream.
|
||||
} else {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
|
||||
// Once publication starts, its stored exit code is authoritative. A
|
||||
// signal recorded just before or during publication must not turn a
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
if err == nil || !stderrors.As(err, &publicationErr) {
|
||||
err = interrupted
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -129,9 +254,121 @@ func Execute() (exitCode int) {
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||
// failure body. Exit code and category are derived from the same error value,
|
||||
// preventing the wire and process status from drifting apart.
|
||||
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
info := &output.ErrorInfo{
|
||||
Type: errorTypeForExitCode(exitCode),
|
||||
ExitCode: exitCode,
|
||||
Message: err.Error(),
|
||||
}
|
||||
var interrupted *processInterruption
|
||||
if stderrors.As(err, &interrupted) && interrupted != nil {
|
||||
info.Type = "internal"
|
||||
info.Subtype = interrupted.Subtype()
|
||||
return info
|
||||
}
|
||||
if stderrors.Is(err, context.DeadlineExceeded) {
|
||||
info.Subtype = "deadline_exceeded"
|
||||
}
|
||||
var cliErr *helpers.CLIError
|
||||
if stderrors.As(err, &cliErr) && cliErr != nil {
|
||||
info.UpstreamCode = cliErr.Code
|
||||
info.Hint = cliErr.Suggestion
|
||||
info.Operation = cliErr.Operation
|
||||
}
|
||||
var callErr *transport.CallError
|
||||
if stderrors.As(err, &callErr) && callErr != nil {
|
||||
info.HTTPStatus = callErr.HTTPStatus
|
||||
info.RPCCode = callErr.RPCCode
|
||||
info.Stage = string(callErr.Stage)
|
||||
if callErr.RequestID != "" {
|
||||
info.RequestID = callErr.RequestID
|
||||
} else if callErr.TraceID != "" {
|
||||
info.RequestID = callErr.TraceID
|
||||
}
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed == nil {
|
||||
return info
|
||||
}
|
||||
if typed.Category == apperrors.CategoryPartial {
|
||||
// An error lacks the item-level data required by partial_failure.
|
||||
// Callers must use output.Partial; fail closed consistently otherwise.
|
||||
info.Type = string(apperrors.CategoryInternal)
|
||||
} else {
|
||||
info.Type = string(typed.Category)
|
||||
}
|
||||
info.Subtype = typed.Reason
|
||||
if typed.Hint != "" {
|
||||
info.Hint = typed.Hint
|
||||
}
|
||||
info.Actions = append([]string(nil), typed.Actions...)
|
||||
info.Retryable = typed.RetryableSet && typed.Retryable
|
||||
info.RetryAfterSeconds = typed.RetryAfterSeconds
|
||||
if typed.RPCCode != 0 {
|
||||
info.RPCCode = typed.RPCCode
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
info.TraceID = typed.ServerDiag.TraceID
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
info.Operation = typed.Operation
|
||||
}
|
||||
info.ServerKey = typed.ServerKey
|
||||
info.Origin = typed.Origin
|
||||
if typed.FailureStage != "" {
|
||||
info.Stage = typed.FailureStage
|
||||
}
|
||||
info.ExecutionStarted = typed.ExecutionStarted
|
||||
if typed.NextRetryAt != nil {
|
||||
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
|
||||
info.SnapshotPath = typed.Snapshot
|
||||
info.Details = typed.Details
|
||||
if len(typed.RPCData) > 0 {
|
||||
var rpcData any
|
||||
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
|
||||
info.RPCData = rpcData
|
||||
}
|
||||
}
|
||||
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
|
||||
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
|
||||
if typed.Cause != nil {
|
||||
info.Cause = typed.Cause.Error()
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func errorTypeForExitCode(code int) string {
|
||||
switch code {
|
||||
case 1:
|
||||
return "api"
|
||||
case 2:
|
||||
return "auth"
|
||||
case 3:
|
||||
return "validation"
|
||||
case 4:
|
||||
return "permission"
|
||||
case 6:
|
||||
return "discovery"
|
||||
default:
|
||||
return "internal"
|
||||
}
|
||||
}
|
||||
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
@@ -368,6 +605,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||
}
|
||||
|
||||
@@ -390,6 +628,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||
}
|
||||
|
||||
@@ -414,6 +653,25 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// A public root may be reused by embedding callers through multiple
|
||||
// ExecuteC invocations. Begin each invocation with an empty result
|
||||
// lifecycle while retaining the store pointer observed by Execute's
|
||||
// signal and exit-code handling. Declaration-only command trees do not
|
||||
// install a store at construction time, so add one lazily when those
|
||||
// trees are executed for compatibility and policy tests.
|
||||
executionCtx, _ := output.WithResultStore(cmd.Context())
|
||||
cmd.SetContext(executionCtx)
|
||||
// WithResultStore above guarantees the reset precondition.
|
||||
_ = output.ResetResultStore(executionCtx)
|
||||
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
|
||||
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
|
||||
// rely on that order to normalize alias flags into required canonical
|
||||
// flags (for example chat message download-media copies --msg-id into
|
||||
// the required --message-id in PreRunE). Running them early fails the
|
||||
// alias path before the leaf can normalize it. The transactional
|
||||
// --output sink instead opens at Run entry (after Cobra's own
|
||||
// validation), so validation failures still cannot strand a
|
||||
// temporary file.
|
||||
// Validate caller-provided identity labels before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
@@ -436,19 +694,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
// Configure global slog level based on --debug / --verbose flags.
|
||||
configureLogLevel(flags)
|
||||
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
||||
return fn(cmd, args)
|
||||
if err := fn(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
_, emitted, emitErr := output.EmitStoredResult(cmd)
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
if emitErr != nil {
|
||||
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if output.UsesUnifiedResult(cmd) && !emitted {
|
||||
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
|
||||
}
|
||||
if closeErr := closeOutputSink(cmd); closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -472,7 +748,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(),
|
||||
newEventCommand(flags),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newUpgradeCommand(),
|
||||
@@ -848,6 +1124,54 @@ func deduplicateCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
type outputSinkState struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
original io.Writer
|
||||
tempPath string
|
||||
target string
|
||||
finished bool
|
||||
}
|
||||
|
||||
type outputPublicationError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *outputPublicationError) Error() string { return e.cause.Error() }
|
||||
func (e *outputPublicationError) Unwrap() error { return e.cause }
|
||||
func (e *outputPublicationError) ExitCode() int { return 5 }
|
||||
|
||||
func newOutputPublicationError(message string, cause error) error {
|
||||
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
|
||||
}
|
||||
|
||||
// emitOutputPublicationFailure replaces a result that was rendered only into a
|
||||
// rolled-back transactional file with one observable failure envelope on the
|
||||
// original output stream. This is not a second public result: closeOutputSink
|
||||
// has removed the temporary file and restored cmd.OutOrStdout before returning
|
||||
// the publication error.
|
||||
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
|
||||
var publicationErr *outputPublicationError
|
||||
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
|
||||
return 0, false, nil
|
||||
}
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
original := state.original
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if original == nil || !finished {
|
||||
return 0, false, nil
|
||||
}
|
||||
cmd.SetOut(original)
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
|
||||
code, emitErr = output.EmitResult(cmd, result)
|
||||
return code, true, emitErr
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -860,32 +1184,180 @@ func configureOutputSink(cmd *cobra.Command) error {
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
// A public root may be reused across ExecuteC calls, accumulating one Run
|
||||
// wrapper per execution. When the sink for this invocation is already open,
|
||||
// an inner wrapper must not replace it with a second temporary file.
|
||||
if state := outputSinkForCommand(cmd); state != nil {
|
||||
state.mu.Lock()
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if !finished {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||
}
|
||||
file, err := rootCreateFile(outputPath)
|
||||
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
|
||||
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
|
||||
}
|
||||
originalOut := cmd.OutOrStdout()
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file,
|
||||
original: originalOut,
|
||||
tempPath: file.Name(),
|
||||
target: outputPath,
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
// installOutputSinkRunBoundary defers opening the transactional --output sink
|
||||
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
|
||||
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
|
||||
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
|
||||
// still normalize alias flags into required canonical flags, and a validation
|
||||
// failure can never strand a temporary output file. Run-only leaves are
|
||||
// converted to RunE so a sink setup failure remains a returned error. Post-run
|
||||
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
|
||||
// transaction; pre-run hooks need no wrapping because the sink cannot exist
|
||||
// before Run entry.
|
||||
func installOutputSinkRunBoundary(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.RunE != nil {
|
||||
cmd.RunE = openSinkAndRun(cmd.RunE)
|
||||
} else if cmd.Run != nil {
|
||||
original := cmd.Run
|
||||
cmd.Run = nil
|
||||
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if cmd.PostRunE != nil {
|
||||
original := cmd.PostRunE
|
||||
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.PostRun != nil {
|
||||
original := cmd.PostRun
|
||||
cmd.PostRun = func(cmd *cobra.Command, args []string) {
|
||||
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
return run()
|
||||
}
|
||||
|
||||
func warnAbortOutputSink(cmd *cobra.Command) {
|
||||
if closeErr := abortOutputSink(cmd); closeErr != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func closeOutputSink(cmd *cobra.Command) error {
|
||||
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
|
||||
if !ok || file == nil {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if err := rootCloseFile(file); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
// A reusable Cobra tree must never retain the transactional file as its
|
||||
// stdout after this execution. Restore the caller's writer on every terminal
|
||||
// path, including sync/close/rename failures and repeated cleanup calls.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
if err := rootSyncFile(state.file); err != nil {
|
||||
_ = rootCloseFile(state.file)
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to sync output file", err)
|
||||
}
|
||||
if err := rootCloseFile(state.file); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to close output file", err)
|
||||
}
|
||||
if err := rootRenameFile(state.tempPath, state.target); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to publish output file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abortOutputSink(cmd *cobra.Command) error {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
// A business error still needs the root execution boundary to publish one
|
||||
// typed failure envelope. Restore the pre-transaction writer before closing
|
||||
// and unlinking the temporary file so that failure emission cannot target a
|
||||
// closed descriptor. The final --output target remains untouched.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
closeErr := rootCloseFile(state.file)
|
||||
removeErr := rootRemoveFile(state.tempPath)
|
||||
if closeErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
|
||||
}
|
||||
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
|
||||
if cmd == nil || cmd.Context() == nil {
|
||||
return nil
|
||||
}
|
||||
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
|
||||
if state == nil || state.file == nil {
|
||||
return nil
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func validateOptionalPath(flagName, path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
})
|
||||
|
||||
oldMkdir := rootMkdirAll
|
||||
oldCreate := rootCreateFile
|
||||
oldCreate := rootCreateTemp
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() {
|
||||
rootMkdirAll = oldMkdir
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
rootCloseFile = oldClose
|
||||
})
|
||||
wantErr := errors.New("filesystem")
|
||||
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
t.Fatal("mkdir failure succeeded")
|
||||
}
|
||||
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
||||
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
|
||||
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
||||
t.Fatal("create failure succeeded")
|
||||
}
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "close"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
|
||||
}))
|
||||
rootCloseFile = func(*os.File) error { return wantErr }
|
||||
if err := closeOutputSink(cmd); err == nil {
|
||||
t.Fatal("close failure succeeded")
|
||||
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
|
||||
}))
|
||||
if err := closeOutputSink(cmd); err != nil {
|
||||
t.Fatalf("close success = %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
|
||||
executed, err := root.ExecuteC()
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteC run %d: %v", want, err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Run int `json:"run"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
|
||||
}
|
||||
if !envelope.OK || envelope.Data.Run != want {
|
||||
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
|
||||
}
|
||||
}
|
||||
|
||||
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
|
||||
root.AddCommand(missing)
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-output-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
target := filepath.Join(t.TempDir(), "result.json")
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("first ExecuteC: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(target)
|
||||
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
|
||||
t.Fatalf("published output=%q err=%v", first, err)
|
||||
}
|
||||
|
||||
if err := root.PersistentFlags().Set("output", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("second ExecuteC: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"run": 2`) {
|
||||
t.Fatalf("second stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("late close diagnostic")
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil || apperrors.ExitCode(err) != 5 {
|
||||
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("executed=%v, want lifecycle leaf", executed)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("legacy close failed")
|
||||
}
|
||||
|
||||
root := NewRootCommandWithEngine(context.Background(), nil)
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
|
||||
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
|
||||
return errors.New("handler failed")
|
||||
}})
|
||||
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
|
||||
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
|
||||
if err := output.StoreResult(cmd.Context(), result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic("after emission")
|
||||
}
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
|
||||
}
|
||||
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
|
||||
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
|
||||
t.Fatalf("panic diagnostic missing: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
|
||||
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
|
||||
info := errorInfoFromExecutionError(err)
|
||||
if info.TraceID != "trace-1" || info.RequestID != "" {
|
||||
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempMode os.FileMode
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
info, err := cmd.OutOrStdout().(*os.File).Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempMode = info.Mode().Perm()
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC: %v", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "replacement", 0o600)
|
||||
if tempMode != 0o600 {
|
||||
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
return errors.New("handler failed")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
|
||||
target := os.Args[3]
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-failure",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
return apperrors.NewValidation("business validation failed")
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
|
||||
t.Fatalf("failure envelope=%+v", envelope)
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
panic("boom")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if recovered := executeAndRecover(root); recovered == nil {
|
||||
t.Fatal("ExecuteC did not panic")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
|
||||
t.Fatalf("ExecuteC error=%v, want publication failure", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seam func(*testing.T)
|
||||
}{
|
||||
{
|
||||
name: "sync",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("close failed")
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.seam(t)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-output-unified", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("unified ExecuteC succeeded without publishing its output")
|
||||
} else if code := apperrors.ExitCode(err); code != 5 {
|
||||
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final output exists after publication failure: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-publication",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
|
||||
t.Fatalf("publication failure envelope=%+v", envelope)
|
||||
}
|
||||
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
|
||||
t.Fatalf("publication failure message=%q", envelope.Error.Message)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-emission-failure",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.OutOrStdout().(*os.File).Close()
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded after emission failure")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().String("required", "", "")
|
||||
_ = leaf.MarkFlagRequired("required")
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-validation", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded without required flag")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
|
||||
root := NewRootCommand()
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "atomic-output",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return run(cmd)
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
func executeAndRecover(cmd *cobra.Command) (recovered any) {
|
||||
defer func() { recovered = recover() }()
|
||||
_, _ = cmd.ExecuteC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("output=%q, want %q", data, want)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat output: %v", err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != wantMode {
|
||||
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoOutputTemps(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary output files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
|
||||
// regression for the alias normalization order: root's persistent pre-run must
|
||||
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
|
||||
// chat message download-media copies --msg-id / --open-message-id into the
|
||||
// required --message-id flag in its PreRunE; validating early failed that
|
||||
// documented alias path with "missing required flag(s): --message-id".
|
||||
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
|
||||
for _, alias := range []string{"msg-id", "open-message-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
target := filepath.Join(t.TempDir(), "download.bin")
|
||||
root.SetArgs([]string{
|
||||
"chat", "message", "download-media",
|
||||
"--type", "mediaId",
|
||||
"--resource-id", "media-1",
|
||||
"--" + alias, "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
"--output", target,
|
||||
"--dry-run", "--format", "json",
|
||||
})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
executed := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
|
||||
executed.SetOut(&stdout)
|
||||
executed.SetErr(&stderr)
|
||||
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
|
||||
executed.SetContext(root.Context())
|
||||
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(executed); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
return executed, nil
|
||||
}
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
|
||||
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
|
||||
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
|
||||
// writer 不得收到任何字节。
|
||||
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
|
||||
// 失败走 stderr(PrintHuman),stdout 为空。
|
||||
func TestRootHumanErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
_ = root.PersistentFlags().Set("format", "table")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Error:") {
|
||||
t.Fatalf("expected human error on stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
|
||||
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
|
||||
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
|
||||
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// ok / pending(信封 success/pending 语义)→ 0
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("success Execute code = %d, want 0", code)
|
||||
}
|
||||
|
||||
// An error cannot carry partial succeeded/failed data and fails closed.
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("partial error Execute code = %d, want 5", code)
|
||||
}
|
||||
|
||||
// confirmation_required(validation 子类)→ 3
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("confirmation Execute code = %d, want 3", code)
|
||||
}
|
||||
|
||||
// plain internal → 5
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, errors.New("plain")
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("plain Execute code = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
|
||||
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
|
||||
// (StopAllStdioClients)在错误路径也被调用。
|
||||
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
stopped := false
|
||||
rootStopAllStdioClients = func() { stopped = true }
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewInternal("fail")
|
||||
}
|
||||
_ = Execute()
|
||||
if !stopped {
|
||||
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
|
||||
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
|
||||
func TestRootSilenceErrorsFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
if !root.SilenceErrors || !root.SilenceUsage {
|
||||
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,32 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
if !containsSchemaPath(report.Covered, "chat category create-smart") {
|
||||
t.Fatal("chat category create-smart is not covered by runtime Schema")
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if !containsSchemaPath(report.Covered, path) {
|
||||
t.Fatalf("%s is not covered by runtime Schema", path)
|
||||
}
|
||||
}
|
||||
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
|
||||
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excluded := map[string]bool{}
|
||||
for _, exclusion := range exclusions {
|
||||
excluded[exclusion.CLIPath] = true
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if excluded[path] {
|
||||
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSchemaPath(paths []string, want string) bool {
|
||||
for _, path := range paths {
|
||||
if path == want {
|
||||
@@ -38,3 +59,38 @@ func containsSchemaPath(paths []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingChatCatalogCoveragePaths() []string {
|
||||
return []string{
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 357
|
||||
publicShortcutCount = 378
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 358
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 379
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 357
|
||||
publiclyDeliveredShortcutCount = 378
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,12 +114,14 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 187; got != want {
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
shortcutCount := 0
|
||||
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
|
||||
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
|
||||
shortcutCount++
|
||||
}
|
||||
@@ -127,6 +129,135 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
for _, cliPath := range missingChatCatalogCoveragePaths() {
|
||||
if summaryByCLIPath[cliPath] == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
}
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
cliPath string,
|
||||
effect string,
|
||||
risk string,
|
||||
confirmation string,
|
||||
) {
|
||||
t.Helper()
|
||||
summary := summaries[cliPath]
|
||||
if summary == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
if got := schemaContractString(summary["effect"]); got != effect {
|
||||
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
|
||||
}
|
||||
if got := schemaContractString(summary["risk"]); got != risk {
|
||||
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
|
||||
}
|
||||
if got := schemaContractString(summary["confirmation"]); got != confirmation {
|
||||
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
t.Helper()
|
||||
for _, cliPath := range []string{
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat hide",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
} {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
|
||||
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
|
||||
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
|
||||
})
|
||||
}
|
||||
|
||||
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
|
||||
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
|
||||
|
||||
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
|
||||
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, chmod, "chat chmod")
|
||||
|
||||
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
|
||||
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
|
||||
"require_one_of": [][]string{{"target-org-id", "all"}},
|
||||
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
|
||||
|
||||
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
|
||||
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
|
||||
"require_one_of": [][]string{{"target", "receiver"}},
|
||||
"mutually_exclusive": [][]string{{"target", "receiver"}},
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got, _ := parameter["required"].(bool); got != want {
|
||||
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
|
||||
t.Helper()
|
||||
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
grantType := parameters["grant-type"]
|
||||
if grantType == nil {
|
||||
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
|
||||
}
|
||||
wantEnum := []string{"once", "session", "timed", "permanent"}
|
||||
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
|
||||
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
|
||||
}
|
||||
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
|
||||
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
|
||||
}
|
||||
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
|
||||
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
var rootEscalateSignal = func(sig os.Signal) {
|
||||
signal.Reset(sig)
|
||||
redeliverProcessSignal(sig)
|
||||
}
|
||||
|
||||
var (
|
||||
rootFindProcess = os.FindProcess
|
||||
rootExitProcess = os.Exit
|
||||
)
|
||||
|
||||
// redeliverProcessSignal asks the current process to handle the second signal
|
||||
// with the platform's default semantics. Platforms that cannot deliver the
|
||||
// requested signal through os.Process.Signal fall back to the conventional
|
||||
// CLI exit status instead of leaving the process running after escalation.
|
||||
func redeliverProcessSignal(sig os.Signal) {
|
||||
process, err := rootFindProcess(os.Getpid())
|
||||
if err == nil {
|
||||
err = process.Signal(sig)
|
||||
}
|
||||
if err != nil {
|
||||
rootExitProcess(interruptionExitCode(sig))
|
||||
}
|
||||
}
|
||||
|
||||
func interruptionExitCode(sig os.Signal) int {
|
||||
if sig == syscall.SIGTERM {
|
||||
return 143
|
||||
}
|
||||
return 130
|
||||
}
|
||||
|
||||
type processInterruption struct {
|
||||
signal os.Signal
|
||||
}
|
||||
|
||||
func (e *processInterruption) Error() string {
|
||||
return fmt.Sprintf("process interrupted by %s", e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Unwrap() error { return context.Canceled }
|
||||
|
||||
func (e *processInterruption) ExitCode() int {
|
||||
return interruptionExitCode(e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Subtype() string {
|
||||
if e.signal == syscall.SIGTERM {
|
||||
return "terminated"
|
||||
}
|
||||
return "cancelled_by_user"
|
||||
}
|
||||
|
||||
type processSignalState struct {
|
||||
mu sync.Mutex
|
||||
interruption *processInterruption
|
||||
primaryCompletedAtSignal bool
|
||||
}
|
||||
|
||||
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.interruption != nil {
|
||||
return false
|
||||
}
|
||||
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
|
||||
s.interruption = &processInterruption{signal: sig}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *processSignalState) outcome() (*processInterruption, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.interruption, s.primaryCompletedAtSignal
|
||||
}
|
||||
|
||||
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
|
||||
}
|
||||
|
||||
func manageProcessSignals(
|
||||
parent context.Context,
|
||||
store *output.ResultStore,
|
||||
signals <-chan os.Signal,
|
||||
stopNotify func(),
|
||||
escalate func(os.Signal),
|
||||
) (context.Context, *processSignalState, func()) {
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
state := &processSignalState{}
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
for {
|
||||
select {
|
||||
case sig := <-signals:
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
if state.record(sig, store) {
|
||||
cancel(state.interruption)
|
||||
continue
|
||||
}
|
||||
escalate(sig)
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
stopNotify()
|
||||
close(done)
|
||||
<-stopped
|
||||
cancel(context.Canceled)
|
||||
})
|
||||
}
|
||||
return ctx, state, stop
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func signalSelf(t *testing.T, sig syscall.Signal) {
|
||||
t.Helper()
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("find current process: %v", err)
|
||||
}
|
||||
if err := process.Signal(sig); err != nil {
|
||||
t.Skipf("current platform does not support process signal delivery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
|
||||
originalFind, originalExit := rootFindProcess, rootExitProcess
|
||||
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
|
||||
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
|
||||
exitCode := 0
|
||||
rootExitProcess = func(code int) { exitCode = code }
|
||||
rootEscalateSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("escalation exit=%d", exitCode)
|
||||
}
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("fallback exit=%d", exitCode)
|
||||
}
|
||||
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGINT)
|
||||
if exitCode != 130 {
|
||||
t.Fatalf("signal fallback exit=%d", exitCode)
|
||||
}
|
||||
interrupted := &processInterruption{signal: syscall.SIGINT}
|
||||
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
|
||||
t.Fatalf("interruption=%v", interrupted)
|
||||
}
|
||||
terminated := &processInterruption{signal: syscall.SIGTERM}
|
||||
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
|
||||
t.Fatalf("termination=%v", terminated)
|
||||
}
|
||||
state := &processSignalState{}
|
||||
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
|
||||
t.Fatal("signal state did not reject a second interruption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
|
||||
signals := make(chan os.Signal, 3)
|
||||
stopped, escalated := false, make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
|
||||
signals <- nil
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated=%v", got)
|
||||
}
|
||||
stop()
|
||||
stop()
|
||||
if !stopped {
|
||||
t.Fatal("signal notification was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
if unified {
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
}
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, &stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
|
||||
}
|
||||
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
|
||||
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
signal syscall.Signal
|
||||
code int
|
||||
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
|
||||
t.Run(tc.signal.String(), func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
return cmd, context.DeadlineExceeded
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
|
||||
t.Fatalf("deadline error=%+v", env.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(failingWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want publication failure code 5", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("second envelope emitted: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
// Record cancellation before publication begins, then simulate a command
|
||||
// hook that has already committed its result and completes publication.
|
||||
// The wire result must remain authoritative over the earlier signal.
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want published success code 0", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if !env.OK || env.Outcome != output.OutcomeSuccess {
|
||||
t.Fatalf("published envelope=%+v, want successful outcome", env)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want established success code 0", code)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
|
||||
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
|
||||
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "READY")
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
os.Exit(Execute())
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
|
||||
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
|
||||
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
|
||||
}
|
||||
if err := cmd.Process.Signal(tc.signal); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
|
||||
}
|
||||
payload, readErr := io.ReadAll(stdout)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
|
||||
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
t.Fatalf("decode helper output: %v; output=%q", err, payload)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("helper error=%+v", env.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
escalated := make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
|
||||
escalated <- sig
|
||||
})
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
if !errors.Is(context.Cause(ctx), context.Canceled) {
|
||||
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
|
||||
}
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated %v, want SIGTERM", got)
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
@@ -44,6 +44,10 @@ import (
|
||||
// test binary never launches a page on the developer's machine; tests that
|
||||
// need to assert the URL can still replace openBrowserFunc locally.
|
||||
func TestMain(m *testing.M) {
|
||||
if code, ok := runRuntimeTokenDetachedE2EChild(); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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 auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataUsesSelectorGrammarAndReturnsCopy(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: 3,
|
||||
Profiles: []Profile{
|
||||
{Name: "Historical", CorpID: "corp-1", CorpName: "Example Org"},
|
||||
{Name: "Exact", CorpID: "corp-1", CorpName: "Example Org", UserID: "user-1", UserName: "Example User", ClientID: "client-1"},
|
||||
},
|
||||
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
|
||||
}
|
||||
|
||||
for _, selector := range []string{"corp-1", "Example Org", "corp-1:Example User", "Example Org:Example User"} {
|
||||
profile, err := ResolveProfileMetadata(cfg, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileMetadata(%q) error = %v", selector, err)
|
||||
}
|
||||
if profile == nil || profile.UserID != "user-1" || profile.ClientID != "client-1" {
|
||||
t.Fatalf("ResolveProfileMetadata(%q) = %#v", selector, profile)
|
||||
}
|
||||
}
|
||||
|
||||
profile, err := ResolveProfileMetadata(cfg, "corp-1:user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileMetadata(exact) error = %v", err)
|
||||
}
|
||||
profile.Name = "mutated copy"
|
||||
if cfg.Profiles[1].Name != "Exact" {
|
||||
t.Fatalf("ResolveProfileMetadata returned registry-owned pointer")
|
||||
}
|
||||
if _, err := ResolveProfileMetadata(cfg, "missing"); err == nil {
|
||||
t.Fatal("ResolveProfileMetadata(missing) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
@@ -738,6 +738,20 @@ func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error)
|
||||
return result, exact, err
|
||||
}
|
||||
|
||||
// ResolveProfileMetadata applies the public profile-selector grammar to an
|
||||
// already-loaded, non-sensitive profiles registry. It performs no migration,
|
||||
// keychain access, token loading, or persistence, making it suitable for
|
||||
// callers that carry an externally managed bearer credential and need only
|
||||
// corp/user/client identity metadata.
|
||||
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
|
||||
profile, _, err := resolveProfileSelection("", cfg, selector)
|
||||
if err != nil || profile == nil {
|
||||
return nil, err
|
||||
}
|
||||
copy := *profile
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
|
||||
if err := profilesEnsureMigration(configDir); err != nil {
|
||||
return nil, false, err
|
||||
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,11 +22,13 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("mode", "", "usage")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
|
||||
@@ -35,6 +38,10 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
Effect: "write", Confirmation: "user_required", Idempotency: "none",
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string","description":"Created object ID"}}}`),
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "from contract",
|
||||
UseWhen: []string{"create things"},
|
||||
@@ -68,6 +75,9 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("dry_run = %#v", spec.DryRun)
|
||||
}
|
||||
if spec.Result == nil || string(spec.Result.DataSchema) != `{"properties":{"id":{"type":"string","description":"Created object ID"}},"type":"object"}` {
|
||||
t.Fatalf("result = %#v", spec.Result)
|
||||
}
|
||||
if spec.Selection.AgentSummary != "from contract" {
|
||||
t.Fatalf("selection = %#v", spec.Selection)
|
||||
}
|
||||
@@ -79,6 +89,39 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
|
||||
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
output.SetCommandRollout(cmd, state)
|
||||
cmd.Flags().String("cursor", "", "cursor")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "cursor", "cursor", "string", false)
|
||||
final := contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "list_things", CanonicalPath: "dev.list_things",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list",
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev", ToolName: "list_things", CLIName: "list",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list", ProductName: "Dev", Command: cmd,
|
||||
}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Result != nil || spec.Pagination != nil {
|
||||
t.Fatalf("inactive rollout published result=%#v pagination=%#v", spec.Result, spec.Pagination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
|
||||
@@ -1059,6 +1059,7 @@ var schemaCompactPayloadKeys = map[string]bool{
|
||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"result": true, "pagination": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
@@ -1074,8 +1075,8 @@ var schemaCompactParamKeys = map[string]bool{
|
||||
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
// recursively; result, constraint, positional and dry-run values are already
|
||||
// typed contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
|
||||
@@ -78,7 +78,9 @@ var schemaCatalogToolOptionalKeys = []string{
|
||||
"interface_reason",
|
||||
"interface_ref",
|
||||
"metadata_source",
|
||||
"pagination",
|
||||
"positionals",
|
||||
"result",
|
||||
}
|
||||
|
||||
var schemaCatalogToolEnums = map[string][]string{
|
||||
@@ -237,6 +239,37 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
|
||||
}
|
||||
|
||||
validateCatalogInterface(toolID, entry, violations)
|
||||
if result, exists := entry["result"]; exists {
|
||||
if _, ok := result.(map[string]any); !ok {
|
||||
report("field %q must be an object", "result")
|
||||
}
|
||||
}
|
||||
if rawPagination, exists := entry["pagination"]; exists {
|
||||
pagination, ok := rawPagination.(map[string]any)
|
||||
if !ok {
|
||||
report("field %q must be an object", "pagination")
|
||||
} else {
|
||||
want := map[string]string{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
for field, expected := range want {
|
||||
if value, _ := pagination[field].(string); value != expected {
|
||||
report("field %q.%s = %q, want %q", "pagination", field, value, expected)
|
||||
}
|
||||
}
|
||||
cursor, _ := pagination["cursor_parameter"].(string)
|
||||
if strings.TrimSpace(cursor) == "" {
|
||||
report("field %q.cursor_parameter must be a non-empty string", "pagination")
|
||||
} else if paramsOK {
|
||||
if _, exists := parameters[cursor]; !exists {
|
||||
report("field %q.cursor_parameter references missing parameter %q", "pagination", cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for paramName, raw := range parameters {
|
||||
param, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// TestDeliverySchemaCatalogStructure gates the delivered catalog: every tool
|
||||
@@ -100,6 +102,90 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
|
||||
entry := validCatalogToolEntry()
|
||||
entry["result"] = map[string]any{
|
||||
"outcomes": []any{"success", "failure"},
|
||||
"data_schema": map[string]any{"type": "object"},
|
||||
}
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v", err)
|
||||
}
|
||||
entry["result"] = "invalid"
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), `field "result" must be an object`) {
|
||||
t.Fatalf("invalid result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureAcceptsStandalonePagination(t *testing.T) {
|
||||
entry := validCatalogToolEntry()
|
||||
parameters := entry["parameters"].(map[string]any)
|
||||
parameters["cursor"] = map[string]any{
|
||||
"description": "续页游标",
|
||||
"field_provenance": map[string]any{},
|
||||
"required": false,
|
||||
"type": "string",
|
||||
}
|
||||
entry["parameter_count"] = float64(len(parameters))
|
||||
entry["has_parameters"] = true
|
||||
entry["pagination"] = map[string]any{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v", err)
|
||||
}
|
||||
|
||||
entry["pagination"].(map[string]any)["next_token_path"] = "data.nextCursor"
|
||||
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), "next_token_path") {
|
||||
t.Fatalf("invalid pagination error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogStructureRejectsMalformedStandalonePagination(t *testing.T) {
|
||||
validPaginationEntry := func() map[string]any {
|
||||
entry := validCatalogToolEntry()
|
||||
parameters := entry["parameters"].(map[string]any)
|
||||
parameters["cursor"] = map[string]any{
|
||||
"description": "续页游标",
|
||||
"field_provenance": map[string]any{},
|
||||
"required": false,
|
||||
"type": "string",
|
||||
}
|
||||
entry["parameter_count"] = float64(len(parameters))
|
||||
entry["pagination"] = map[string]any{
|
||||
"kind": contract.PaginationKindCursor,
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": contract.PaginationMetaPath,
|
||||
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
||||
"next_token_path": contract.PaginationNextTokenPath,
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
want string
|
||||
}{
|
||||
{"not an object", func(entry map[string]any) { entry["pagination"] = "cursor" }, `field "pagination" must be an object`},
|
||||
{"empty cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = " " }, "cursor_parameter must be a non-empty string"},
|
||||
{"unknown cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = "page-token" }, "references missing parameter"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := validPaginationEntry()
|
||||
tc.mutate(entry)
|
||||
err := ValidateCatalogStructure(catalogPayload(t, entry))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCatalogStructureRejectsViolations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -33,8 +33,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"auth status",
|
||||
"completion",
|
||||
"config list",
|
||||
"dev connect list",
|
||||
"dev connect restart",
|
||||
"doctor",
|
||||
"plugin build",
|
||||
"plugin config get",
|
||||
@@ -93,40 +91,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"calendar acl add",
|
||||
"calendar acl delete",
|
||||
"calendar book update",
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
"contact label get",
|
||||
"contact label list",
|
||||
"contact label list-members",
|
||||
"dev app version check-approval",
|
||||
"ding message list",
|
||||
"ding message recall-personal",
|
||||
"ding message receiver-status",
|
||||
|
||||
@@ -38,20 +38,33 @@ func init() {
|
||||
registerRequireTogether("calendar.update_calendar_event", "recurrence-type", "recurrence-interval", "recurrence-range-type")
|
||||
registerExclusiveOneOf("chat.search_messages_by_sender", "sender-user-id", "sender-open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
|
||||
RegisterRuntimeSchemaConstraints("chat.chat_permission_grant", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
registerExclusiveOneOf("chat.share_group_invite_url", "target", "receiver")
|
||||
registerExclusiveOneOf("chat.transfer_group_owner", "new-owner", "user")
|
||||
registerRequireOneOf("chat.update_conv_member_roles", "users", "user")
|
||||
registerExclusiveOneOf("chat.update_at_all_notification_off", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_notification_off", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.update_red_env_notification_off", "conversation-id", "id", "chat")
|
||||
registerRequireTogether("contact.query_dismission_employee_list", "start", "end")
|
||||
registerRequireOneOf("dev.connect_status", "robot-client-id", "unified-app-id")
|
||||
registerRequireOneOf("dev.connect_stop", "robot-client-id", "unified-app-id")
|
||||
|
||||
@@ -60,6 +60,8 @@ type ToolSpec struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
Interface contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
@@ -133,6 +135,8 @@ type RuntimeToolSpecInput struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
Interface contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
@@ -538,6 +542,20 @@ func (t ToolSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Result != nil {
|
||||
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !seen[pagination.CursorParameter] {
|
||||
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
|
||||
}
|
||||
}
|
||||
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
|
||||
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
|
||||
return err
|
||||
@@ -726,6 +744,18 @@ func (t ToolSpec) normalized() ToolSpec {
|
||||
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
|
||||
out.DryRun = &dryRun
|
||||
}
|
||||
if t.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
|
||||
if err == nil {
|
||||
out.Result = result
|
||||
}
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
|
||||
if err == nil {
|
||||
out.Pagination = pagination
|
||||
}
|
||||
}
|
||||
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
|
||||
sort.Slice(out.Positionals, func(i, j int) bool {
|
||||
if out.Positionals[i].Index != out.Positionals[j].Index {
|
||||
@@ -952,6 +982,14 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
|
||||
value, _ := typedJSONValue(t.DryRun)
|
||||
payload["dry_run"] = value
|
||||
}
|
||||
if t.Result != nil {
|
||||
value, _ := typedJSONValue(t.Result)
|
||||
payload["result"] = value
|
||||
}
|
||||
if t.Pagination != nil {
|
||||
value, _ := typedJSONValue(t.Pagination)
|
||||
payload["pagination"] = value
|
||||
}
|
||||
applySafetyPayload(payload, t.Safety)
|
||||
applyInterfacePayload(payload, t.Interface)
|
||||
applySelectionPayload(payload, t.Selection, true)
|
||||
@@ -975,7 +1013,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
|
||||
}
|
||||
for _, key := range []string{
|
||||
"parameters", "has_parameters", "parameter_count", "constraints",
|
||||
"positionals", "examples", "effect_source", "agent_source_refs",
|
||||
"positionals", "result", "examples", "effect_source", "agent_source_refs",
|
||||
"field_provenance", "path", "source", "product_id", "display", "is_alias",
|
||||
} {
|
||||
delete(payload, key)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestResultContractModelWireRoundTripAndCompactPolicy(t *testing.T) {
|
||||
result := &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{ "type":"object", "properties":{"items":{"type":"array","description":"Business result records","items":{"type":"object"}}} }`),
|
||||
SensitivePaths: []string{"items.secret", "credential"},
|
||||
}
|
||||
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"},
|
||||
Parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
|
||||
Result: result,
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
|
||||
}
|
||||
if got, want := spec.Result.Outcomes, []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
result.Outcomes[0] = contract.ResultOutcomePending
|
||||
result.DataSchema[0] = '['
|
||||
if spec.Result.Outcomes[0] != contract.ResultOutcomeSuccess || spec.Result.DataSchema[0] != '{' {
|
||||
t.Fatal("ToolSpec result aliases runtime input")
|
||||
}
|
||||
|
||||
payload, err := spec.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPayload() error = %v", err)
|
||||
}
|
||||
resultPayload, ok := payload["result"].(map[string]any)
|
||||
if !ok || schemaString(resultPayload["data_schema"].(map[string]any)["type"]) != "object" {
|
||||
t.Fatalf("result payload = %#v", payload["result"])
|
||||
}
|
||||
if _, exists := specResultSummary(t, spec)["result"]; exists {
|
||||
t.Fatal("result must remain full-leaf-only")
|
||||
}
|
||||
compactResult, exists := stripSchemaPayloadCompact(payload)["result"].(map[string]any)
|
||||
if !exists {
|
||||
t.Fatal("compact leaf must include the reviewed result contract")
|
||||
}
|
||||
if outcomes, ok := compactResult["outcomes"].([]any); !ok || len(outcomes) != 2 {
|
||||
t.Fatalf("compact result outcomes = %#v", compactResult["outcomes"])
|
||||
}
|
||||
if dataSchema, ok := compactResult["data_schema"].(map[string]any); !ok || schemaString(dataSchema["type"]) != "object" {
|
||||
t.Fatalf("compact result data_schema = %#v", compactResult["data_schema"])
|
||||
}
|
||||
if !reflect.DeepEqual(compactResult, resultPayload) {
|
||||
t.Fatalf("compact result must equal full-leaf result\ncompact: %#v\nfull: %#v", compactResult, resultPayload)
|
||||
}
|
||||
compactPagination, exists := stripSchemaPayloadCompact(payload)["pagination"].(map[string]any)
|
||||
if !exists || schemaString(compactPagination["meta_path"]) != contract.PaginationMetaPath || schemaString(compactPagination["cursor_parameter"]) != "cursor" {
|
||||
t.Fatalf("compact pagination = %#v", compactPagination)
|
||||
}
|
||||
|
||||
wire, err := schemaToolWireFromPayload(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
|
||||
}
|
||||
roundTrip, err := schemaToolSpecFromWire(wire)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaToolSpecFromWire() error = %v", err)
|
||||
}
|
||||
roundTripPayload, err := roundTrip.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("round-trip ToPayload() error = %v", err)
|
||||
}
|
||||
if !schemaJSONEqual(payload, roundTripPayload) {
|
||||
t.Fatalf("result changed across wire round-trip\nfirst: %#v\nround: %#v", payload["result"], roundTripPayload["result"])
|
||||
}
|
||||
}
|
||||
|
||||
func specResultSummary(t *testing.T, spec ToolSpec) map[string]any {
|
||||
t.Helper()
|
||||
payload, err := spec.ToSummaryPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToSummaryPayload() error = %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestToolWithoutResultKeepsResultAbsent(t *testing.T) {
|
||||
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "legacy", CLIName: "legacy", CLIPath: "dev legacy"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := spec.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := payload["result"]; exists {
|
||||
t.Fatal("tool without Result gained a result key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSpecRejectsInvalidResultInsteadOfDroppingIt(t *testing.T) {
|
||||
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "invalid", CLIName: "invalid", CLIPath: "dev invalid"},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`[]`),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("invalid result schema was silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSpecRejectsInvalidOrUndeclaredPaginationCursor(t *testing.T) {
|
||||
identity := contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
parameters []ParameterSpec
|
||||
pagination *contract.PaginationSpec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid pagination declaration",
|
||||
parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
|
||||
pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
|
||||
want: "unsupported kind",
|
||||
},
|
||||
{
|
||||
name: "cursor is not a parameter",
|
||||
pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
want: "is not a declared parameter",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: identity, Parameters: tc.parameters, Pagination: tc.pagination,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ToolSpecFromRuntime() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -334,6 +335,15 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
|
||||
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
|
||||
|
||||
result, pagination := final.Result, final.Pagination
|
||||
if !output.UsesUnifiedResult(entry.Command) {
|
||||
// ResultSpec describes the unified envelope data value and PaginationSpec
|
||||
// describes meta.pagination. Keep both declarations internal while a
|
||||
// command still emits legacy bytes or only shadow-validates the new
|
||||
// contract; publishing them early makes Schema disagree with runtime.
|
||||
result, pagination = nil, nil
|
||||
}
|
||||
|
||||
return ToolSpecFromRuntime(RuntimeToolSpecInput{
|
||||
Identity: identity,
|
||||
Display: entry.ProductName,
|
||||
@@ -344,6 +354,8 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
Constraints: constraints,
|
||||
Positionals: positionals,
|
||||
DryRun: final.DryRun,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Safety: safety,
|
||||
Interface: interfaceSpec,
|
||||
Selection: selection,
|
||||
|
||||
@@ -65,6 +65,8 @@ type schemaToolWire struct {
|
||||
Constraints RuntimeSchemaConstraints `json:"constraints"`
|
||||
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
|
||||
DryRun *contract.DryRunSpec `json:"dry_run"`
|
||||
Result *contract.ResultSpec `json:"result"`
|
||||
Pagination *contract.PaginationSpec `json:"pagination"`
|
||||
Effect string `json:"effect"`
|
||||
EffectSource string `json:"effect_source"`
|
||||
Risk string `json:"risk"`
|
||||
@@ -267,6 +269,8 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
|
||||
Constraints: wire.Constraints,
|
||||
Positionals: wire.Positionals,
|
||||
DryRun: wire.DryRun,
|
||||
Result: wire.Result,
|
||||
Pagination: wire.Pagination,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: wire.Effect,
|
||||
EffectSource: wire.EffectSource,
|
||||
|
||||
@@ -30,6 +30,8 @@ type ContractFinalPayload struct {
|
||||
Parameters []ParamDecl
|
||||
Safety *SafetySpec
|
||||
DryRun *DryRunSpec
|
||||
Result *ResultSpec
|
||||
Pagination *PaginationSpec
|
||||
Interface *InterfaceSpec
|
||||
Selection *SelectionSpec
|
||||
Identity *ToolIdentitySpec
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeResultSpecCanonicalizesAndCopies(t *testing.T) {
|
||||
in := &ResultSpec{
|
||||
Outcomes: []ResultOutcome{ResultOutcomeFailure, ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{ "properties": {"items":{"type":"array","description":"Result records","items":{"type":"object"}}}, "type":"object" }`),
|
||||
SensitivePaths: []string{"items.secret", "credential"},
|
||||
}
|
||||
|
||||
got, err := NormalizeResultSpec(in, "dev.list")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeResultSpec() error = %v", err)
|
||||
}
|
||||
if want := []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeFailure}; !reflect.DeepEqual(got.Outcomes, want) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", got.Outcomes, want)
|
||||
}
|
||||
if string(got.DataSchema) != `{"properties":{"items":{"type":"array","description":"Result records","items":{"type":"object"}}},"type":"object"}` {
|
||||
t.Fatalf("data_schema = %s", got.DataSchema)
|
||||
}
|
||||
if want := []string{"credential", "items.secret"}; !reflect.DeepEqual(got.SensitivePaths, want) {
|
||||
t.Fatalf("sensitive_paths = %#v, want %#v", got.SensitivePaths, want)
|
||||
}
|
||||
|
||||
in.Outcomes[0] = ResultOutcomePending
|
||||
in.DataSchema[0] = '['
|
||||
in.SensitivePaths[0] = "changed"
|
||||
if got.Outcomes[0] != ResultOutcomeSuccess || got.DataSchema[0] != '{' || got.SensitivePaths[0] != "credential" {
|
||||
t.Fatalf("normalized result aliases input: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeResultSpecRejectsInvalidContractsDeterministically(t *testing.T) {
|
||||
valid := func() *ResultSpec {
|
||||
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*ResultSpec)
|
||||
want string
|
||||
}{
|
||||
{"no outcomes", func(r *ResultSpec) { r.Outcomes = nil }, "no outcomes"},
|
||||
{"unknown outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{"ok"} }, "unknown outcome"},
|
||||
{"duplicate outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeSuccess} }, "duplicate outcome"},
|
||||
{"schema array", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`[]`) }, "data_schema: must be one JSON object"},
|
||||
{"multiple schemas", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`{} {}`) }, "data_schema: must be one JSON object"},
|
||||
{"missing property description", func(r *ResultSpec) {
|
||||
r.DataSchema = json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)
|
||||
}, "properties.id requires description"},
|
||||
{"unsafe path", func(r *ResultSpec) { r.SensitivePaths = []string{"$.token"} }, "unsafe segment"},
|
||||
{"duplicate path", func(r *ResultSpec) { r.SensitivePaths = []string{"token", " token "} }, "duplicate sensitive path"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
spec := valid()
|
||||
test.edit(spec)
|
||||
_, err := NormalizeResultSpec(spec, "dev.test")
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationSpecUsesFrameworkMetaPaths(t *testing.T) {
|
||||
got, err := NormalizePaginationSpec(&PaginationSpec{Kind: PaginationKindCursor, CursorParameter: "--cursor"}, "dev.list")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.CursorParameter != "cursor" || got.MetaPath != PaginationMetaPath || got.EndpointExhaustedPath != PaginationExhaustedPath || got.NextTokenPath != PaginationNextTokenPath {
|
||||
t.Fatalf("pagination = %#v", got)
|
||||
}
|
||||
for _, spec := range []*PaginationSpec{
|
||||
{Kind: "offset", CursorParameter: "cursor"},
|
||||
{Kind: PaginationKindCursor},
|
||||
{Kind: PaginationKindCursor, CursorParameter: "cursor", MetaPath: "data.pagination"},
|
||||
} {
|
||||
if _, err := NormalizePaginationSpec(spec, "dev.list"); err == nil {
|
||||
t.Fatalf("invalid pagination accepted: %#v", spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -59,6 +62,242 @@ type DryRunSpec struct {
|
||||
RemoteReads bool `json:"remote_reads,omitempty"`
|
||||
}
|
||||
|
||||
// ResultOutcome is one closed unified-output envelope outcome.
|
||||
type ResultOutcome string
|
||||
|
||||
const (
|
||||
ResultOutcomeSuccess ResultOutcome = "success"
|
||||
ResultOutcomePending ResultOutcome = "pending"
|
||||
ResultOutcomePartialFailure ResultOutcome = "partial_failure"
|
||||
ResultOutcomeFailure ResultOutcome = "failure"
|
||||
)
|
||||
|
||||
var canonicalResultOutcomes = [...]ResultOutcome{
|
||||
ResultOutcomeSuccess,
|
||||
ResultOutcomePending,
|
||||
ResultOutcomePartialFailure,
|
||||
ResultOutcomeFailure,
|
||||
}
|
||||
|
||||
const (
|
||||
PaginationKindCursor = "cursor"
|
||||
PaginationMetaPath = "meta.pagination"
|
||||
PaginationExhaustedPath = "meta.pagination.endpoint_exhausted"
|
||||
PaginationNextTokenPath = "meta.pagination.next_token"
|
||||
)
|
||||
|
||||
// PaginationSpec is a command-level declaration for framework pagination
|
||||
// metadata. It is deliberately separate from ResultSpec because pagination is
|
||||
// emitted under envelope meta, not inside the business response data.
|
||||
type PaginationSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
CursorParameter string `json:"cursor_parameter"`
|
||||
MetaPath string `json:"meta_path"`
|
||||
EndpointExhaustedPath string `json:"endpoint_exhausted_path"`
|
||||
NextTokenPath string `json:"next_token_path"`
|
||||
}
|
||||
|
||||
// ResultSpec is the reviewed return-value contract for one command and is
|
||||
// projected unchanged into both full-leaf and compact-leaf Schema. Outcomes
|
||||
// and DataSchema are required; Pagination and SensitivePaths are omitted when
|
||||
// absent. DataSchema is a canonical recursive JSON Schema object; every path
|
||||
// is relative to the unified-output envelope data value.
|
||||
type ResultSpec struct {
|
||||
Outcomes []ResultOutcome `json:"outcomes"`
|
||||
DataSchema json.RawMessage `json:"data_schema"`
|
||||
SensitivePaths []string `json:"sensitive_paths,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeResultSpec returns a validated, canonical, defensively copied
|
||||
// result contract. It is shared by declaration, ToolSpec, and snapshot paths.
|
||||
func NormalizeResultSpec(in *ResultSpec, canonical string) (*ResultSpec, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||
out := &ResultSpec{}
|
||||
seenOutcomes := make(map[ResultOutcome]bool, len(in.Outcomes))
|
||||
for _, outcome := range in.Outcomes {
|
||||
outcome = ResultOutcome(strings.TrimSpace(string(outcome)))
|
||||
valid := false
|
||||
for _, allowed := range canonicalResultOutcomes {
|
||||
if outcome == allowed {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("schema tool %s result has unknown outcome %q", canonical, outcome)
|
||||
}
|
||||
if seenOutcomes[outcome] {
|
||||
return nil, fmt.Errorf("schema tool %s result has duplicate outcome %q", canonical, outcome)
|
||||
}
|
||||
seenOutcomes[outcome] = true
|
||||
}
|
||||
if len(seenOutcomes) == 0 {
|
||||
return nil, fmt.Errorf("schema tool %s result has no outcomes", canonical)
|
||||
}
|
||||
for _, outcome := range canonicalResultOutcomes {
|
||||
if seenOutcomes[outcome] {
|
||||
out.Outcomes = append(out.Outcomes, outcome)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
out.DataSchema, err = canonicalJSONObject(in.DataSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result data_schema: %w", canonical, err)
|
||||
}
|
||||
if err := validateResultSchemaDescriptions(out.DataSchema, "data_schema"); err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result %w", canonical, err)
|
||||
}
|
||||
seenPaths := make(map[string]bool, len(in.SensitivePaths))
|
||||
for _, path := range in.SensitivePaths {
|
||||
path = strings.TrimSpace(path)
|
||||
if err := validateResultPath(path); err != nil {
|
||||
return nil, fmt.Errorf("schema tool %s result sensitive path: %w", canonical, err)
|
||||
}
|
||||
if seenPaths[path] {
|
||||
return nil, fmt.Errorf("schema tool %s result has duplicate sensitive path %q", canonical, path)
|
||||
}
|
||||
seenPaths[path] = true
|
||||
out.SensitivePaths = append(out.SensitivePaths, path)
|
||||
}
|
||||
sort.Strings(out.SensitivePaths)
|
||||
if len(out.SensitivePaths) == 0 {
|
||||
out.SensitivePaths = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalizePaginationSpec validates the command-specific input parameter and
|
||||
// fills the framework-owned public meta paths.
|
||||
func NormalizePaginationSpec(in *PaginationSpec, canonical string) (*PaginationSpec, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||
kind := strings.TrimSpace(in.Kind)
|
||||
if kind != PaginationKindCursor {
|
||||
return nil, fmt.Errorf("schema tool %s pagination has unsupported kind %q", canonical, kind)
|
||||
}
|
||||
cursorParameter := strings.TrimSpace(strings.TrimPrefix(in.CursorParameter, "--"))
|
||||
if cursorParameter == "" || strings.Contains(cursorParameter, ".") {
|
||||
return nil, fmt.Errorf("schema tool %s pagination cursor_parameter must name one CLI flag", canonical)
|
||||
}
|
||||
provided := []struct{ name, got, want string }{
|
||||
{"meta_path", strings.TrimSpace(in.MetaPath), PaginationMetaPath},
|
||||
{"endpoint_exhausted_path", strings.TrimSpace(in.EndpointExhaustedPath), PaginationExhaustedPath},
|
||||
{"next_token_path", strings.TrimSpace(in.NextTokenPath), PaginationNextTokenPath},
|
||||
}
|
||||
for _, field := range provided {
|
||||
if field.got != "" && field.got != field.want {
|
||||
return nil, fmt.Errorf("schema tool %s pagination %s is framework-owned and must be %q", canonical, field.name, field.want)
|
||||
}
|
||||
}
|
||||
return &PaginationSpec{
|
||||
Kind: kind,
|
||||
CursorParameter: cursorParameter,
|
||||
MetaPath: PaginationMetaPath,
|
||||
EndpointExhaustedPath: PaginationExhaustedPath,
|
||||
NextTokenPath: PaginationNextTokenPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var object map[string]json.RawMessage
|
||||
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
canonical, _ := json.Marshal(object) // decoded RawMessages are always marshalable
|
||||
return json.RawMessage(canonical), nil
|
||||
}
|
||||
|
||||
// validateResultSchemaDescriptions keeps the Agent-facing return contract
|
||||
// self-explanatory. Every named property needs a description; nested object
|
||||
// properties and array items are checked recursively. The root schema and
|
||||
// anonymous composition branches do not need descriptions because they are
|
||||
// not field names an Agent must interpret.
|
||||
func validateResultSchemaDescriptions(raw json.RawMessage, location string) error {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return fmt.Errorf("%s must be one JSON Schema object", location)
|
||||
}
|
||||
return validateResultSchemaNode(schema, location)
|
||||
}
|
||||
|
||||
func validateResultSchemaNode(schema map[string]any, location string) error {
|
||||
if rawProperties, exists := schema["properties"]; exists {
|
||||
properties, ok := rawProperties.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.properties must be an object", location)
|
||||
}
|
||||
for name, rawProperty := range properties {
|
||||
property, ok := rawProperty.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.properties.%s must be a JSON Schema object", location, name)
|
||||
}
|
||||
description, _ := property["description"].(string)
|
||||
if strings.TrimSpace(description) == "" {
|
||||
return fmt.Errorf("%s.properties.%s requires description", location, name)
|
||||
}
|
||||
if err := validateResultSchemaNode(property, location+".properties."+name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawItems, exists := schema["items"]; exists {
|
||||
items, ok := rawItems.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.items must be a JSON Schema object", location)
|
||||
}
|
||||
if err := validateResultSchemaNode(items, location+".items"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, keyword := range []string{"allOf", "anyOf", "oneOf"} {
|
||||
rawBranches, exists := schema[keyword]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
branches, ok := rawBranches.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s must be an array", location, keyword)
|
||||
}
|
||||
for index, rawBranch := range branches {
|
||||
branch, ok := rawBranch.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s[%d] must be a JSON Schema object", location, keyword, index)
|
||||
}
|
||||
if err := validateResultSchemaNode(branch, fmt.Sprintf("%s.%s[%d]", location, keyword, index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateResultPath(path string) error {
|
||||
if path == "" || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") || strings.Contains(path, "..") {
|
||||
return fmt.Errorf("path %q is not a relative data path", path)
|
||||
}
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
for i, r := range segment {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (i > 0 && (r == '-' || (r >= '0' && r <= '9')))) {
|
||||
return fmt.Errorf("path %q contains unsafe segment %q", path, segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
DryRunPreviewInvocation = "invocation"
|
||||
DryRunPreviewRequest = "request"
|
||||
|
||||
@@ -4,10 +4,75 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameworkResultSpecValidationEdges(t *testing.T) {
|
||||
if got, err := NormalizeResultSpec(nil, ""); err != nil || got != nil {
|
||||
t.Fatalf("NormalizeResultSpec(nil)=(%v,%v)", got, err)
|
||||
}
|
||||
base := func() *ResultSpec {
|
||||
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
if got, err := NormalizeResultSpec(base(), ""); err != nil || got == nil || got.SensitivePaths != nil {
|
||||
t.Fatalf("valid default spec=(%#v,%v)", got, err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(*ResultSpec)
|
||||
}{
|
||||
{"sensitive invalid", func(s *ResultSpec) { s.SensitivePaths = []string{"bad..path"} }},
|
||||
{"empty schema", func(s *ResultSpec) { s.DataSchema = nil }},
|
||||
{"multiple schema", func(s *ResultSpec) { s.DataSchema = json.RawMessage(`{} {}`) }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := base()
|
||||
tc.edit(spec)
|
||||
if _, err := NormalizeResultSpec(spec, "sample"); err == nil {
|
||||
t.Fatalf("invalid spec accepted: %#v", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := validateResultPath("a.$"); err == nil {
|
||||
t.Fatal("unsafe segment accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultSchemaDescriptionValidationEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"invalid json", `{`, "must be one JSON Schema object"},
|
||||
{"properties is not object", `{"properties":[]}`, "properties must be an object"},
|
||||
{"property is not schema", `{"properties":{"id":"string"}}`, "properties.id must be a JSON Schema object"},
|
||||
{"property description missing", `{"properties":{"id":{"type":"string"}}}`, "properties.id requires description"},
|
||||
{"nested property invalid", `{"properties":{"item":{"description":"item","properties":[]}}}`, "properties.item.properties must be an object"},
|
||||
{"items is not schema", `{"items":[]}`, "items must be a JSON Schema object"},
|
||||
{"nested items invalid", `{"items":{"properties":[]}}`, "items.properties must be an object"},
|
||||
{"composition is not array", `{"oneOf":{}}`, "oneOf must be an array"},
|
||||
{"composition branch is not schema", `{"anyOf":["string"]}`, "anyOf[0] must be a JSON Schema object"},
|
||||
{"nested composition invalid", `{"allOf":[{"properties":[]}]}`, "allOf[0].properties must be an object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateResultSchemaDescriptions(json.RawMessage(tc.raw), "data_schema")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationSpecNilIsAbsent(t *testing.T) {
|
||||
if got, err := NormalizePaginationSpec(nil, ""); err != nil || got != nil {
|
||||
t.Fatalf("NormalizePaginationSpec(nil) = (%#v, %v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDryRunSpecValidate(t *testing.T) {
|
||||
for _, kind := range []string{DryRunPreviewInvocation, DryRunPreviewRequest, DryRunPreviewPlan, DryRunPreviewDiff} {
|
||||
if err := (DryRunSpec{PreviewKind: kind}).Validate("sample.run"); err != nil {
|
||||
|
||||
@@ -42,6 +42,8 @@ type ContractDecl struct {
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
Parameters []contract.ParamDecl
|
||||
DryRun *contract.DryRunSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Interface *contract.InterfaceSpec
|
||||
Selection contract.SelectionSpec
|
||||
Identity contract.ToolIdentitySpec
|
||||
@@ -144,6 +146,12 @@ func (s ContractDecl) empty() bool {
|
||||
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
|
||||
return false
|
||||
}
|
||||
if s.Result != nil {
|
||||
return false
|
||||
}
|
||||
if s.Pagination != nil {
|
||||
return false
|
||||
}
|
||||
if s.Interface != nil {
|
||||
iface := s.Interface
|
||||
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
Description: "Create Desc",
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`),
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
@@ -79,6 +84,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
|
||||
t.Fatalf("dry_run = %#v", final.DryRun)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) != 1 {
|
||||
t.Fatalf("result = %#v", final.Result)
|
||||
}
|
||||
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
||||
t.Fatalf("pagination = %#v", final.Pagination)
|
||||
}
|
||||
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
|
||||
t.Fatalf("interface = %#v", final.Interface)
|
||||
}
|
||||
@@ -110,6 +121,54 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkContractDeclResultMarksNonEmptyAndRejectsInvalidSchema(t *testing.T) {
|
||||
if (ContractDecl{Result: &contract.ResultSpec{}}).Empty() {
|
||||
t.Fatal("Result declaration was treated as empty")
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Result") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "bad-result",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: ContractDecl{
|
||||
Title: "Bad", Description: "bad result",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-result"}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad", CanonicalPath: "sample.bad", CLIPath: "bad-result", PrimaryCLIPath: "bad-result"},
|
||||
},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrameworkContractDeclPaginationMarksNonEmptyAndRejectsInvalidSpec(t *testing.T) {
|
||||
if (ContractDecl{Pagination: &contract.PaginationSpec{}}).Empty() {
|
||||
t.Fatal("Pagination declaration was treated as empty")
|
||||
}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Pagination") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "bad-pagination",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: ContractDecl{
|
||||
Title: "Bad pagination",
|
||||
Description: "bad pagination",
|
||||
Pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-pagination"}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad_pagination", CanonicalPath: "sample.bad_pagination", CLIPath: "bad-pagination", PrimaryCLIPath: "bad-pagination"},
|
||||
},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewCommandFallsBackToDeclaredDescriptionWithoutLong(t *testing.T) {
|
||||
// Long wins when authored; without one the mandatory declaration supplies it.
|
||||
cmd := New(Spec{
|
||||
|
||||
@@ -24,6 +24,11 @@ import (
|
||||
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||
result := &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`),
|
||||
SensitivePaths: []string{"token"},
|
||||
}
|
||||
|
||||
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Title: "T",
|
||||
@@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
},
|
||||
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
|
||||
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
|
||||
Result: result,
|
||||
})
|
||||
result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||
result.DataSchema[0] = '['
|
||||
result.SensitivePaths[0] = "changed"
|
||||
if cmd.Annotations != nil {
|
||||
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
|
||||
t.Fatal("must not write JSON annotation dws.schema.final")
|
||||
@@ -45,6 +54,14 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
|
||||
if got.Selection == nil || got.Selection.Reviewed != nil {
|
||||
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
|
||||
}
|
||||
if got.Result == nil || got.Result.Outcomes[0] != contract.ResultOutcomeSuccess || got.Result.DataSchema[0] != '{' || got.Result.SensitivePaths[0] != "token" {
|
||||
t.Fatalf("stored result aliases registration input: %#v", got.Result)
|
||||
}
|
||||
got.Result.Outcomes[0] = contract.ResultOutcomeFailure
|
||||
again, _ := RuntimeContractFinal(cmd)
|
||||
if again.Result.Outcomes[0] != contract.ResultOutcomeSuccess {
|
||||
t.Fatal("RuntimeContractFinal result aliases stored payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
|
||||
@@ -137,3 +154,96 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t
|
||||
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *testing.T) {
|
||||
read := &cobra.Command{Use: "read"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(read) })
|
||||
RegisterRuntimeContractFinal(read, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.read", PrimaryCLIPath: "sample get"},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", Idempotency: " idempotent "},
|
||||
})
|
||||
|
||||
for _, lookup := range []struct {
|
||||
canonical string
|
||||
cli string
|
||||
}{
|
||||
{canonical: "sample.read"},
|
||||
{canonical: "different.rpc", cli: "dws sample get"},
|
||||
} {
|
||||
safety, declared, ok := ResolveRuntimeSafety(lookup.canonical, lookup.cli)
|
||||
if !declared || !ok || safety.Effect != "read" || safety.Idempotency != "idempotent" {
|
||||
t.Fatalf("ResolveRuntimeSafety(%q, %q) = %#v, %v, %v", lookup.canonical, lookup.cli, safety, declared, ok)
|
||||
}
|
||||
}
|
||||
|
||||
missingSafety := &cobra.Command{Use: "write"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(missingSafety) })
|
||||
RegisterRuntimeContractFinal(missingSafety, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.write"},
|
||||
})
|
||||
if _, declared, ok := ResolveRuntimeSafety("sample.write", ""); !declared || ok {
|
||||
t.Fatalf("missing safety = declared %v ok %v, want true false", declared, ok)
|
||||
}
|
||||
if _, declared, ok := ResolveRuntimeSafety("legacy.call", "legacy call"); declared || ok {
|
||||
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "all"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||
payload := contract.ContractFinalPayload{
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id"}},
|
||||
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
Interface: &contract.InterfaceSpec{Ref: &contract.InterfaceRefSpec{}},
|
||||
Selection: &contract.SelectionSpec{
|
||||
UseWhen: []string{"use"}, AvoidWhen: []string{"avoid"}, Prerequisites: []string{"pre"}, Tips: []string{"tip"},
|
||||
WorkflowRefs: []string{"flow"}, Examples: []string{"example"}, SourceRefs: []string{"source"},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{Index: intPointer(1)}}, Reviewed: boolPointer(true),
|
||||
},
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all", Aliases: []string{"alias"}},
|
||||
}
|
||||
RegisterRuntimeContractFinal(cmd, payload)
|
||||
got, ok := RuntimeContractFinal(cmd)
|
||||
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
|
||||
t.Fatalf("payload not deeply cloned: %#v", got)
|
||||
}
|
||||
payload.Parameters[0].Enum[0] = "changed"
|
||||
*payload.Parameters[0].Required = false
|
||||
*payload.Selection.ExampleDispositions[0].Index = 9
|
||||
*payload.Selection.Reviewed = false
|
||||
again, _ := RuntimeContractFinal(cmd)
|
||||
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
|
||||
t.Fatalf("stored payload aliased input: %#v", again)
|
||||
}
|
||||
|
||||
matching := &cobra.Command{Use: "matching"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
|
||||
RegisterRuntimeContractFinal(matching, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{Path: "sample.all", CLIPath: "sample all"}, Safety: &contract.SafetySpec{Effect: "read", EffectSource: "source", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || !valid {
|
||||
t.Fatalf("equivalent duplicate=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
|
||||
conflict := &cobra.Command{Use: "conflict"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(conflict) })
|
||||
RegisterRuntimeContractFinal(conflict, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all"}, Safety: &contract.SafetySpec{Effect: "write"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || valid {
|
||||
t.Fatalf("conflict=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
if runtimeIdentityMatches(contract.ToolIdentitySpec{}, "", "") {
|
||||
t.Fatal("empty identity matched")
|
||||
}
|
||||
if got := cloneSlice[string](nil); got != nil {
|
||||
t.Fatalf("cloneSlice(nil)=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package contractfinal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF
|
||||
return
|
||||
}
|
||||
runtimeannotate.AnnotateRuntimeContract(cmd)
|
||||
p := payload
|
||||
p := cloneContractFinalPayload(payload)
|
||||
contractFinalByCommand.Store(cmd, &p)
|
||||
}
|
||||
|
||||
@@ -51,7 +52,84 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo
|
||||
if !ok || p == nil {
|
||||
return contract.ContractFinalPayload{}, false
|
||||
}
|
||||
return *p, true
|
||||
return cloneContractFinalPayload(*p), true
|
||||
}
|
||||
|
||||
func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.ContractFinalPayload {
|
||||
out := in
|
||||
out.Positionals = cloneSlice(in.Positionals)
|
||||
out.Parameters = cloneSlice(in.Parameters)
|
||||
for i := range out.Parameters {
|
||||
out.Parameters[i].Enum = cloneSlice(in.Parameters[i].Enum)
|
||||
if in.Parameters[i].Required != nil {
|
||||
required := *in.Parameters[i].Required
|
||||
out.Parameters[i].Required = &required
|
||||
}
|
||||
}
|
||||
if in.Safety != nil {
|
||||
value := *in.Safety
|
||||
out.Safety = &value
|
||||
}
|
||||
if in.DryRun != nil {
|
||||
value := *in.DryRun
|
||||
out.DryRun = &value
|
||||
}
|
||||
if in.Result != nil {
|
||||
value := *in.Result
|
||||
value.Outcomes = cloneSlice(in.Result.Outcomes)
|
||||
value.DataSchema = cloneSlice(in.Result.DataSchema)
|
||||
value.SensitivePaths = cloneSlice(in.Result.SensitivePaths)
|
||||
out.Result = &value
|
||||
}
|
||||
if in.Pagination != nil {
|
||||
value := *in.Pagination
|
||||
out.Pagination = &value
|
||||
}
|
||||
if in.Interface != nil {
|
||||
value := *in.Interface
|
||||
if in.Interface.Ref != nil {
|
||||
ref := *in.Interface.Ref
|
||||
value.Ref = &ref
|
||||
}
|
||||
out.Interface = &value
|
||||
}
|
||||
if in.Selection != nil {
|
||||
value := *in.Selection
|
||||
value.UseWhen = cloneSlice(in.Selection.UseWhen)
|
||||
value.AvoidWhen = cloneSlice(in.Selection.AvoidWhen)
|
||||
value.Prerequisites = cloneSlice(in.Selection.Prerequisites)
|
||||
value.Tips = cloneSlice(in.Selection.Tips)
|
||||
value.WorkflowRefs = cloneSlice(in.Selection.WorkflowRefs)
|
||||
value.Examples = cloneSlice(in.Selection.Examples)
|
||||
value.SourceRefs = cloneSlice(in.Selection.SourceRefs)
|
||||
value.ExampleDispositions = cloneSlice(in.Selection.ExampleDispositions)
|
||||
for i := range value.ExampleDispositions {
|
||||
if in.Selection.ExampleDispositions[i].Index != nil {
|
||||
index := *in.Selection.ExampleDispositions[i].Index
|
||||
value.ExampleDispositions[i].Index = &index
|
||||
}
|
||||
}
|
||||
if in.Selection.Reviewed != nil {
|
||||
reviewed := *in.Selection.Reviewed
|
||||
value.Reviewed = &reviewed
|
||||
}
|
||||
out.Selection = &value
|
||||
}
|
||||
if in.Identity != nil {
|
||||
value := *in.Identity
|
||||
value.Aliases = cloneSlice(in.Identity.Aliases)
|
||||
out.Identity = &value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSlice[T any](in []T) []T {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]T, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
|
||||
@@ -62,3 +140,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool {
|
||||
_, ok := contractFinalByCommand.Load(cmd)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResolveRuntimeSafety finds the live ContractFinal safety declaration for an
|
||||
// invocation identity. declared distinguishes a matched declaration whose
|
||||
// safety is unavailable or conflicting from a legacy invocation with no unified
|
||||
// declaration context. Repeated equivalent command-tree registrations are
|
||||
// accepted; conflicting matches fail closed with ok=false.
|
||||
func ResolveRuntimeSafety(canonicalPath, cliPath string) (safety contract.SafetySpec, declared, ok bool) {
|
||||
canonicalPath = strings.TrimSpace(canonicalPath)
|
||||
cliPath = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cliPath), "dws "))
|
||||
|
||||
var resolved contract.SafetySpec
|
||||
contractFinalByCommand.Range(func(_, raw any) bool {
|
||||
payload, valid := raw.(*contract.ContractFinalPayload)
|
||||
if !valid || payload == nil || payload.Identity == nil ||
|
||||
!runtimeIdentityMatches(*payload.Identity, canonicalPath, cliPath) {
|
||||
return true
|
||||
}
|
||||
declared = true
|
||||
if payload.Safety == nil {
|
||||
ok = false
|
||||
return false
|
||||
}
|
||||
candidate := normalizedRuntimeSafety(*payload.Safety)
|
||||
if !ok {
|
||||
resolved = candidate
|
||||
ok = true
|
||||
return true
|
||||
}
|
||||
if resolved != candidate {
|
||||
ok = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return resolved, declared, ok
|
||||
}
|
||||
|
||||
func runtimeIdentityMatches(identity contract.ToolIdentitySpec, canonicalPath, cliPath string) bool {
|
||||
if canonicalPath != "" {
|
||||
for _, value := range []string{identity.CanonicalPath, identity.Path} {
|
||||
if strings.TrimSpace(value) == canonicalPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if cliPath == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range []string{identity.PrimaryCLIPath, identity.CLIPath} {
|
||||
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "dws ")) == cliPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedRuntimeSafety(safety contract.SafetySpec) contract.SafetySpec {
|
||||
safety.Effect = strings.TrimSpace(safety.Effect)
|
||||
safety.EffectSource = strings.TrimSpace(safety.EffectSource)
|
||||
safety.Risk = strings.TrimSpace(safety.Risk)
|
||||
safety.Confirmation = strings.TrimSpace(safety.Confirmation)
|
||||
safety.Idempotency = strings.TrimSpace(safety.Idempotency)
|
||||
return safety
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
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/cmdutil"
|
||||
)
|
||||
|
||||
@@ -224,11 +225,12 @@ const (
|
||||
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
||||
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||||
type Spec struct {
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
OutputRollout output.RolloutState
|
||||
|
||||
Flags []FlagSpec
|
||||
Constraints []Constraint
|
||||
@@ -266,6 +268,8 @@ type Spec struct {
|
||||
RunE func(cmd *cobra.Command, args []string) error
|
||||
// Invoke executes a single-step command with the assembled toolArgs.
|
||||
Invoke func(c *Ctx, toolArgs map[string]any) error
|
||||
// ResultInvoke executes once and returns an immutable framework 2.0 result.
|
||||
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, error)
|
||||
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
||||
// it needs from the Ctx.
|
||||
Orchestrate func(c *Ctx) error
|
||||
@@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command {
|
||||
if spec.PostMount != nil {
|
||||
spec.PostMount(cmd)
|
||||
}
|
||||
if spec.OutputRollout != "" {
|
||||
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||
}
|
||||
if spec.ConfirmFirst {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
@@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ResultInvoke != nil {
|
||||
if !output.UsesUnifiedResult(cmd) {
|
||||
return fmt.Errorf("command %q uses ResultInvoke without an active unified-result rollout", cmd.CommandPath())
|
||||
}
|
||||
result, err := spec.ResultInvoke(ctx, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.StoreResult(cmd.Context(), result)
|
||||
}
|
||||
return spec.Invoke(ctx, toolArgs)
|
||||
}
|
||||
return cmd
|
||||
@@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) {
|
||||
if spec.Invoke != nil {
|
||||
declared++
|
||||
}
|
||||
if spec.ResultInvoke != nil {
|
||||
declared++
|
||||
}
|
||||
if spec.Orchestrate != nil {
|
||||
declared++
|
||||
}
|
||||
if declared != 1 {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
|
||||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d (ResultInvoke is also a dispatcher)",
|
||||
spec.Use, declared))
|
||||
}
|
||||
// ConfirmFirst only changes the ordering of a declared confirmation gate.
|
||||
@@ -681,7 +701,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
|
||||
if hint == "" {
|
||||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||||
}
|
||||
return fmt.Errorf("%s", hint)
|
||||
return apperrors.NewValidation(hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1360,6 +1380,20 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
||||
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
||||
payload.DryRun = &d
|
||||
}
|
||||
if decl.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
|
||||
}
|
||||
payload.Result = result
|
||||
}
|
||||
if decl.Pagination != nil {
|
||||
pagination, err := contract.NormalizePaginationSpec(decl.Pagination, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Pagination: %v", cmd.Name(), err))
|
||||
}
|
||||
payload.Pagination = pagination
|
||||
}
|
||||
if decl.Interface != nil {
|
||||
iface := &contract.InterfaceSpec{
|
||||
Mode: strings.TrimSpace(decl.Interface.Mode),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
|
||||
calls := 0
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd := New(Spec{
|
||||
Use: "result",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
calls++
|
||||
return output.Success(map[string]any{"id": "a"}), nil
|
||||
},
|
||||
})
|
||||
cmd.SetContext(ctx)
|
||||
cmd.PersistentFlags().String("format", "json", "")
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("calls=%d, want 1", calls)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
if output.CommandRollout(cmd) != output.RolloutUnifiedActive {
|
||||
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultInvokeErrorLegacyAndStoreEdges(t *testing.T) {
|
||||
wantErr := errors.New("invoke failed")
|
||||
cases := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
invoke func(*Ctx, map[string]any) (output.CommandResult, error)
|
||||
want string
|
||||
}{
|
||||
{"invoke error", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return nil, wantErr }, "invoke failed"},
|
||||
{"legacy guard", output.RolloutLegacyOnly, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "without an active unified-result rollout"},
|
||||
{"missing store", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "no result store"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := New(Spec{Use: "result", OutputRollout: tc.rollout, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, ResultInvoke: tc.invoke})
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute error=%v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyResultInvokeIsRejectedBeforeBusinessDispatch(t *testing.T) {
|
||||
calls := 0
|
||||
cmd := New(Spec{
|
||||
Use: "result",
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
calls++
|
||||
return output.Success(map[string]any{"changed": true}), nil
|
||||
},
|
||||
})
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "without an active unified-result rollout") {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("business dispatcher ran %d time(s), want 0", calls)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,11 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
t.Cleanup(func() { marshalErrorJSON = oldMarshal })
|
||||
marshalErrorJSON = func(any, string, string) ([]byte, error) { return nil, stderrors.New("encode") }
|
||||
out.Reset()
|
||||
if err := PrintJSON(&out, err); err != nil || !strings.Contains(out.String(), "failed to encode") {
|
||||
if err := PrintJSON(&out, err); err != nil ||
|
||||
!strings.Contains(out.String(), `"code":5`) ||
|
||||
!strings.Contains(out.String(), `"category":"internal"`) ||
|
||||
strings.Contains(out.String(), `"outcome"`) ||
|
||||
strings.Contains(out.String(), `"type"`) {
|
||||
t.Fatalf("PrintJSON fallback = %q, %v", out.String(), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,41 @@ const (
|
||||
CategoryValidation Category = "validation"
|
||||
CategoryDiscovery Category = "discovery"
|
||||
CategoryInternal Category = "internal"
|
||||
|
||||
// CategoryPartial is retained for source compatibility, but an error cannot
|
||||
// reconstruct the per-item data required by a partial result. It therefore
|
||||
// fails closed as internal; callers must use output.Partial for exit code 7.
|
||||
CategoryPartial Category = "partial_failure"
|
||||
)
|
||||
|
||||
// 退出码表(规划 v1.2 OQ-1 定案;契约规范 §4;轮10裁决⑬——保留现行码表,
|
||||
// 仅新增 partial_failure 专用码,不做 wire 破坏性重排):
|
||||
//
|
||||
// 0 success / pending(异步受理不是失败)
|
||||
// 1 api (CategoryAPI)
|
||||
// 2 auth (CategoryAuth)
|
||||
// 3 validation (CategoryValidation;confirmation_required 子类共享此码,
|
||||
// 以 reason/subtype 区分,AC-13)
|
||||
// 4 PAT (PATError 专属,见 pat.go ExitCodePermission;Category 不占用)
|
||||
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
|
||||
// 6 discovery (CategoryDiscovery)
|
||||
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
|
||||
//
|
||||
// ExitCodePartial is the partial-result exit code shared with internal/output.
|
||||
// It is not returned for CategoryPartial errors because they lack the typed
|
||||
// succeeded/failed/unknown payload required for an honest partial result.
|
||||
const ExitCodePartial = 7
|
||||
|
||||
// 类别专属退出码常量(B171/B172,权威 = 规划 v1.2 OQ-1 定案,契约规范 §4)。
|
||||
// ExitCode() 的 switch 用内联字面量,本组常量由 exitcodes.go 的
|
||||
// exitCodeByCategory 映射表引用,值与内联字面量一一对应(同源不双轨)。
|
||||
// 修改任一值必须先同步 ExitCode() 的 switch 分支与 internal/output 侧码表。
|
||||
const (
|
||||
ExitCodeAPI = 1
|
||||
ExitCodeAuth = 2
|
||||
ExitCodeValidation = 3
|
||||
ExitCodeDiscovery = 6
|
||||
ExitCodeInternal = 5
|
||||
)
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
@@ -83,6 +118,13 @@ type Option func(*Error)
|
||||
// ExitCodePermission and the exit-code table in docs/reference.md);
|
||||
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
||||
// apart from "PAT permission insufficient".
|
||||
//
|
||||
// confirmation_required 是 validation 的子类而非独立类别(B171,AC-13,
|
||||
// 规划 v1.2 OQ-1 定案):门禁拦截错误挂 CategoryValidation 并以
|
||||
// reason=confirmation_required 区分,与 validation 共享 rc=3。信封侧
|
||||
// internal/output exitCodeForErrorInfo 的「subtype 优先于 type、
|
||||
// confirmation_required 恒 3」规则与本表同源(轮10裁决⑬;远期独立码
|
||||
// 保留于规划 OQ-9,落地前不得双轨)。
|
||||
func (e *Error) ExitCode() int {
|
||||
switch e.Category {
|
||||
case CategoryAPI:
|
||||
@@ -93,6 +135,10 @@ func (e *Error) ExitCode() int {
|
||||
return 3
|
||||
case CategoryDiscovery:
|
||||
return 6
|
||||
case CategoryPartial:
|
||||
// An error has no per-item succeeded/failed/unknown data and therefore
|
||||
// cannot truthfully represent partial_failure. Fail closed as internal.
|
||||
return ExitCodeInternal
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
@@ -149,6 +195,11 @@ func WithRetryable(retryable bool) Option {
|
||||
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
||||
// A zero delay is meaningful and is therefore preserved; negative values are
|
||||
// ignored as invalid server guidance.
|
||||
//
|
||||
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
|
||||
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
|
||||
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
|
||||
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
|
||||
func WithRetryAfterSeconds(seconds int64) Option {
|
||||
return func(err *Error) {
|
||||
if seconds < 0 {
|
||||
@@ -322,14 +373,17 @@ func ExitCode(err error) int {
|
||||
return 5
|
||||
}
|
||||
|
||||
// PrintJSON writes a machine-readable JSON error object.
|
||||
// PrintJSON writes the legacy machine-readable JSON error object.
|
||||
//
|
||||
// This wire predates the unified result framework and is intentionally kept
|
||||
// byte-compatible for commands whose rollout is legacy_only or dual_validate.
|
||||
// Unified commands publish outcome/type/subtype through internal/output only.
|
||||
func PrintJSON(w io.Writer, err error) error {
|
||||
errorPayload := map[string]any{
|
||||
"code": ExitCode(err),
|
||||
"category": category(err),
|
||||
"message": err.Error(),
|
||||
}
|
||||
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
if typed.Reason != "" {
|
||||
@@ -409,7 +463,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
|
||||
data, marshalErr := marshalErrorJSON(payload, "", " ")
|
||||
if marshalErr != nil {
|
||||
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
||||
_, writeErr := fmt.Fprintln(w, `{"error":{"code":5,"category":"internal","message":"failed to encode error output"}}`)
|
||||
return writeErr
|
||||
}
|
||||
|
||||
@@ -556,6 +610,12 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return friendlyHint, actionURL
|
||||
}
|
||||
|
||||
// ServerGuidance exposes the same recovery projection to repository-local
|
||||
// adapters so legacy JSON and unified-result errors stay semantically aligned.
|
||||
func ServerGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return serverGuidance(diag)
|
||||
}
|
||||
|
||||
func safeServerActionURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -572,6 +632,9 @@ func safeServerActionURL(raw string) string {
|
||||
func category(err error) string {
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
if typed.Category == CategoryPartial {
|
||||
return string(CategoryInternal)
|
||||
}
|
||||
return string(typed.Category)
|
||||
}
|
||||
return string(CategoryInternal)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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 errors
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestErrorsExitCodeMapConsistentWithExitCode 是 B209 的 errors 侧同源锁定:
|
||||
// exitcodes.go 的 exitCodeByCategory 映射表必须与 ExitCode() 的 switch 分支
|
||||
// 逐类别一致(同源不双轨,契约 §4)。任一单边修改即失败,防止未来漂移。
|
||||
// output 侧同源锁定由 internal/output emitter_phase_c_test.go
|
||||
// TestExitCodeForEnvelopeSameSourceAsErrorsExitCode 交叉断言(B209)。
|
||||
func TestErrorsExitCodeMapConsistentWithExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cats := []Category{
|
||||
CategoryAPI,
|
||||
CategoryAuth,
|
||||
CategoryValidation,
|
||||
CategoryDiscovery,
|
||||
CategoryInternal,
|
||||
CategoryPartial,
|
||||
}
|
||||
for _, cat := range cats {
|
||||
table := exitCodeByCategory[cat]
|
||||
viaSwitch := (&Error{Category: cat, Message: "x"}).ExitCode()
|
||||
if table != viaSwitch {
|
||||
t.Fatalf("exitCodeByCategory[%q]=%d disagrees with ExitCode()=%d", cat, table, viaSwitch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsExitCodeConstantsEqualMap 锁定类别专属常量与映射表值一致。
|
||||
func TestErrorsExitCodeConstantsEqualMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[Category]int{
|
||||
CategoryAPI: ExitCodeAPI,
|
||||
CategoryAuth: ExitCodeAuth,
|
||||
CategoryValidation: ExitCodeValidation,
|
||||
CategoryDiscovery: ExitCodeDiscovery,
|
||||
CategoryInternal: ExitCodeInternal,
|
||||
CategoryPartial: ExitCodeInternal,
|
||||
}
|
||||
for cat, wantCode := range want {
|
||||
if got := exitCodeByCategory[cat]; got != wantCode {
|
||||
t.Fatalf("exitCodeByCategory[%q]=%d, want %d", cat, got, wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// 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 errors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestErrorsPrintJSONFieldInventory protects the published legacy error wire.
|
||||
// Unified type/subtype/outcome fields belong to internal/output and must not
|
||||
// leak into commands that have not migrated.
|
||||
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"too many requests",
|
||||
WithReason("rate_limit"),
|
||||
WithHint("wait and retry"),
|
||||
WithRetryable(true),
|
||||
WithRetryAfterSeconds(30),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`"category": "api"`,
|
||||
`"reason": "rate_limit"`,
|
||||
`"code": 1`,
|
||||
`"retryable": true`,
|
||||
`"retry_after_seconds": 30`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing wire-stable field %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
// informational 组
|
||||
for _, want := range []string{
|
||||
`"message": "too many requests"`,
|
||||
`"hint": "wait and retry"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing informational field %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"outcome"`, `"type"`, `"subtype"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Errorf("unified field %s leaked into legacy wire: %s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONLegacyWireGolden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation("missing", WithReason("missing_required_flags"))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
want := "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"missing\",\n \"reason\": \"missing_required_flags\"\n }\n}\n"
|
||||
if got := b.String(); got != want {
|
||||
t.Fatalf("legacy error wire changed\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONReasonProjectionStaysLegacy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("confirmation_required", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"confirmation required",
|
||||
WithReason("confirmation_required"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"reason": "confirmation_required"`) {
|
||||
t.Fatalf("expected confirmation_required reason, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, `"subtype"`) || strings.Contains(got, `"type"`) {
|
||||
t.Fatalf("unified fields leaked into legacy wire: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no reason omits reason", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"reason"`) {
|
||||
t.Fatalf("reason must be omitted when Reason is empty, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
|
||||
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
|
||||
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
|
||||
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
|
||||
validation := NewValidation("bad param")
|
||||
|
||||
if got := ExitCode(confirmation); got != 3 {
|
||||
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
|
||||
}
|
||||
if got := ExitCode(validation); got != 3 {
|
||||
t.Fatalf("validation ExitCode = %d, want 3", got)
|
||||
}
|
||||
// Legacy reason distinguishes the confirmation subtype.
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, confirmation); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"reason": "confirmation_required"`) {
|
||||
t.Fatalf("confirmation must carry reason, got %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := &Error{Category: CategoryPartial, Message: "partial"}
|
||||
if got := ExitCode(err); got != ExitCodeInternal {
|
||||
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
|
||||
}
|
||||
if ExitCodePartial != 7 {
|
||||
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"category": "internal"`) {
|
||||
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsPrintJSONKeepsLegacyCategories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"api", NewAPI("x"), "api"},
|
||||
{"auth", NewAuth("x"), "auth"},
|
||||
{"validation", NewValidation("x"), "validation"},
|
||||
{"discovery", NewDiscovery("x"), "discovery"},
|
||||
{"internal", NewInternal("x"), "internal"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, tc.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
|
||||
t.Fatalf("expected legacy category %q in %s", tc.want, got)
|
||||
}
|
||||
if strings.Contains(got, `"type"`) {
|
||||
t.Fatalf("unified type leaked into legacy JSON: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsWireStableFieldsSubset protects the legacy recovery fields that
|
||||
// remain useful without changing the top-level envelope.
|
||||
func TestErrorsWireStableFieldsSubset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"rpc failed",
|
||||
WithReason("rate_limit"),
|
||||
WithHint("h"),
|
||||
WithRetryable(true),
|
||||
WithRetryAfterSeconds(5),
|
||||
WithActions("retry"),
|
||||
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
|
||||
WithRPCCode(-32602),
|
||||
WithRPCData([]byte(`{"field":"x"}`)),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"category"`, `"reason"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
|
||||
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("wire-stable field %s missing from %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
|
||||
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
|
||||
// 未置)时 retryable 缺席。
|
||||
func TestErrorsRetryableOmitEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("true present", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retryable": true`) {
|
||||
t.Fatalf("expected retryable:true, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("unset omitted", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"retryable"`) {
|
||||
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
|
||||
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
|
||||
// type/code 组合,plain 错误归 internal(rc=5)。
|
||||
func TestErrorsCategorySnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
category string
|
||||
code string
|
||||
}{
|
||||
{"api", NewAPI("x"), "api", `"code": 1`},
|
||||
{"auth", NewAuth("x"), "auth", `"code": 2`},
|
||||
{"validation", NewValidation("x"), "validation", `"code": 3`},
|
||||
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
|
||||
{"internal", NewInternal("x"), "internal", `"code": 5`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, tc.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"category": "`+tc.category+`"`) || !strings.Contains(got, tc.code) {
|
||||
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
|
||||
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
|
||||
func TestErrorsActionsArrayPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"confirm required",
|
||||
WithReason("confirmation_required"),
|
||||
WithActions("dws chat send --yes", "", "dws chat cancel"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"actions"`,
|
||||
`"dws chat send --yes"`,
|
||||
`"dws chat cancel"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %s in actions, got %q", want, got)
|
||||
}
|
||||
}
|
||||
// 空串条目被过滤:不应出现空引号动作。
|
||||
if strings.Contains(got, `""`) {
|
||||
t.Fatalf("empty action must be filtered, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
|
||||
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
|
||||
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
|
||||
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"rpc failed",
|
||||
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
|
||||
WithRPCCode(-32602),
|
||||
WithRPCData([]byte(`{"field":"base_id"}`)),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
`"trace_id": "trace-abc"`,
|
||||
`"rpc_code": -32602`,
|
||||
`"field"`,
|
||||
`"base_id"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected %s in %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsLegacyPrintJSONOmitsUnifiedOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewInternal("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"outcome"`) {
|
||||
t.Fatalf("legacy error JSON must not carry unified outcome, got %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
|
||||
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
|
||||
// transport 重试延迟选择,B196 双通道分离)。
|
||||
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
|
||||
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
|
||||
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
|
||||
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
|
||||
// (WithRetryAfterSeconds 忽略负值)。
|
||||
func TestRetryAfterZeroValuePreserved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
|
||||
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
|
||||
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
|
||||
}
|
||||
|
||||
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
|
||||
if negative.RetryAfterSeconds != nil {
|
||||
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
|
||||
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
|
||||
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("nonzero", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
|
||||
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("zero preserved", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
|
||||
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
|
||||
}
|
||||
})
|
||||
t.Run("unset omitted", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI("x")); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
if strings.Contains(b.String(), `"retry_after_seconds"`) {
|
||||
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
|
||||
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
|
||||
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
|
||||
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tz := time.FixedZone("CST", 8*60*60)
|
||||
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
|
||||
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
|
||||
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
|
||||
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
|
||||
}
|
||||
if err.NextRetryAt == nil {
|
||||
t.Fatal("NextRetryAt must be set")
|
||||
}
|
||||
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
|
||||
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
|
||||
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
|
||||
}
|
||||
|
||||
// wire 上两字段同现且互不覆盖。
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"retry_after_seconds": 30`) {
|
||||
t.Fatalf("missing retry_after_seconds:30 in %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
|
||||
t.Fatalf("missing next_retry_at in %s", got)
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,18 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hint, action := ServerGuidance(ServerDiagnostics{
|
||||
FriendlyHint: "follow the recovery action",
|
||||
ActionURL: "https://example.test/recover",
|
||||
})
|
||||
if hint != "follow the recovery action" || action != "https://example.test/recover" {
|
||||
t.Fatalf("ServerGuidance() = (%q, %q)", hint, action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, actionURL := range []string{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package errors
|
||||
|
||||
// 统一退出码表(B171/B172;轮 10 裁决⑬,权威 = 规划 v1.2 OQ-1 定案):
|
||||
// 类别与退出码一一对应,`confirmation_required` 是 `validation` 下的子类
|
||||
// 共享 3,不新增独立退出码。
|
||||
//
|
||||
// 跨包同源锁定:internal/output.ExitCodeForEnvelope 对同一信封必须给出
|
||||
// 本表完全一致的码(api=1/auth=2/validation=3/discovery=6/internal=5)。
|
||||
// Typed output.Partial remains the only path to partial_failure exit 7.
|
||||
// 修改本表 = 契约变更,必须双侧同步并更新两侧同源测试。
|
||||
var exitCodeByCategory = map[Category]int{
|
||||
CategoryAPI: ExitCodeAPI,
|
||||
CategoryAuth: ExitCodeAuth,
|
||||
CategoryValidation: ExitCodeValidation,
|
||||
CategoryDiscovery: ExitCodeDiscovery,
|
||||
CategoryInternal: ExitCodeInternal,
|
||||
CategoryPartial: ExitCodeInternal,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package errors
|
||||
|
||||
// 错误信封 wire-stable 字段集(契约规范 §2.4;B174)。
|
||||
//
|
||||
// Agent 可编程分流的字段集合:type/subtype/code/retryable/
|
||||
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data/
|
||||
// outcome。wireErrors 的每个字段都必须落在此集合内;新增字段 = 契约
|
||||
// 扩展,需评审。wireErrors 未声明 JSON tag 的字段(如 Cause)是内部
|
||||
// 字段,序列化缺席,不属于 wire。
|
||||
|
||||
// WireStableFields 是错误信封 wire-stable 字段名全集(含 outcome)。
|
||||
var WireStableFields = []string{
|
||||
"type",
|
||||
"subtype",
|
||||
"code",
|
||||
"retryable",
|
||||
"retry_after_seconds",
|
||||
"message",
|
||||
"hint",
|
||||
"actions",
|
||||
"trace_id",
|
||||
"rpc_code",
|
||||
"rpc_data",
|
||||
"outcome",
|
||||
}
|
||||
|
||||
// WireStableErrorBodyFields 是 error 对象体(不含顶层 outcome)的
|
||||
// wire-stable 字段名子集。
|
||||
var WireStableErrorBodyFields = []string{
|
||||
"type",
|
||||
"subtype",
|
||||
"code",
|
||||
"retryable",
|
||||
"retry_after_seconds",
|
||||
"message",
|
||||
"hint",
|
||||
"actions",
|
||||
"trace_id",
|
||||
"rpc_code",
|
||||
"rpc_data",
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -324,6 +325,21 @@ func TestCrossPlatformCoverageRunStartupAndSourceEdges(t *testing.T) {
|
||||
if err := Run(context.Background(), base); !errors.Is(err, errBusInjected) {
|
||||
t.Fatalf("source error = %v", err)
|
||||
}
|
||||
|
||||
runtimeWorkDir := shortTempDir(t)
|
||||
base.WorkDir = runtimeWorkDir
|
||||
base.IPCEndpoint = dwsevent.IPCEndpoint(
|
||||
runtimeWorkDir,
|
||||
"open",
|
||||
dwsevent.SourceKindPersonalStream,
|
||||
dwsevent.IdentityHash(runtimeWorkDir),
|
||||
)
|
||||
base.Source = edgeSource{start: func(context.Context, dwsevent.EmitFn) error {
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}}
|
||||
if err := Run(context.Background(), base); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("runtime source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedListener struct {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func eventCoreDaemon(broker *runtimecred.Broker) *daemon {
|
||||
return &daemon{
|
||||
cfg: Config{CredentialBroker: broker, IdleTimeout: time.Second},
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
hub: NewHub(4),
|
||||
started: time.Now(),
|
||||
idleStop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func eventCoreConnection(d *daemon, wrap func(net.Conn) net.Conn) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
|
||||
server, client := net.Pipe()
|
||||
if wrap != nil {
|
||||
server = wrap(server)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.handleConnection(context.Background(), server)
|
||||
close(done)
|
||||
}()
|
||||
return client, transport.NewWriter(client), transport.NewReader(client), done
|
||||
}
|
||||
|
||||
func eventCoreWaitDone(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection handler did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
type eventCoreWriteHookConn struct {
|
||||
net.Conn
|
||||
writes int
|
||||
hook func(int)
|
||||
}
|
||||
|
||||
func (c *eventCoreWriteHookConn) Write(p []byte) (int, error) {
|
||||
n, err := c.Conn.Write(p)
|
||||
c.writes++
|
||||
if err == nil && c.hook != nil {
|
||||
c.hook(c.writes)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreDaemonHandshakeEdges(t *testing.T) {
|
||||
t.Run("incompatible ack write failure", func(t *testing.T) {
|
||||
d := eventCoreDaemon(nil)
|
||||
client, w, _, done := eventCoreConnection(d, nil)
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: "unsupported"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Close()
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("runtime ack write failure", func(t *testing.T) {
|
||||
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
|
||||
client, w, _, done := eventCoreConnection(d, nil)
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Close()
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("terminal runtime hello", func(t *testing.T) {
|
||||
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
|
||||
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
|
||||
client, w, r, done := eventCoreConnection(d, nil)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil || ack.TerminalReason != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("terminal ack = %#v, %v", ack, err)
|
||||
}
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("malformed credential update", func(t *testing.T) {
|
||||
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
|
||||
client, w, r, done := eventCoreConnection(d, nil)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Write([]byte("{\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("unexpected credential update", func(t *testing.T) {
|
||||
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
|
||||
client, w, r, done := eventCoreConnection(d, nil)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.Heartbeat{Type: transport.FrameTypeHeartbeat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil || updateAck.ErrorCode != transport.CredentialErrorInvalid {
|
||||
t.Fatalf("unexpected-frame ack = %#v, %v", updateAck, err)
|
||||
}
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("credential ack write failure", func(t *testing.T) {
|
||||
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
|
||||
client, w, r, done := eventCoreConnection(d, nil)
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate, ExpectedGeneration: ack.CredentialGeneration, Token: "token",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Close()
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
|
||||
t.Run("activation conflict", func(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
d := eventCoreDaemon(broker)
|
||||
client, w, r, done := eventCoreConnection(d, func(conn net.Conn) net.Conn {
|
||||
return &eventCoreWriteHookConn{Conn: conn, hook: func(write int) {
|
||||
if write == 2 {
|
||||
_, _ = broker.Update(1, "newer-token")
|
||||
}
|
||||
}}
|
||||
})
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{Type: transport.FrameTypeCredentialUpdate, Token: "first-token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
|
||||
t.Fatalf("credential ack = %#v, %v", updateAck, err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "runtime_credential_activation_failed" {
|
||||
t.Fatalf("activation failure bye = %#v, %v", bye, err)
|
||||
}
|
||||
eventCoreWaitDone(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreDaemonWriterStopEdges(t *testing.T) {
|
||||
originalProcs := runtime.GOMAXPROCS(1)
|
||||
t.Cleanup(func() { runtime.GOMAXPROCS(originalProcs) })
|
||||
|
||||
run := func(t *testing.T, queueEvent bool) {
|
||||
t.Helper()
|
||||
d := eventCoreDaemon(nil)
|
||||
client, w, r, done := eventCoreConnection(d, nil)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, SubscribeID: "writer-stop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for d.hub.Len() != 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
d.hub.mu.RLock()
|
||||
var consumer *Consumer
|
||||
for _, candidate := range d.hub.consumers {
|
||||
consumer = candidate
|
||||
}
|
||||
d.hub.mu.RUnlock()
|
||||
if consumer == nil {
|
||||
t.Fatal("consumer not registered")
|
||||
}
|
||||
if queueEvent {
|
||||
consumer.SendCh <- transport.Heartbeat{Type: transport.FrameTypeHeartbeat}
|
||||
}
|
||||
consumer.StopCh <- "writer-stop"
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "writer-stop" {
|
||||
t.Fatalf("writer stop bye = %#v, %v", bye, err)
|
||||
}
|
||||
eventCoreWaitDone(t, done)
|
||||
}
|
||||
|
||||
t.Run("recheck after event", func(t *testing.T) { run(t, true) })
|
||||
t.Run("blocked stop select", func(t *testing.T) { run(t, false) })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreDaemonHelpersAndStopAll(t *testing.T) {
|
||||
var nilDaemon *daemon
|
||||
nilDaemon.setTerminalReason("ignored")
|
||||
if nilDaemon.getTerminalReason() != "" {
|
||||
t.Fatal("nil daemon returned terminal reason")
|
||||
}
|
||||
d := eventCoreDaemon(nil)
|
||||
d.setTerminalReason("ignored")
|
||||
if d.getTerminalReason() != "" {
|
||||
t.Fatal("invalid terminal reason was stored")
|
||||
}
|
||||
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
|
||||
if d.getTerminalReason() != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatal("terminal reason was not stored")
|
||||
}
|
||||
|
||||
if code, _ := classifyCredentialUpdateError(runtimecred.ErrEmptyToken); code != transport.CredentialErrorInvalid {
|
||||
t.Fatalf("empty-token classification = %q", code)
|
||||
}
|
||||
if code, message := classifyCredentialUpdateError(errors.New("internal detail")); code != transport.CredentialErrorInternal || message != "runtime credential update failed" {
|
||||
t.Fatalf("internal classification = %q, %q", code, message)
|
||||
}
|
||||
|
||||
hub := NewHub(1)
|
||||
consumer, err := hub.Register(transport.Hello{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stopped := hub.StopAll(" "); stopped != 1 {
|
||||
t.Fatalf("StopAll = %d", stopped)
|
||||
}
|
||||
select {
|
||||
case reason := <-consumer.StopCh:
|
||||
if reason != "shutdown" {
|
||||
t.Fatalf("default stop reason = %q", reason)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("default stop reason not delivered")
|
||||
}
|
||||
hub.Unregister(consumer.ID)
|
||||
}
|
||||
+251
-35
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
@@ -78,6 +79,11 @@ type Config struct {
|
||||
// Source is the cloud adapter. Required.
|
||||
Source SourceAdapter
|
||||
|
||||
// CredentialBroker enables additive runtime-token handoff over the
|
||||
// owner-only local IPC transport. Nil preserves the original protocol and
|
||||
// does not advertise runtime-token support.
|
||||
CredentialBroker *runtimecred.Broker
|
||||
|
||||
// IdleTimeout: bus self-exits after this long with zero consumers.
|
||||
// Zero disables (bus runs until SIGTERM).
|
||||
IdleTimeout time.Duration
|
||||
@@ -106,11 +112,13 @@ type Config struct {
|
||||
}
|
||||
|
||||
var (
|
||||
daemonMkdirAll = os.MkdirAll
|
||||
daemonAcquire = Acquire
|
||||
daemonWriteMeta = WriteMeta
|
||||
daemonListen = transport.Listen
|
||||
daemonShutdownTimeout = 2 * time.Second
|
||||
daemonMkdirAll = os.MkdirAll
|
||||
daemonAcquire = Acquire
|
||||
daemonWriteMeta = WriteMeta
|
||||
daemonListen = transport.Listen
|
||||
daemonShutdownTimeout = 2 * time.Second
|
||||
daemonByeDrainTimeout = 100 * time.Millisecond
|
||||
daemonCredentialHandshakeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
|
||||
@@ -237,12 +245,22 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
|
||||
// 6. Wait for shutdown trigger.
|
||||
var exitErr error
|
||||
shutdownReason := "shutdown"
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
|
||||
case err := <-srcErr:
|
||||
log.Error("bus: source exited", "err", err)
|
||||
exitErr = err
|
||||
shutdownReason = sourceShutdownReason(err)
|
||||
if shutdownReason == transport.ByeReasonRuntimeTokenRejected {
|
||||
// A runtime token can fail immediately after Broker.Update. Serialize
|
||||
// terminal publication with that handshake so the initiating consumer
|
||||
// is registered (or receives a terminal HelloAck) before shutdown.
|
||||
d.credentialHandoffMu.Lock()
|
||||
d.setTerminalReason(shutdownReason)
|
||||
d.credentialHandoffMu.Unlock()
|
||||
}
|
||||
case <-d.idleStop:
|
||||
log.Info("bus: idle timeout reached, shutting down")
|
||||
}
|
||||
@@ -252,7 +270,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
|
||||
// sync.WaitGroup forbids a positive Add racing with Wait.
|
||||
cancelRun()
|
||||
d.shutdown(acceptDone)
|
||||
d.shutdown(acceptDone, shutdownReason)
|
||||
<-idleDone
|
||||
<-dropWarnDone
|
||||
|
||||
@@ -274,6 +292,10 @@ type daemon struct {
|
||||
shutdownMu sync.Mutex
|
||||
shuttingDown atomic.Bool
|
||||
idleStop chan struct{}
|
||||
|
||||
credentialHandoffMu sync.Mutex
|
||||
terminalMu sync.RWMutex
|
||||
terminalReason string
|
||||
}
|
||||
|
||||
// closeOnceConn makes every connection close path idempotent. A live consumer
|
||||
@@ -330,7 +352,8 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
}
|
||||
|
||||
// handleConnection processes one IPC connection's full lifecycle: read
|
||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||
// Hello → optional runtime credential negotiation → register with Hub → spawn
|
||||
// writer goroutine → read until EOF/Bye.
|
||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
conn = ensureCloseOnce(conn)
|
||||
@@ -372,29 +395,141 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
// Regular consumer registration
|
||||
// HelloAck — credentials_source fields are filled in by the daemon
|
||||
// runner (which knows from the strict resolver) and exposed via the
|
||||
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
|
||||
// passes them through future config if the caller wishes.
|
||||
ack := d.helloAck()
|
||||
handoffLocked := false
|
||||
runtimeGeneration := uint64(0)
|
||||
defer func() {
|
||||
if handoffLocked {
|
||||
d.credentialHandoffMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Runtime credentials use a two-phase additive handshake. The first ack
|
||||
// proves capability before the client sends any secret. Only a successful
|
||||
// CAS and credential ack permit Hub registration.
|
||||
if hello.CredentialMode != "" {
|
||||
if hello.CredentialMode != transport.CredentialModeRuntimeToken || d.cfg.CredentialBroker == nil {
|
||||
ack.Capabilities = nil
|
||||
ack.CredentialGeneration = 0
|
||||
if err := w.WriteJSON(ack); err != nil {
|
||||
d.log.Warn("bus: incompatible helloack write failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
d.credentialHandoffMu.Lock()
|
||||
handoffLocked = true
|
||||
// Terminal state may have been published while this Hello waited for a
|
||||
// concurrent credential handoff. Rebuild the ack while holding the gate.
|
||||
ack = d.helloAck()
|
||||
if err := w.WriteJSON(ack); err != nil {
|
||||
d.log.Warn("bus: runtime helloack write failed", "err", err)
|
||||
return
|
||||
}
|
||||
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
|
||||
return
|
||||
}
|
||||
|
||||
var update transport.CredentialUpdate
|
||||
_ = conn.SetReadDeadline(time.Now().Add(daemonCredentialHandshakeTimeout))
|
||||
if err := r.ReadJSON(&update); err != nil {
|
||||
// Do not include the decoder error: malformed JSON may contain
|
||||
// fragments of the credential.
|
||||
d.log.Warn("bus: malformed runtime credential update")
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if update.Type != transport.FrameTypeCredentialUpdate {
|
||||
_ = w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: false,
|
||||
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
|
||||
ErrorCode: transport.CredentialErrorInvalid,
|
||||
Error: "unexpected credential update frame",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Validate registration before applying the credential or sending an
|
||||
// accepted ack. Hub.Register performs the same deterministic compile
|
||||
// before mutating the Hub; this preflight keeps invalid filters from
|
||||
// producing a ready marker after credential negotiation.
|
||||
if _, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID); err != nil {
|
||||
update.Token = ""
|
||||
_ = w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: false,
|
||||
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
|
||||
ErrorCode: transport.CredentialErrorRegistration,
|
||||
Error: "consumer registration validation failed",
|
||||
})
|
||||
d.log.Warn("bus: runtime consumer registration validation failed")
|
||||
return
|
||||
}
|
||||
|
||||
generation, updateErr := d.cfg.CredentialBroker.Update(update.ExpectedGeneration, update.Token)
|
||||
runtimeGeneration = generation
|
||||
update.Token = ""
|
||||
credentialAck := transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: updateErr == nil,
|
||||
CredentialGeneration: generation,
|
||||
}
|
||||
if updateErr != nil {
|
||||
credentialAck.ErrorCode, credentialAck.Error = classifyCredentialUpdateError(updateErr)
|
||||
}
|
||||
if err := w.WriteJSON(credentialAck); err != nil {
|
||||
d.log.Warn("bus: credential update ack write failed", "err", err)
|
||||
return
|
||||
}
|
||||
if updateErr != nil {
|
||||
d.log.Warn("bus: runtime credential update rejected", "error_code", credentialAck.ErrorCode)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Regular consumer registration. Local clients retain the original
|
||||
// register-before-HelloAck ordering; runtime clients were already acked by
|
||||
// the additive handshake above.
|
||||
c, err := d.hub.Register(hello)
|
||||
if err != nil {
|
||||
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// HelloAck — credentials_source fields are filled in by the daemon
|
||||
// runner (which knows from the strict resolver) and exposed via the
|
||||
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
|
||||
// passes them through future config if the caller wishes.
|
||||
idleSecs := int(d.cfg.IdleTimeout / time.Second)
|
||||
if err := w.WriteJSON(transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: os.Getpid(),
|
||||
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
|
||||
StateSource: "inferred",
|
||||
IdleTimeoutSecs: idleSecs,
|
||||
}); err != nil {
|
||||
d.log.Warn("bus: helloack write failed", "err", err)
|
||||
if handoffLocked {
|
||||
// The runtime broker deliberately keeps the seed pending until the
|
||||
// initiating consumer is registered. This prevents ticket acquisition
|
||||
// (and an immediate 401) from racing ahead of the only connection that
|
||||
// can observe the typed terminal reason.
|
||||
if _, activateErr := d.cfg.CredentialBroker.Activate(runtimeGeneration); activateErr != nil {
|
||||
d.log.Error("bus: runtime credential activation failed")
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "runtime_credential_activation_failed"})
|
||||
d.hub.Unregister(c.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A local/legacy consumer can arrive after terminal publication but after
|
||||
// StopAll took its snapshot. Refuse it synchronously so it cannot observe a
|
||||
// clean EOF for a runtime-token rejection.
|
||||
if terminalReason := d.getTerminalReason(); terminalReason != "" {
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: terminalReason})
|
||||
d.hub.Unregister(c.ID)
|
||||
return
|
||||
}
|
||||
if handoffLocked {
|
||||
d.credentialHandoffMu.Unlock()
|
||||
handoffLocked = false
|
||||
}
|
||||
if hello.CredentialMode == "" {
|
||||
if err := w.WriteJSON(ack); err != nil {
|
||||
d.log.Warn("bus: helloack write failed", "err", err)
|
||||
d.hub.Unregister(c.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Writer goroutine pulls from SendCh and writes to the wire.
|
||||
writerDone := make(chan struct{})
|
||||
@@ -416,6 +551,16 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A stop may have arrived while both channels were ready and the
|
||||
// scheduler selected the buffered event. Re-check before starting a
|
||||
// potentially blocking event write so terminal reasons stay prompt.
|
||||
select {
|
||||
case reason := <-c.StopCh:
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
|
||||
_ = conn.Close()
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := w.WriteJSON(frame); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -454,6 +599,51 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
_ = ctx // for future use (writer ctx-cancel propagation)
|
||||
}
|
||||
|
||||
func (d *daemon) helloAck() transport.HelloAck {
|
||||
ack := transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: os.Getpid(),
|
||||
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
|
||||
StateSource: "inferred",
|
||||
IdleTimeoutSecs: int(d.cfg.IdleTimeout / time.Second),
|
||||
}
|
||||
if d.cfg.CredentialBroker != nil {
|
||||
ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
ack.CredentialGeneration = d.cfg.CredentialBroker.Generation()
|
||||
}
|
||||
ack.TerminalReason = d.getTerminalReason()
|
||||
return ack
|
||||
}
|
||||
|
||||
func (d *daemon) setTerminalReason(reason string) {
|
||||
if d == nil || reason != transport.ByeReasonRuntimeTokenRejected {
|
||||
return
|
||||
}
|
||||
d.terminalMu.Lock()
|
||||
d.terminalReason = reason
|
||||
d.terminalMu.Unlock()
|
||||
}
|
||||
|
||||
func (d *daemon) getTerminalReason() string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
d.terminalMu.RLock()
|
||||
defer d.terminalMu.RUnlock()
|
||||
return d.terminalReason
|
||||
}
|
||||
|
||||
func classifyCredentialUpdateError(err error) (string, string) {
|
||||
var conflict *runtimecred.GenerationConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
return transport.CredentialErrorGenerationConflict, conflict.Error()
|
||||
}
|
||||
if errors.Is(err, runtimecred.ErrEmptyToken) || errors.Is(err, runtimecred.ErrTokenTooLarge) {
|
||||
return transport.CredentialErrorInvalid, err.Error()
|
||||
}
|
||||
return transport.CredentialErrorInternal, "runtime credential update failed"
|
||||
}
|
||||
|
||||
func (d *daemon) handleConsumerStopRPC(w *transport.Writer, r *transport.Reader) {
|
||||
var req transport.ConsumerStopReq
|
||||
if err := r.ReadJSON(&req); err != nil {
|
||||
@@ -577,38 +767,64 @@ func (d *daemon) triggerShutdown(reason string) {
|
||||
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
|
||||
// 5. close all accepted connections and wait for handlers to drain
|
||||
// 6. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
|
||||
func (d *daemon) shutdown(acceptDone <-chan struct{}, reasons ...string) {
|
||||
d.shutdownMu.Lock()
|
||||
defer d.shutdownMu.Unlock()
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||
reason := normalizedShutdownReason(reasons...)
|
||||
if reason == transport.ByeReasonRuntimeTokenRejected {
|
||||
d.hub.StopAll(reason)
|
||||
} else {
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
|
||||
}
|
||||
_ = d.listener.Close()
|
||||
<-acceptDone
|
||||
// Force-close all open IPC connections so any reader goroutine blocked
|
||||
// on Read() returns with a network error and exits cleanly. Without
|
||||
// this the consumerWG never drains and Run hangs forever.
|
||||
d.conns.Range(func(k, _ any) bool {
|
||||
if c, ok := k.(net.Conn); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Give consumers a brief moment to drain final frames before we tear
|
||||
// down their channels.
|
||||
// Let local consumers drain the final Bye before force-closing their
|
||||
// connections. This short grace period is what makes typed shutdown
|
||||
// reasons (notably runtime_token_rejected) observable instead of racing
|
||||
// with EOF. Consumers close their side immediately after reading Bye.
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
d.consumerWG.Wait()
|
||||
close(doneCh)
|
||||
}()
|
||||
select {
|
||||
case <-doneCh:
|
||||
return
|
||||
case <-time.After(daemonByeDrainTimeout):
|
||||
}
|
||||
|
||||
// A wedged/old consumer may not close after Bye. Force-close remaining
|
||||
// connections so the daemon still has a bounded shutdown.
|
||||
d.conns.Range(func(k, _ any) bool {
|
||||
if c, ok := k.(net.Conn); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(daemonShutdownTimeout):
|
||||
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
func sourceShutdownReason(err error) string {
|
||||
if errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
return transport.ByeReasonRuntimeTokenRejected
|
||||
}
|
||||
return "shutdown"
|
||||
}
|
||||
|
||||
func normalizedShutdownReason(reasons ...string) string {
|
||||
if len(reasons) > 0 && reasons[0] == transport.ByeReasonRuntimeTokenRejected {
|
||||
return transport.ByeReasonRuntimeTokenRejected
|
||||
}
|
||||
return "shutdown"
|
||||
}
|
||||
|
||||
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
|
||||
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
|
||||
func signalReady(p *os.File) {
|
||||
|
||||
@@ -246,6 +246,31 @@ func (h *Hub) StopConsumers(subscribeIDs []string, reason string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// StopAll requests a high-priority graceful close for every live consumer.
|
||||
// Unlike Broadcast(Bye), this uses the writer's priority StopCh and therefore
|
||||
// cannot sit behind a full event buffer during a terminal source failure.
|
||||
func (h *Hub) StopAll(reason string) int {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "shutdown"
|
||||
}
|
||||
h.mu.RLock()
|
||||
consumers := make([]*Consumer, 0, len(h.consumers))
|
||||
for _, consumer := range h.consumers {
|
||||
consumers = append(consumers, consumer)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
stopped := 0
|
||||
for _, consumer := range consumers {
|
||||
select {
|
||||
case consumer.StopCh <- reason:
|
||||
stopped++
|
||||
default:
|
||||
}
|
||||
}
|
||||
return stopped
|
||||
}
|
||||
|
||||
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
|
||||
// calling twice or on an unknown ID is a no-op. closeSend shares the same
|
||||
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
|
||||
|
||||
@@ -346,6 +346,27 @@ func TestHub_StopConsumersCoalescesQueuedStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHubStopAllBypassesFullEventBuffer(t *testing.T) {
|
||||
hub := NewHub(1)
|
||||
consumer, err := hub.Register(transport.Hello{Type: transport.FrameTypeHello})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumer.SendCh <- transport.Event{Type: transport.FrameTypeEvent}
|
||||
|
||||
if stopped := hub.StopAll(transport.ByeReasonRuntimeTokenRejected); stopped != 1 {
|
||||
t.Fatalf("StopAll() = %d, want 1", stopped)
|
||||
}
|
||||
select {
|
||||
case reason := <-consumer.StopCh:
|
||||
if reason != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("StopCh reason = %q", reason)
|
||||
}
|
||||
default:
|
||||
t.Fatal("terminal stop was blocked behind the full event buffer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ConcurrentStopConsumersRegisterUnregister(t *testing.T) {
|
||||
h := NewHub(4)
|
||||
const workers = 32
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type runtimeCredentialRejectSource struct {
|
||||
broker *runtimecred.Broker
|
||||
}
|
||||
|
||||
func (s *runtimeCredentialRejectSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
|
||||
if _, err := s.broker.Resolve(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}
|
||||
|
||||
func runtimeCredentialDaemon(broker *runtimecred.Broker, logOutput io.Writer) *daemon {
|
||||
if logOutput == nil {
|
||||
logOutput = io.Discard
|
||||
}
|
||||
return &daemon{
|
||||
cfg: Config{CredentialBroker: broker},
|
||||
log: slog.New(slog.NewTextHandler(logOutput, nil)),
|
||||
hub: NewHub(2),
|
||||
started: time.Now(),
|
||||
idleStop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func runRuntimeCredentialConnection(t *testing.T, d *daemon) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
|
||||
t.Helper()
|
||||
server, client := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.handleConnection(context.Background(), server)
|
||||
close(done)
|
||||
}()
|
||||
return client, transport.NewWriter(client), transport.NewReader(client), done
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonRuntimeCredentialHandshakeBeforeRegister(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
d := runtimeCredentialDaemon(broker, nil)
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
defer client.Close()
|
||||
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: 42,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var helloAck transport.HelloAck
|
||||
if err := r.ReadJSON(&helloAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasTransportCapability(helloAck.Capabilities, transport.CapabilityRuntimeTokenV1) || helloAck.CredentialGeneration != 0 {
|
||||
t.Fatalf("hello ack = %#v", helloAck)
|
||||
}
|
||||
if d.hub.Len() != 0 {
|
||||
t.Fatalf("consumer registered before credential update: %d", d.hub.Len())
|
||||
}
|
||||
|
||||
const canary = "ipc-canary-runtime-token"
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: helloAck.CredentialGeneration,
|
||||
Token: canary,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updateAck.Accepted || updateAck.CredentialGeneration != 1 {
|
||||
t.Fatalf("credential ack = %#v", updateAck)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for d.hub.Len() != 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if d.hub.Len() != 1 {
|
||||
t.Fatal("consumer was not registered after credential ack")
|
||||
}
|
||||
if token, err := broker.Resolve(context.Background()); err != nil || token != canary {
|
||||
t.Fatalf("broker did not resolve installed runtime token: %v", err)
|
||||
}
|
||||
|
||||
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonCompatibleBusRotatesRuntimeCredentialAcrossConnections(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
d := runtimeCredentialDaemon(broker, nil)
|
||||
|
||||
handshake := func(token string, wantHelloGeneration, wantAckGeneration uint64) {
|
||||
t.Helper()
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var helloAck transport.HelloAck
|
||||
if err := r.ReadJSON(&helloAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if helloAck.CredentialGeneration != wantHelloGeneration {
|
||||
t.Fatalf("hello generation = %d, want %d", helloAck.CredentialGeneration, wantHelloGeneration)
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: helloAck.CredentialGeneration,
|
||||
Token: token,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updateAck.Accepted || updateAck.CredentialGeneration != wantAckGeneration {
|
||||
t.Fatalf("credential ack accepted=%v generation=%d, want true/%d", updateAck.Accepted, updateAck.CredentialGeneration, wantAckGeneration)
|
||||
}
|
||||
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection did not close")
|
||||
}
|
||||
}
|
||||
|
||||
handshake("runtime-token-a", 0, 1)
|
||||
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-a" {
|
||||
t.Fatalf("broker did not retain first token: %v", err)
|
||||
}
|
||||
handshake("runtime-token-b", 1, 2)
|
||||
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-b" {
|
||||
t.Fatalf("broker did not rotate to second token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonRuntimeCredentialMissingCapabilityDoesNotRegister(t *testing.T) {
|
||||
d := runtimeCredentialDaemon(nil, nil)
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasTransportCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
|
||||
t.Fatalf("unsupported daemon advertised capability: %#v", ack)
|
||||
}
|
||||
if d.hub.Len() != 0 {
|
||||
t.Fatalf("unsupported daemon registered consumer: %d", d.hub.Len())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("unsupported connection did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonTerminalStateRejectsLateLegacyConsumer(t *testing.T) {
|
||||
d := runtimeCredentialDaemon(nil, nil)
|
||||
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
defer client.Close()
|
||||
|
||||
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, ConsumerPID: 7}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("late consumer frame = %#v", bye)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("late consumer connection did not close")
|
||||
}
|
||||
if d.hub.Len() != 0 {
|
||||
t.Fatalf("late terminal consumer remained registered: %d", d.hub.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonRuntimeCredentialConflictDoesNotLeakOrRegister(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{})
|
||||
if _, err := broker.Update(0, "installed-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs bytes.Buffer
|
||||
d := runtimeCredentialDaemon(broker, &logs)
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var helloAck transport.HelloAck
|
||||
if err := r.ReadJSON(&helloAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const canary = "rejected-canary-secret"
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: 0,
|
||||
Token: canary,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorGenerationConflict || updateAck.CredentialGeneration != 1 {
|
||||
t.Fatalf("conflict ack = %#v", updateAck)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("rejected connection did not close")
|
||||
}
|
||||
if strings.Contains(updateAck.Error, canary) || strings.Contains(logs.String(), canary) {
|
||||
t.Fatal("credential appeared in acknowledgement or logs")
|
||||
}
|
||||
if d.hub.Len() != 0 {
|
||||
t.Fatalf("rejected consumer registered: %d", d.hub.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonRuntimeCredentialInvalidFilterRejectedBeforeUpdate(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{})
|
||||
d := runtimeCredentialDaemon(broker, nil)
|
||||
client, w, r, done := runRuntimeCredentialConnection(t, d)
|
||||
defer client.Close()
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
Filter: "[",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var helloAck transport.HelloAck
|
||||
if err := r.ReadJSON(&helloAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: helloAck.CredentialGeneration,
|
||||
Token: "filter-canary-secret",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorRegistration {
|
||||
t.Fatalf("invalid filter ack = %#v", updateAck)
|
||||
}
|
||||
if broker.Generation() != 0 || d.hub.Len() != 0 {
|
||||
t.Fatalf("invalid filter mutated state: generation=%d consumers=%d", broker.Generation(), d.hub.Len())
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("invalid filter connection did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeCredentialShutdownReason(t *testing.T) {
|
||||
if got := sourceShutdownReason(runtimecred.ErrRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("runtime source shutdown reason = %q", got)
|
||||
}
|
||||
if got := sourceShutdownReason(errors.New("local source failed")); got != "shutdown" {
|
||||
t.Fatalf("local source shutdown reason = %q", got)
|
||||
}
|
||||
if got := normalizedShutdownReason(transport.ByeReasonRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("normalized runtime shutdown reason = %q", got)
|
||||
}
|
||||
for _, reasons := range [][]string{nil, {"peer-controlled"}} {
|
||||
if got := normalizedShutdownReason(reasons...); got != "shutdown" {
|
||||
t.Fatalf("normalized untrusted shutdown reason = %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonRuntimeCredentialSourceRejectionBroadcastsTypedBye(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket dial")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "runtime-client",
|
||||
Edition: "open",
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: "0123456789abcdef",
|
||||
SourceID: "open",
|
||||
Source: &runtimeCredentialRejectSource{broker: broker},
|
||||
CredentialBroker: broker,
|
||||
})
|
||||
}()
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
conn, err := transport.Dial(sockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
CredentialMode: transport.CredentialModeRuntimeToken,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var helloAck transport.HelloAck
|
||||
if err := r.ReadJSON(&helloAck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: helloAck.CredentialGeneration,
|
||||
Token: "runtime-rejected-canary",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
|
||||
t.Fatalf("credential update ack = %#v, %v", updateAck, err)
|
||||
}
|
||||
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
t.Fatalf("read typed shutdown: %v", err)
|
||||
}
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
|
||||
t.Fatalf("shutdown frame = %#v", bye)
|
||||
}
|
||||
_ = conn.Close()
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("runtime credential bus did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func hasTransportCapability(capabilities []string, want string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func eventCoreCredentialReader(t *testing.T, frame any) *transport.Reader {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
if err := transport.NewWriter(&buffer).WriteJSON(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return transport.NewReader(&buffer)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreRuntimeNegotiationEdges(t *testing.T) {
|
||||
capable := transport.HelloAck{Capabilities: []string{transport.CapabilityRuntimeTokenV1}}
|
||||
if err := negotiateRuntimeToken(
|
||||
transport.NewWriter(io.Discard),
|
||||
transport.NewReader(strings.NewReader("")),
|
||||
transport.HelloAck{TerminalReason: transport.ByeReasonRuntimeTokenRejected},
|
||||
"token",
|
||||
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("terminal hello error = %v", err)
|
||||
}
|
||||
|
||||
wantWriteErr := errors.New("write failed")
|
||||
if err := negotiateRuntimeToken(
|
||||
transport.NewWriter(errorWriter{err: wantWriteErr}),
|
||||
transport.NewReader(strings.NewReader("")),
|
||||
capable,
|
||||
"token",
|
||||
); !errors.Is(err, wantWriteErr) {
|
||||
t.Fatalf("credential write error = %v", err)
|
||||
}
|
||||
|
||||
if err := negotiateRuntimeToken(
|
||||
transport.NewWriter(io.Discard),
|
||||
transport.NewReader(strings.NewReader("")),
|
||||
capable,
|
||||
"token",
|
||||
); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("credential ack read error = %v", err)
|
||||
}
|
||||
|
||||
if err := negotiateRuntimeToken(
|
||||
transport.NewWriter(io.Discard),
|
||||
eventCoreCredentialReader(t, transport.Heartbeat{Type: transport.FrameTypeHeartbeat}),
|
||||
capable,
|
||||
"token",
|
||||
); err == nil || !strings.Contains(err.Error(), "unexpected runtime credential response") {
|
||||
t.Fatalf("unexpected credential frame error = %v", err)
|
||||
}
|
||||
|
||||
if err := negotiateRuntimeToken(
|
||||
transport.NewWriter(io.Discard),
|
||||
eventCoreCredentialReader(t, transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: false,
|
||||
ErrorCode: transport.CredentialErrorRuntimeRejected,
|
||||
}),
|
||||
capable,
|
||||
"token",
|
||||
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("runtime rejected ack error = %v", err)
|
||||
}
|
||||
|
||||
for _, code := range []string{
|
||||
transport.CredentialErrorGenerationConflict,
|
||||
transport.CredentialErrorInvalid,
|
||||
transport.CredentialErrorRegistration,
|
||||
transport.CredentialErrorRuntimeRejected,
|
||||
transport.CredentialErrorInternal,
|
||||
} {
|
||||
if got := safeCredentialErrorCode(code); got != code {
|
||||
t.Fatalf("safeCredentialErrorCode(%q) = %q", code, got)
|
||||
}
|
||||
}
|
||||
if got := safeCredentialErrorCode("peer-controlled"); got != transport.CredentialErrorInternal {
|
||||
t.Fatalf("unknown credential error code = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreRunManyHandshakeFailure(t *testing.T) {
|
||||
bus := newManyFakeBus(901, nil)
|
||||
installManyDiscover(t, bus)
|
||||
cfg := manyTestConfig(io.Discard, io.Discard)
|
||||
cfg.RuntimeToken = "runtime-token"
|
||||
err := RunMany(context.Background(), cfg, manyTestSpecs())
|
||||
if !errors.Is(err, ErrRuntimeTokenUnsupported) || !strings.Contains(err.Error(), "runtime credential handshake") {
|
||||
t.Fatalf("RunMany handshake error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -45,6 +47,11 @@ type Config struct {
|
||||
// personal_stream.
|
||||
SpawnExtraArgs []string
|
||||
|
||||
// RuntimeToken is a host-supplied credential handed to a compatible bus
|
||||
// only after capability negotiation over owner-only local IPC. It is never
|
||||
// included in dry-run output, child argv, environment, or persisted state.
|
||||
RuntimeToken string `json:"-" yaml:"-"`
|
||||
|
||||
// EventTypes / Filter / Compact are forwarded to the bus via Hello
|
||||
// for server-side pushdown filtering.
|
||||
EventTypes []string
|
||||
@@ -132,6 +139,36 @@ type Config struct {
|
||||
|
||||
var discoverBus = busctl.Discover
|
||||
|
||||
var (
|
||||
ErrRuntimeTokenUnsupported = errors.New("consume: event bus does not support secure runtime-token handoff")
|
||||
ErrRuntimeTokenUpdate = errors.New("consume: event bus rejected runtime-token update")
|
||||
)
|
||||
|
||||
// RuntimeTokenUnsupportedError is returned before the token is sent when the
|
||||
// connected bus lacks the runtime_token_v1 capability.
|
||||
type RuntimeTokenUnsupportedError struct {
|
||||
BusPID int
|
||||
}
|
||||
|
||||
func (e *RuntimeTokenUnsupportedError) Error() string {
|
||||
return fmt.Sprintf("consume: running event bus (pid %d) does not support secure runtime-token handoff; let existing consumers exit, inspect with `dws event status --as user --format json`, preview cleanup with `dws event stop --as user --all --dry-run`, then confirm with `dws event stop --as user --all --yes` and retry", e.BusPID)
|
||||
}
|
||||
|
||||
func (e *RuntimeTokenUnsupportedError) Unwrap() error { return ErrRuntimeTokenUnsupported }
|
||||
|
||||
// RuntimeTokenUpdateError reports a rejected credential CAS without carrying
|
||||
// either the credential or peer-provided free-form error text.
|
||||
type RuntimeTokenUpdateError struct {
|
||||
Code string
|
||||
Generation uint64
|
||||
}
|
||||
|
||||
func (e *RuntimeTokenUpdateError) Error() string {
|
||||
return fmt.Sprintf("consume: event bus rejected runtime-token update (code=%s, generation=%d)", e.Code, e.Generation)
|
||||
}
|
||||
|
||||
func (e *RuntimeTokenUpdateError) Unwrap() error { return ErrRuntimeTokenUpdate }
|
||||
|
||||
// Run dials the bus (forking one if necessary), sends Hello, and writes
|
||||
// each received Event frame as one NDJSON line to stdout. Blocks until
|
||||
// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the
|
||||
@@ -144,6 +181,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
|
||||
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
|
||||
}
|
||||
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
|
||||
if cfg.Stdout == nil {
|
||||
cfg.Stdout = os.Stdout
|
||||
}
|
||||
@@ -225,6 +263,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
SubscribeID: cfg.SubscribeID,
|
||||
Compact: cfg.Compact,
|
||||
}
|
||||
if cfg.RuntimeToken != "" {
|
||||
hello.CredentialMode = transport.CredentialModeRuntimeToken
|
||||
}
|
||||
if err := w.WriteJSON(hello); err != nil {
|
||||
return fmt.Errorf("consume: write hello: %w", err)
|
||||
}
|
||||
@@ -236,6 +277,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
if ack.Type != transport.FrameTypeHelloAck {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
|
||||
}
|
||||
if err := negotiateRuntimeToken(w, r, ack, cfg.RuntimeToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
@@ -331,6 +375,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(raw, &bye)
|
||||
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
|
||||
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
|
||||
}
|
||||
@@ -350,6 +397,62 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
func negotiateRuntimeToken(w *transport.Writer, r *transport.Reader, ack transport.HelloAck, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
|
||||
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
|
||||
}
|
||||
if !hasCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
|
||||
return &RuntimeTokenUnsupportedError{BusPID: ack.BusPID}
|
||||
}
|
||||
if err := w.WriteJSON(transport.CredentialUpdate{
|
||||
Type: transport.FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: ack.CredentialGeneration,
|
||||
Token: token,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("consume: write runtime credential update: %w", err)
|
||||
}
|
||||
var updateAck transport.CredentialUpdateAck
|
||||
if err := r.ReadJSON(&updateAck); err != nil {
|
||||
return fmt.Errorf("consume: read runtime credential update ack: %w", err)
|
||||
}
|
||||
if updateAck.Type != transport.FrameTypeCredentialUpdateAck {
|
||||
return errors.New("consume: unexpected runtime credential response frame")
|
||||
}
|
||||
if !updateAck.Accepted {
|
||||
code := safeCredentialErrorCode(updateAck.ErrorCode)
|
||||
if code == transport.CredentialErrorRuntimeRejected {
|
||||
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
|
||||
}
|
||||
return &RuntimeTokenUpdateError{Code: code, Generation: updateAck.CredentialGeneration}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeCredentialErrorCode(code string) string {
|
||||
switch code {
|
||||
case transport.CredentialErrorGenerationConflict,
|
||||
transport.CredentialErrorInvalid,
|
||||
transport.CredentialErrorRegistration,
|
||||
transport.CredentialErrorRuntimeRejected,
|
||||
transport.CredentialErrorInternal:
|
||||
return code
|
||||
default:
|
||||
return transport.CredentialErrorInternal
|
||||
}
|
||||
}
|
||||
|
||||
func hasCapability(capabilities []string, want string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// closeOnContext spawns a goroutine that closes conn when ctx is done.
|
||||
// This unblocks any pending Read on conn so the main loop can return.
|
||||
func closeOnContext(ctx context.Context, conn net.Conn) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,7 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
|
||||
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
|
||||
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
|
||||
}
|
||||
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
|
||||
if len(specs) < 2 {
|
||||
return errors.New("consume: RunMany requires at least two consumers")
|
||||
}
|
||||
@@ -127,14 +129,18 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
closeOnContext(ctx, session.conn)
|
||||
if err := session.w.WriteJSON(transport.Hello{
|
||||
hello := transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
EventTypes: spec.EventTypes,
|
||||
Filter: spec.Filter,
|
||||
SubscribeID: spec.SubscribeID,
|
||||
Compact: cfg.Compact,
|
||||
}); err != nil {
|
||||
}
|
||||
if cfg.RuntimeToken != "" {
|
||||
hello.CredentialMode = transport.CredentialModeRuntimeToken
|
||||
}
|
||||
if err := session.w.WriteJSON(hello); err != nil {
|
||||
return fmt.Errorf("consume: write hello for %s: %w", spec.EventKey, err)
|
||||
}
|
||||
if err := session.r.ReadJSON(&session.ack); err != nil {
|
||||
@@ -143,9 +149,16 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
|
||||
if session.ack.Type != transport.FrameTypeHelloAck {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q for %s", session.ack.Type, spec.EventKey)
|
||||
}
|
||||
// Verify that every connection reached the same bus before handing a
|
||||
// runtime credential to it. Discovery is expected to converge on one
|
||||
// daemon, but a stale endpoint/race must not propagate the host token to
|
||||
// an unrelated process merely so we can report the PID mismatch later.
|
||||
if len(sessions) > 1 && session.ack.BusPID != sessions[0].ack.BusPID {
|
||||
return fmt.Errorf("consume: consumers connected to different bus processes (%d and %d)", sessions[0].ack.BusPID, session.ack.BusPID)
|
||||
}
|
||||
if err := negotiateRuntimeToken(session.w, session.r, session.ack, cfg.RuntimeToken); err != nil {
|
||||
return fmt.Errorf("consume: runtime credential handshake for %s: %w", spec.EventKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
@@ -225,6 +238,9 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(frame.raw, &bye)
|
||||
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
|
||||
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
|
||||
}
|
||||
if bye.Reason == transport.ByeReasonSubscriptionStopped {
|
||||
delete(active, frame.index)
|
||||
_ = sessions[frame.index].conn.Close()
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -37,23 +38,25 @@ func (b *synchronizedBuffer) String() string {
|
||||
}
|
||||
|
||||
type manyFakeBus struct {
|
||||
client net.Conn
|
||||
server net.Conn
|
||||
hello chan transport.Hello
|
||||
acked chan struct{}
|
||||
ackGate <-chan struct{}
|
||||
ack transport.HelloAck
|
||||
send chan any
|
||||
client net.Conn
|
||||
server net.Conn
|
||||
hello chan transport.Hello
|
||||
credentialUpdate chan transport.CredentialUpdate
|
||||
acked chan struct{}
|
||||
ackGate <-chan struct{}
|
||||
ack transport.HelloAck
|
||||
send chan any
|
||||
}
|
||||
|
||||
func newManyFakeBus(busPID int, ackGate <-chan struct{}) *manyFakeBus {
|
||||
client, server := net.Pipe()
|
||||
f := &manyFakeBus{
|
||||
client: client,
|
||||
server: server,
|
||||
hello: make(chan transport.Hello, 1),
|
||||
acked: make(chan struct{}),
|
||||
ackGate: ackGate,
|
||||
client: client,
|
||||
server: server,
|
||||
hello: make(chan transport.Hello, 1),
|
||||
credentialUpdate: make(chan transport.CredentialUpdate, 1),
|
||||
acked: make(chan struct{}),
|
||||
ackGate: ackGate,
|
||||
ack: transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: busPID,
|
||||
@@ -85,6 +88,23 @@ func (f *manyFakeBus) serve() {
|
||||
if err := w.WriteJSON(f.ack); err != nil {
|
||||
return
|
||||
}
|
||||
if hello.CredentialMode == transport.CredentialModeRuntimeToken {
|
||||
if !hasCapability(f.ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
|
||||
return
|
||||
}
|
||||
var update transport.CredentialUpdate
|
||||
if err := r.ReadJSON(&update); err != nil {
|
||||
return
|
||||
}
|
||||
f.credentialUpdate <- update
|
||||
if err := w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: true,
|
||||
CredentialGeneration: f.ack.CredentialGeneration + 1,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
close(f.acked)
|
||||
go func() {
|
||||
for {
|
||||
@@ -221,6 +241,91 @@ func TestRunManyWaitsForAllConsumersAndStopsOneAtATime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunManyRuntimeTokenUsesEachConnectionGeneration(t *testing.T) {
|
||||
const canary = "many-runtime-canary"
|
||||
busA := newManyFakeBus(111, nil)
|
||||
busB := newManyFakeBus(111, nil)
|
||||
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
busA.ack.CredentialGeneration = 2
|
||||
busB.ack.CredentialGeneration = 8
|
||||
installManyDiscover(t, busA, busB)
|
||||
|
||||
cfg := manyTestConfig(io.Discard, io.Discard)
|
||||
cfg.RuntimeToken = canary
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
|
||||
|
||||
updateA := <-busA.credentialUpdate
|
||||
updateB := <-busB.credentialUpdate
|
||||
if updateA.Token != canary || updateA.ExpectedGeneration != 2 {
|
||||
t.Fatal("first connection used the wrong credential or generation")
|
||||
}
|
||||
if updateB.Token != canary || updateB.ExpectedGeneration != 8 {
|
||||
t.Fatal("second connection used the wrong credential or generation")
|
||||
}
|
||||
busA.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RunMany did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectsDifferentBusBeforeSecondCredential(t *testing.T) {
|
||||
const canary = "many-mismatched-bus-canary"
|
||||
busA := newManyFakeBus(111, nil)
|
||||
busB := newManyFakeBus(222, nil)
|
||||
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
installManyDiscover(t, busA, busB)
|
||||
|
||||
cfg := manyTestConfig(io.Discard, io.Discard)
|
||||
cfg.RuntimeToken = canary
|
||||
err := RunMany(context.Background(), cfg, manyTestSpecs())
|
||||
if err == nil || !strings.Contains(err.Error(), "different bus processes") {
|
||||
t.Fatalf("RunMany() error = %v", err)
|
||||
}
|
||||
if update := <-busA.credentialUpdate; update.Token != canary {
|
||||
t.Fatal("first bus did not receive the negotiated credential")
|
||||
}
|
||||
select {
|
||||
case update := <-busB.credentialUpdate:
|
||||
t.Fatalf("mismatched second bus received credential: generation=%d", update.ExpectedGeneration)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
|
||||
busA := newManyFakeBus(333, nil)
|
||||
busB := newManyFakeBus(333, nil)
|
||||
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
|
||||
installManyDiscover(t, busA, busB)
|
||||
|
||||
cfg := manyTestConfig(io.Discard, io.Discard)
|
||||
cfg.RuntimeToken = "many-runtime-rejected-canary"
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
|
||||
<-busA.credentialUpdate
|
||||
<-busB.credentialUpdate
|
||||
busA.send <- transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: transport.ByeReasonRuntimeTokenRejected,
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("RunMany() error = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RunMany did not return runtime credential rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunManyMaxEventsIsSharedAcrossConsumers(t *testing.T) {
|
||||
busA := newManyFakeBus(202, nil)
|
||||
busB := newManyFakeBus(202, nil)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func installRuntimeCredentialDiscover(t *testing.T, serve func(net.Conn)) {
|
||||
t.Helper()
|
||||
oldDiscover := discoverBus
|
||||
done := make(chan struct{})
|
||||
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer server.Close()
|
||||
serve(server)
|
||||
}()
|
||||
return client, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverBus = oldDiscover
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("fake runtime credential bus did not stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRuntimeTokenNegotiatesBeforeReady(t *testing.T) {
|
||||
const canary = "consume-runtime-canary"
|
||||
helloSeen := make(chan transport.Hello, 1)
|
||||
updateSeen := make(chan transport.CredentialUpdate, 1)
|
||||
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
return
|
||||
}
|
||||
helloSeen <- hello
|
||||
_ = w.WriteJSON(transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: 71,
|
||||
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
|
||||
CredentialGeneration: 3,
|
||||
})
|
||||
var update transport.CredentialUpdate
|
||||
if err := r.ReadJSON(&update); err != nil {
|
||||
return
|
||||
}
|
||||
updateSeen <- update
|
||||
_ = w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: true,
|
||||
CredentialGeneration: 4,
|
||||
})
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
|
||||
})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = " " + canary + " "
|
||||
cfg.Stderr = &stderr
|
||||
if err := Run(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello := <-helloSeen
|
||||
if hello.CredentialMode != transport.CredentialModeRuntimeToken {
|
||||
t.Fatalf("credential mode = %q", hello.CredentialMode)
|
||||
}
|
||||
update := <-updateSeen
|
||||
if update.ExpectedGeneration != 3 || update.Token != canary {
|
||||
t.Fatal("credential update used the wrong token or generation")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[event] ready bus_pid=71") {
|
||||
t.Fatalf("ready marker missing: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRuntimeTokenMissingCapabilityFailsBeforeSendingSecretOrReady(t *testing.T) {
|
||||
const canary = "unsupported-canary-secret"
|
||||
peerBytes := make(chan string, 1)
|
||||
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 72})
|
||||
raw, _ := io.ReadAll(conn)
|
||||
peerBytes <- string(raw)
|
||||
})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = canary
|
||||
cfg.Stderr = &stderr
|
||||
err := Run(context.Background(), cfg)
|
||||
if !errors.Is(err, ErrRuntimeTokenUnsupported) {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatal("unsupported-bus error contained runtime token")
|
||||
}
|
||||
for _, recoveryStep := range []string{
|
||||
"dws event status --as user --format json",
|
||||
"dws event stop --as user --all --dry-run",
|
||||
"dws event stop --as user --all --yes",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), recoveryStep) {
|
||||
t.Fatalf("unsupported-bus error missing recovery step %q: %v", recoveryStep, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stderr.String(), "[event] ready") {
|
||||
t.Fatal("ready marker was written before capability negotiation succeeded")
|
||||
}
|
||||
if got := <-peerBytes; strings.Contains(got, canary) || got != "" {
|
||||
t.Fatal("client sent data after unsupported capability acknowledgement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRuntimeTokenRejectedDoesNotSurfacePeerText(t *testing.T) {
|
||||
const canary = "rejected-canary-secret"
|
||||
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.WriteJSON(transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
|
||||
})
|
||||
var update transport.CredentialUpdate
|
||||
if err := r.ReadJSON(&update); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: false,
|
||||
ErrorCode: "malicious-code-" + update.Token,
|
||||
Error: "malicious echo " + update.Token,
|
||||
})
|
||||
})
|
||||
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = canary
|
||||
err := Run(context.Background(), cfg)
|
||||
if !errors.Is(err, ErrRuntimeTokenUpdate) {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatal("credential update error contained peer-provided token text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenNeverAppearsInDryRun(t *testing.T) {
|
||||
const canary = "dry-run-canary-secret"
|
||||
var output bytes.Buffer
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = canary
|
||||
PrintDryRun(&output, cfg)
|
||||
if strings.Contains(output.String(), canary) || strings.Contains(output.String(), "RuntimeToken") {
|
||||
t.Fatal("dry-run output contained runtime-token data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunWhitespaceRuntimeTokenUsesLegacyHandshake(t *testing.T) {
|
||||
helloSeen := make(chan transport.Hello, 1)
|
||||
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
return
|
||||
}
|
||||
helloSeen <- hello
|
||||
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck})
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
|
||||
})
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = " "
|
||||
if err := Run(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hello := <-helloSeen; hello.CredentialMode != "" {
|
||||
t.Fatalf("whitespace token enabled mode %q", hello.CredentialMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
|
||||
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
|
||||
r, w := transport.NewReader(conn), transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.WriteJSON(transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: 73,
|
||||
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
|
||||
})
|
||||
var update transport.CredentialUpdate
|
||||
if err := r.ReadJSON(&update); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.WriteJSON(transport.CredentialUpdateAck{
|
||||
Type: transport.FrameTypeCredentialUpdateAck,
|
||||
Accepted: true,
|
||||
CredentialGeneration: 1,
|
||||
})
|
||||
_ = w.WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: transport.ByeReasonRuntimeTokenRejected,
|
||||
})
|
||||
})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cfg := validRunConfig()
|
||||
cfg.RuntimeToken = "runtime-rejected-canary"
|
||||
cfg.Stderr = &stderr
|
||||
err := Run(context.Background(), cfg)
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "reason: bus_shutdown") {
|
||||
t.Fatalf("runtime rejection was reported as a successful exit: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,9 @@ func IsValidationError(err error) bool {
|
||||
// human-readable block. Called by Run when cfg.DryRun is true. Format
|
||||
// avoids JSON so users can `dws event consume --dry-run | head` cleanly.
|
||||
//
|
||||
// Secret-bearing fields are never present in Config (credentials never
|
||||
// reach this layer), so no redaction is required here.
|
||||
// RuntimeToken is the only secret-bearing Config field and is deliberately
|
||||
// not read or rendered here. Keep this function allowlist-based: never switch
|
||||
// it to generic struct serialization.
|
||||
func PrintDryRun(w io.Writer, cfg Config) {
|
||||
if w == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// 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 runtimecred provides an in-memory credential broker for event bus
|
||||
// processes. Runtime credentials are never persisted by this package.
|
||||
package runtimecred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultMaxTokenBytes bounds credentials accepted over the local IPC
|
||||
// channel. Access tokens are normally only a few KiB; the larger limit leaves
|
||||
// ample room for future token formats while avoiding accidental large secret
|
||||
// allocations.
|
||||
const DefaultMaxTokenBytes = 64 << 10
|
||||
|
||||
var (
|
||||
ErrEmptyToken = errors.New("runtime credential: token is empty")
|
||||
ErrTokenTooLarge = errors.New("runtime credential: token exceeds size limit")
|
||||
ErrCredentialUnavailable = errors.New("runtime credential: no credential resolver is available")
|
||||
ErrLocalRefreshUnavailable = errors.New("runtime credential: no local refresh callback is available")
|
||||
ErrRuntimeTokenRejected = &RuntimeTokenRejectedError{}
|
||||
)
|
||||
|
||||
// RuntimeTokenRejectedError means the currently installed runtime token was
|
||||
// rejected and no newer runtime token is available. It deliberately carries
|
||||
// no token or server response data so it is safe to surface to users and logs.
|
||||
type RuntimeTokenRejectedError struct{}
|
||||
|
||||
func (*RuntimeTokenRejectedError) Error() string {
|
||||
return "event runtime token was rejected; retry with a fresh host credential"
|
||||
}
|
||||
|
||||
func (*RuntimeTokenRejectedError) Is(target error) bool {
|
||||
_, ok := target.(*RuntimeTokenRejectedError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GenerationConflictError reports a failed compare-and-swap update.
|
||||
type GenerationConflictError struct {
|
||||
Expected uint64
|
||||
Actual uint64
|
||||
}
|
||||
|
||||
func (e *GenerationConflictError) Error() string {
|
||||
return fmt.Sprintf("runtime credential: generation conflict (expected %d, actual %d)", e.Expected, e.Actual)
|
||||
}
|
||||
|
||||
// ResolveFunc resolves the existing local OAuth credential when no runtime
|
||||
// credential has been installed.
|
||||
type ResolveFunc func(context.Context) (string, error)
|
||||
|
||||
// RefreshFunc refreshes a rejected local OAuth credential. It is never called
|
||||
// after a runtime credential has been installed.
|
||||
type RefreshFunc func(context.Context, string) (string, error)
|
||||
|
||||
type Config struct {
|
||||
LocalResolve ResolveFunc
|
||||
LocalRefresh RefreshFunc
|
||||
RequireSeed bool
|
||||
// RequireActivation keeps the first installed runtime credential pending
|
||||
// until Activate is called. Detached buses use it to register the consumer
|
||||
// before ticket acquisition can emit or fail.
|
||||
RequireActivation bool
|
||||
MaxTokenBytes int
|
||||
}
|
||||
|
||||
// Broker holds at most one runtime credential. All state, including the
|
||||
// credential generation, is process-local and concurrency-safe.
|
||||
type Broker struct {
|
||||
localResolve ResolveFunc
|
||||
localRefresh RefreshFunc
|
||||
requireSeed bool
|
||||
requireActivation bool
|
||||
maxBytes int
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
generation uint64
|
||||
active bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func New(cfg Config) *Broker {
|
||||
maxBytes := cfg.MaxTokenBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxTokenBytes
|
||||
}
|
||||
return &Broker{
|
||||
localResolve: cfg.LocalResolve,
|
||||
localRefresh: cfg.LocalRefresh,
|
||||
requireSeed: cfg.RequireSeed,
|
||||
requireActivation: cfg.RequireActivation,
|
||||
maxBytes: maxBytes,
|
||||
active: !cfg.RequireActivation,
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Generation returns the current runtime credential generation. Generation 0
|
||||
// means that no runtime credential has been installed yet.
|
||||
func (b *Broker) Generation() uint64 {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.generation
|
||||
}
|
||||
|
||||
// Update atomically installs token when expectedGeneration matches the
|
||||
// current generation. Reinstalling the same token is idempotent, including
|
||||
// when another concurrent writer already installed it.
|
||||
func (b *Broker) Update(expectedGeneration uint64, token string) (uint64, error) {
|
||||
if b == nil {
|
||||
return 0, ErrCredentialUnavailable
|
||||
}
|
||||
normalized, err := b.validate(token)
|
||||
if err != nil {
|
||||
return b.Generation(), err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.token == normalized {
|
||||
return b.generation, nil
|
||||
}
|
||||
if expectedGeneration != b.generation {
|
||||
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
|
||||
}
|
||||
b.token = normalized
|
||||
b.generation++
|
||||
if b.active {
|
||||
b.signalChangedLocked()
|
||||
}
|
||||
return b.generation, nil
|
||||
}
|
||||
|
||||
// Activate publishes a pending first runtime credential after the bus has
|
||||
// registered the initiating consumer. It is an idempotent generation-checked
|
||||
// no-op for brokers that do not require activation.
|
||||
func (b *Broker) Activate(expectedGeneration uint64) (uint64, error) {
|
||||
if b == nil {
|
||||
return 0, ErrCredentialUnavailable
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if expectedGeneration != b.generation {
|
||||
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
|
||||
}
|
||||
if b.token == "" {
|
||||
return b.generation, ErrCredentialUnavailable
|
||||
}
|
||||
if b.active {
|
||||
return b.generation, nil
|
||||
}
|
||||
b.active = true
|
||||
b.signalChangedLocked()
|
||||
return b.generation, nil
|
||||
}
|
||||
|
||||
// Resolve returns the runtime credential when installed. In RequireSeed mode
|
||||
// it waits until Update installs one; otherwise it preserves the existing
|
||||
// local resolver behavior until a runtime credential arrives.
|
||||
func (b *Broker) Resolve(ctx context.Context) (string, error) {
|
||||
if b == nil {
|
||||
return "", ErrCredentialUnavailable
|
||||
}
|
||||
for {
|
||||
token, wait, requireSeed, active := b.snapshot()
|
||||
if token != "" && active {
|
||||
return token, nil
|
||||
}
|
||||
if requireSeed || (token != "" && !active) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-wait:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if b.localResolve == nil {
|
||||
return "", ErrCredentialUnavailable
|
||||
}
|
||||
local, err := b.localResolve(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
|
||||
return runtime, nil
|
||||
}
|
||||
return validateResolved(local)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRejected returns a newer runtime token if one was installed after
|
||||
// rejectedToken was used. If the installed runtime token itself was rejected,
|
||||
// it returns RuntimeTokenRejectedError and never invokes local OAuth refresh.
|
||||
func (b *Broker) RefreshRejected(ctx context.Context, rejectedToken string) (string, error) {
|
||||
if b == nil {
|
||||
return "", ErrCredentialUnavailable
|
||||
}
|
||||
for {
|
||||
token, wait, requireSeed, active := b.snapshot()
|
||||
if token != "" && active {
|
||||
if token != rejectedToken {
|
||||
return token, nil
|
||||
}
|
||||
return "", &RuntimeTokenRejectedError{}
|
||||
}
|
||||
if requireSeed || (token != "" && !active) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-wait:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if b.localRefresh == nil {
|
||||
return "", ErrLocalRefreshUnavailable
|
||||
}
|
||||
refreshed, err := b.localRefresh(ctx, rejectedToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
|
||||
if runtime != rejectedToken {
|
||||
return runtime, nil
|
||||
}
|
||||
return "", &RuntimeTokenRejectedError{}
|
||||
}
|
||||
return validateResolved(refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// ClassifyRejectedAfterRetry is called when a token returned by
|
||||
// RefreshRejected was itself rejected. When a still newer runtime credential
|
||||
// is already installed, superseded is true so the source may reconnect and
|
||||
// resolve that generation without a second in-attempt retry. When the rejected
|
||||
// token is still current, the fixed typed rejection is returned. With no
|
||||
// runtime credential installed it preserves local OAuth behavior by returning
|
||||
// (false, nil).
|
||||
func (b *Broker) ClassifyRejectedAfterRetry(rejectedToken string) (superseded bool, err error) {
|
||||
if b == nil {
|
||||
return false, ErrCredentialUnavailable
|
||||
}
|
||||
token, _, _, active := b.snapshot()
|
||||
if token == "" || !active {
|
||||
return false, nil
|
||||
}
|
||||
if token != strings.TrimSpace(rejectedToken) {
|
||||
return true, nil
|
||||
}
|
||||
return false, &RuntimeTokenRejectedError{}
|
||||
}
|
||||
|
||||
func (b *Broker) snapshot() (string, <-chan struct{}, bool, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.token, b.changed, b.requireSeed, b.active
|
||||
}
|
||||
|
||||
func (b *Broker) signalChangedLocked() {
|
||||
close(b.changed)
|
||||
b.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
func (b *Broker) validate(token string) (string, error) {
|
||||
normalized := strings.TrimSpace(token)
|
||||
if normalized == "" {
|
||||
return "", ErrEmptyToken
|
||||
}
|
||||
if len(normalized) > b.maxBytes {
|
||||
return "", ErrTokenTooLarge
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateResolved(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", ErrCredentialUnavailable
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package runtimecred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageBrokerLocalFallbackAndRuntimePrecedence(t *testing.T) {
|
||||
var localCalls atomic.Int32
|
||||
b := New(Config{LocalResolve: func(context.Context) (string, error) {
|
||||
localCalls.Add(1)
|
||||
return "local", nil
|
||||
}})
|
||||
if got, err := b.Resolve(context.Background()); err != nil || got != "local" {
|
||||
t.Fatalf("local Resolve = %q, %v", got, err)
|
||||
}
|
||||
gen, err := b.Update(0, " runtime ")
|
||||
if err != nil || gen != 1 {
|
||||
t.Fatalf("Update = %d, %v", gen, err)
|
||||
}
|
||||
if got, err := b.Resolve(context.Background()); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime Resolve = %q, %v", got, err)
|
||||
}
|
||||
if localCalls.Load() != 1 {
|
||||
t.Fatalf("local resolver calls = %d", localCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrokerRequireSeedWaitsAndCancels(t *testing.T) {
|
||||
b := New(Config{RequireSeed: true})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := b.Resolve(ctx); done <- err }()
|
||||
cancel()
|
||||
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Resolve error = %v", err)
|
||||
}
|
||||
|
||||
resolved := make(chan string, 1)
|
||||
go func() {
|
||||
token, _ := b.Resolve(context.Background())
|
||||
resolved <- token
|
||||
}()
|
||||
select {
|
||||
case <-resolved:
|
||||
t.Fatal("Resolve returned before seed")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if _, err := b.Update(0, "seed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-resolved; got != "seed" {
|
||||
t.Fatalf("Resolve = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrokerUpdateCASIdempotenceAndConcurrentConflict(t *testing.T) {
|
||||
b := New(Config{})
|
||||
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
|
||||
t.Fatalf("first update = %d, %v", gen, err)
|
||||
}
|
||||
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
|
||||
t.Fatalf("idempotent stale update = %d, %v", gen, err)
|
||||
}
|
||||
if _, err := b.Update(0, "different"); err == nil {
|
||||
t.Fatal("expected generation conflict")
|
||||
} else {
|
||||
var conflict *GenerationConflictError
|
||||
if !errors.As(err, &conflict) || conflict.Actual != 1 {
|
||||
t.Fatalf("conflict = %#v, %v", conflict, err)
|
||||
}
|
||||
}
|
||||
|
||||
b = New(Config{})
|
||||
start := make(chan struct{})
|
||||
var successes atomic.Int32
|
||||
var conflicts atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for _, token := range []string{"a", "b"} {
|
||||
wg.Add(1)
|
||||
go func(token string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := b.Update(0, token)
|
||||
if err == nil {
|
||||
successes.Add(1)
|
||||
return
|
||||
}
|
||||
var conflict *GenerationConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
conflicts.Add(1)
|
||||
}
|
||||
}(token)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if successes.Load() != 1 || conflicts.Load() != 1 {
|
||||
t.Fatalf("successes=%d conflicts=%d", successes.Load(), conflicts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrokerRefreshRejectedRuntimeNeverFallsBack(t *testing.T) {
|
||||
var refreshCalls atomic.Int32
|
||||
b := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
|
||||
refreshCalls.Add(1)
|
||||
return "local-new", nil
|
||||
}})
|
||||
if _, err := b.Update(0, "runtime-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.RefreshRejected(context.Background(), "runtime-a"); !errors.Is(err, ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("same token refresh error = %v", err)
|
||||
}
|
||||
if refreshCalls.Load() != 0 {
|
||||
t.Fatalf("local refresh called %d times", refreshCalls.Load())
|
||||
}
|
||||
if _, err := b.Update(1, "runtime-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := b.RefreshRejected(context.Background(), "runtime-a"); err != nil || got != "runtime-b" {
|
||||
t.Fatalf("rotated refresh = %q, %v", got, err)
|
||||
}
|
||||
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); superseded || !errors.Is(err, ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("current retry rejection = superseded %v, error %v", superseded, err)
|
||||
}
|
||||
if _, err := b.Update(2, "runtime-c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); !superseded || err != nil {
|
||||
t.Fatalf("newer generation classification = superseded %v, error %v", superseded, err)
|
||||
}
|
||||
localOnly := New(Config{})
|
||||
if superseded, err := localOnly.ClassifyRejectedAfterRetry("local"); superseded || err != nil {
|
||||
t.Fatalf("local-only classification = superseded %v, error %v", superseded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrokerDefersSeedUntilActivation(t *testing.T) {
|
||||
b := New(Config{RequireSeed: true, RequireActivation: true})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
resolved := make(chan string, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
token, err := b.Resolve(ctx)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
resolved <- token
|
||||
}()
|
||||
|
||||
generation, err := b.Update(0, "runtime-a")
|
||||
if err != nil || generation != 1 {
|
||||
t.Fatalf("Update() = generation %d, err %v", generation, err)
|
||||
}
|
||||
select {
|
||||
case token := <-resolved:
|
||||
t.Fatalf("Resolve() returned before activation: %q", token)
|
||||
case err := <-errs:
|
||||
t.Fatalf("Resolve() failed before activation: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
if _, err := b.Activate(0); err == nil {
|
||||
t.Fatal("Activate() with stale generation unexpectedly succeeded")
|
||||
}
|
||||
if activeGeneration, err := b.Activate(generation); err != nil || activeGeneration != generation {
|
||||
t.Fatalf("Activate() = generation %d, err %v", activeGeneration, err)
|
||||
}
|
||||
select {
|
||||
case token := <-resolved:
|
||||
if token != "runtime-a" {
|
||||
t.Fatalf("Resolve() token = %q", token)
|
||||
}
|
||||
case err := <-errs:
|
||||
t.Fatalf("Resolve() failed after activation: %v", err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Resolve() remained blocked after activation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrokerRejectsInvalidTokensWithoutEcho(t *testing.T) {
|
||||
b := New(Config{MaxTokenBytes: 4})
|
||||
for _, token := range []string{" ", "secret-token"} {
|
||||
_, err := b.Update(0, token)
|
||||
if err == nil {
|
||||
t.Fatalf("Update(%q) succeeded", token)
|
||||
}
|
||||
if strings.Contains(err.Error(), token) {
|
||||
t.Fatal("validation error contained rejected token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package runtimecred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventCoreBrokerEdges(t *testing.T) {
|
||||
rejected := &RuntimeTokenRejectedError{}
|
||||
if rejected.Error() == "" || !errors.Is(rejected, ErrRuntimeTokenRejected) || rejected.Is(errors.New("other")) {
|
||||
t.Fatal("runtime rejection error contract failed")
|
||||
}
|
||||
conflict := &GenerationConflictError{Expected: 2, Actual: 3}
|
||||
if conflict.Error() == "" {
|
||||
t.Fatal("generation conflict error is empty")
|
||||
}
|
||||
|
||||
var nilBroker *Broker
|
||||
if nilBroker.Generation() != 0 {
|
||||
t.Fatal("nil broker generation is non-zero")
|
||||
}
|
||||
if _, err := nilBroker.Update(0, "token"); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("nil Update error = %v", err)
|
||||
}
|
||||
if _, err := nilBroker.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("nil Activate error = %v", err)
|
||||
}
|
||||
if _, err := nilBroker.Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("nil Resolve error = %v", err)
|
||||
}
|
||||
if _, err := nilBroker.RefreshRejected(context.Background(), "token"); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("nil RefreshRejected error = %v", err)
|
||||
}
|
||||
if superseded, err := nilBroker.ClassifyRejectedAfterRetry("token"); superseded || !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("nil classification = %v, %v", superseded, err)
|
||||
}
|
||||
|
||||
b := New(Config{})
|
||||
if _, err := b.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("empty Activate error = %v", err)
|
||||
}
|
||||
if _, err := b.Update(0, "token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if generation, err := b.Activate(1); err != nil || generation != 1 {
|
||||
t.Fatalf("active Activate = %d, %v", generation, err)
|
||||
}
|
||||
|
||||
if _, err := New(Config{}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("missing local resolver error = %v", err)
|
||||
}
|
||||
wantResolveErr := errors.New("resolve failed")
|
||||
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
|
||||
return "", wantResolveErr
|
||||
}}).Resolve(context.Background()); !errors.Is(err, wantResolveErr) {
|
||||
t.Fatalf("local resolve error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
|
||||
return " ", nil
|
||||
}}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("empty resolved credential error = %v", err)
|
||||
}
|
||||
var racingResolve *Broker
|
||||
racingResolve = New(Config{LocalResolve: func(context.Context) (string, error) {
|
||||
_, err := racingResolve.Update(0, "runtime-wins")
|
||||
return "local", err
|
||||
}})
|
||||
if token, err := racingResolve.Resolve(context.Background()); err != nil || token != "runtime-wins" {
|
||||
t.Fatalf("runtime precedence after resolve = %q, %v", token, err)
|
||||
}
|
||||
|
||||
seed := New(Config{RequireSeed: true})
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := seed.Resolve(canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("seed cancellation = %v", err)
|
||||
}
|
||||
|
||||
if _, err := New(Config{}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrLocalRefreshUnavailable) {
|
||||
t.Fatalf("missing local refresh error = %v", err)
|
||||
}
|
||||
wantRefreshErr := errors.New("refresh failed")
|
||||
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
|
||||
return "", wantRefreshErr
|
||||
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, wantRefreshErr) {
|
||||
t.Fatalf("local refresh error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
|
||||
return " ", nil
|
||||
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrCredentialUnavailable) {
|
||||
t.Fatalf("empty refreshed credential error = %v", err)
|
||||
}
|
||||
|
||||
var racingRefresh *Broker
|
||||
racingRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
|
||||
_, err := racingRefresh.Update(0, "runtime-new")
|
||||
return "local-new", err
|
||||
}})
|
||||
if token, err := racingRefresh.RefreshRejected(context.Background(), "runtime-old"); err != nil || token != "runtime-new" {
|
||||
t.Fatalf("runtime precedence after refresh = %q, %v", token, err)
|
||||
}
|
||||
var sameRefresh *Broker
|
||||
sameRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
|
||||
_, err := sameRefresh.Update(0, "same")
|
||||
return "local-new", err
|
||||
}})
|
||||
if _, err := sameRefresh.RefreshRejected(context.Background(), "same"); !errors.Is(err, ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("same runtime after refresh error = %v", err)
|
||||
}
|
||||
|
||||
pending := New(Config{RequireSeed: true, RequireActivation: true})
|
||||
generation, err := pending.Update(0, "pending")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitCtx, waitCancel := context.WithCancel(context.Background())
|
||||
waitCancel()
|
||||
if _, err := pending.RefreshRejected(waitCtx, "pending"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("pending refresh cancellation = %v", err)
|
||||
}
|
||||
refreshDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := pending.RefreshRejected(context.Background(), "pending")
|
||||
refreshDone <- err
|
||||
}()
|
||||
// Let RefreshRejected take the pending credential's changed-channel path
|
||||
// before activation publishes it.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if _, err := pending.Activate(generation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-refreshDone:
|
||||
if !errors.Is(err, ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("activated pending refresh error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("pending refresh did not resume")
|
||||
}
|
||||
|
||||
inactive := New(Config{RequireActivation: true})
|
||||
if _, err := inactive.Update(0, "inactive"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if superseded, err := inactive.ClassifyRejectedAfterRetry("other"); superseded || err != nil {
|
||||
t.Fatalf("inactive classification = %v, %v", superseded, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventCorePersonalSupersededRetry(t *testing.T) {
|
||||
source, err := NewPersonal(PersonalConfig{
|
||||
AccessToken: "stale",
|
||||
ForceRefreshToken: func(context.Context, string) (string, error) {
|
||||
return "rotated", nil
|
||||
},
|
||||
ClassifyRetryReject: func(token string) (bool, error) {
|
||||
if token != "rotated" {
|
||||
t.Fatalf("classified token = %q", token)
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Body: io.NopCloser(strings.NewReader("rejected")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := source.fetchTicket(context.Background()); !isRetryablePersonalError(err) {
|
||||
t.Fatalf("superseded retry error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type PersonalConfig struct {
|
||||
AccessToken string
|
||||
AccessTokenProvider AccessTokenProvider
|
||||
ForceRefreshToken ForceRefreshTokenFn
|
||||
ClassifyRetryReject RetryRejectClassifier
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
SourceID string
|
||||
@@ -66,6 +67,12 @@ type AccessTokenProvider func(context.Context) (string, error)
|
||||
// when nil a 401 stays fatal, matching the previous behavior.
|
||||
type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error)
|
||||
|
||||
// RetryRejectClassifier classifies a 401 from the one refreshed-token retry.
|
||||
// superseded means a newer credential is already available and the outer
|
||||
// reconnect loop should start a fresh attempt; err is a terminal typed
|
||||
// rejection. A nil callback preserves the historical local OAuth behavior.
|
||||
type RetryRejectClassifier func(rejectedToken string) (superseded bool, err error)
|
||||
|
||||
type PersonalSource struct {
|
||||
cfg PersonalConfig
|
||||
machine *Machine
|
||||
@@ -223,7 +230,19 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
|
||||
return nil, refreshErr
|
||||
}
|
||||
// Retry once with the freshly rotated token; a second 401 stays fatal.
|
||||
ticket, _, err = s.fetchTicketAttempt(ctx, refreshed)
|
||||
var retryStatus int
|
||||
var retryErr error
|
||||
ticket, retryStatus, retryErr = s.fetchTicketAttempt(ctx, refreshed)
|
||||
if retryStatus == http.StatusUnauthorized && s.cfg.ClassifyRetryReject != nil {
|
||||
superseded, classifyErr := s.cfg.ClassifyRetryReject(refreshed)
|
||||
if classifyErr != nil {
|
||||
return nil, classifyErr
|
||||
}
|
||||
if superseded {
|
||||
return nil, retryPersonal(retryErr)
|
||||
}
|
||||
}
|
||||
err = retryErr
|
||||
}
|
||||
return ticket, err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
)
|
||||
@@ -316,6 +317,46 @@ func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalRuntimeTokenAtoBSecond401IsTyped(t *testing.T) {
|
||||
broker := runtimecred.New(runtimecred.Config{})
|
||||
if _, err := broker.Update(0, "runtime-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attemptTokens []string
|
||||
src, err := NewPersonal(PersonalConfig{
|
||||
AccessTokenProvider: broker.Resolve,
|
||||
ForceRefreshToken: broker.RefreshRejected,
|
||||
ClassifyRetryReject: broker.ClassifyRejectedAfterRetry,
|
||||
ClientID: "client",
|
||||
SourceID: "source",
|
||||
TicketURL: "https://ticket.test",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
token := req.Header.Get("x-user-access-token")
|
||||
attemptTokens = append(attemptTokens, token)
|
||||
if token == "runtime-a" {
|
||||
if _, err := broker.Update(1, "runtime-b"); err != nil {
|
||||
t.Fatalf("rotate runtime credential: %v", err)
|
||||
}
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Body: io.NopCloser(strings.NewReader("must-not-surface")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = src.fetchTicket(context.Background())
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("fetchTicket() error = %v", err)
|
||||
}
|
||||
if len(attemptTokens) != 2 || attemptTokens[0] != "runtime-a" || attemptTokens[1] != "runtime-b" {
|
||||
t.Fatalf("attempt tokens = %v", attemptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// brokenBody simulates a response body that fails mid-read, e.g. the server
|
||||
// closing the connection before the error payload is fully written.
|
||||
type brokenBody struct{}
|
||||
|
||||
@@ -26,16 +26,28 @@ import (
|
||||
type FrameType string
|
||||
|
||||
const (
|
||||
FrameTypeHello FrameType = "hello" // consume → bus
|
||||
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
|
||||
FrameTypeEvent FrameType = "event" // bus → consume
|
||||
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
|
||||
FrameTypeSourceState FrameType = "source_state" // bus → consume
|
||||
FrameTypeBye FrameType = "bye" // bidirectional
|
||||
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
|
||||
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
|
||||
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
|
||||
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
|
||||
FrameTypeHello FrameType = "hello" // consume → bus
|
||||
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
|
||||
FrameTypeEvent FrameType = "event" // bus → consume
|
||||
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
|
||||
FrameTypeSourceState FrameType = "source_state" // bus → consume
|
||||
FrameTypeBye FrameType = "bye" // bidirectional
|
||||
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
|
||||
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
|
||||
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
|
||||
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
|
||||
FrameTypeCredentialUpdate FrameType = "credential_update" // consume → bus
|
||||
FrameTypeCredentialUpdateAck FrameType = "credential_update_ack" // bus → consume
|
||||
)
|
||||
|
||||
// CredentialMode declares that a consumer needs an additive credential
|
||||
// negotiation before it can register for events. The zero value preserves the
|
||||
// original protocol.
|
||||
type CredentialMode string
|
||||
|
||||
const (
|
||||
CredentialModeRuntimeToken CredentialMode = "runtime_token"
|
||||
CapabilityRuntimeTokenV1 = "runtime_token_v1"
|
||||
)
|
||||
|
||||
// Hello is the first frame a consumer sends after dialing the bus. The bus
|
||||
@@ -51,7 +63,8 @@ type Hello struct {
|
||||
// Role distinguishes a real consumer (registered for events) from an
|
||||
// ad-hoc tooling connection (status/list/stop). Ad-hoc connections do
|
||||
// NOT register with the Hub.
|
||||
Role HelloRole `json:"role,omitempty"`
|
||||
Role HelloRole `json:"role,omitempty"`
|
||||
CredentialMode CredentialMode `json:"credential_mode,omitempty"`
|
||||
}
|
||||
|
||||
// HelloRole tags the purpose of a Hello connection.
|
||||
@@ -76,6 +89,38 @@ type HelloAck struct {
|
||||
ClientIDSource string `json:"client_id_source"` // auth.CredentialSource string
|
||||
ClientSecretSource string `json:"client_secret_source"` // auth.CredentialSource string
|
||||
IdleTimeoutSecs int `json:"idle_timeout_secs,omitempty"` // bus's IdleTimeout for diagnostics
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
// CredentialGeneration is process-local and contains no secret data.
|
||||
CredentialGeneration uint64 `json:"credential_generation"`
|
||||
// TerminalReason is a fixed, non-sensitive bus terminal state. A runtime
|
||||
// client checks it before sending credential material.
|
||||
TerminalReason string `json:"terminal_reason,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialUpdate installs a host-supplied runtime token into a compatible
|
||||
// bus after the bus has advertised CapabilityRuntimeTokenV1. Token is carried
|
||||
// only over the owner-only local IPC connection and must never be logged.
|
||||
type CredentialUpdate struct {
|
||||
Type FrameType `json:"type"`
|
||||
ExpectedGeneration uint64 `json:"expected_generation"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
const (
|
||||
CredentialErrorGenerationConflict = "generation_conflict"
|
||||
CredentialErrorInvalid = "invalid_credential"
|
||||
CredentialErrorRegistration = "registration_failed"
|
||||
CredentialErrorRuntimeRejected = "runtime_token_rejected"
|
||||
CredentialErrorInternal = "internal_error"
|
||||
)
|
||||
|
||||
// CredentialUpdateAck reports the CAS result without echoing any credential.
|
||||
type CredentialUpdateAck struct {
|
||||
Type FrameType `json:"type"`
|
||||
Accepted bool `json:"accepted"`
|
||||
CredentialGeneration uint64 `json:"credential_generation"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Event wraps one delivered RawEvent for the wire. We keep the payload as
|
||||
@@ -126,7 +171,10 @@ type Bye struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
const ByeReasonSubscriptionStopped = "subscription_stopped"
|
||||
const (
|
||||
ByeReasonSubscriptionStopped = "subscription_stopped"
|
||||
ByeReasonRuntimeTokenRejected = "runtime_token_rejected"
|
||||
)
|
||||
|
||||
// ConsumerStopReq asks the bus to close consumers whose exact personal
|
||||
// subscription IDs match. It is an additive local IPC control operation;
|
||||
|
||||
@@ -23,21 +23,23 @@ import (
|
||||
// to guard against accidental wire-format changes (the JSON field tags
|
||||
// double as part of the cross-version protocol contract).
|
||||
|
||||
func TestFrameType_StableWireValues(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameTypeStableWireValues(t *testing.T) {
|
||||
// If any of these strings change we've made a protocol-breaking
|
||||
// change. The test value list is duplicated here on purpose so a
|
||||
// reviewer renaming a constant is forced to also update the test.
|
||||
wants := map[FrameType]string{
|
||||
FrameTypeHello: "hello",
|
||||
FrameTypeHelloAck: "hello_ack",
|
||||
FrameTypeEvent: "event",
|
||||
FrameTypeHeartbeat: "heartbeat",
|
||||
FrameTypeSourceState: "source_state",
|
||||
FrameTypeBye: "bye",
|
||||
FrameTypeStatusReq: "status_req",
|
||||
FrameTypeStatusResp: "status_resp",
|
||||
FrameTypeConsumerStopReq: "consumer_stop_req",
|
||||
FrameTypeConsumerStopResp: "consumer_stop_resp",
|
||||
FrameTypeHello: "hello",
|
||||
FrameTypeHelloAck: "hello_ack",
|
||||
FrameTypeEvent: "event",
|
||||
FrameTypeHeartbeat: "heartbeat",
|
||||
FrameTypeSourceState: "source_state",
|
||||
FrameTypeBye: "bye",
|
||||
FrameTypeStatusReq: "status_req",
|
||||
FrameTypeStatusResp: "status_resp",
|
||||
FrameTypeConsumerStopReq: "consumer_stop_req",
|
||||
FrameTypeConsumerStopResp: "consumer_stop_resp",
|
||||
FrameTypeCredentialUpdate: "credential_update",
|
||||
FrameTypeCredentialUpdateAck: "credential_update_ack",
|
||||
}
|
||||
for ft, want := range wants {
|
||||
if string(ft) != want {
|
||||
@@ -104,54 +106,91 @@ func roundTrip(t *testing.T, in any, dst any) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHello_Roundtrip(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHelloRoundtrip(t *testing.T) {
|
||||
in := Hello{
|
||||
Type: FrameTypeHello,
|
||||
ConsumerPID: 42,
|
||||
EventTypes: []string{"im.*", "approval.task"},
|
||||
Filter: `^im\.`,
|
||||
Compact: true,
|
||||
Role: HelloRoleStatus,
|
||||
Type: FrameTypeHello,
|
||||
ConsumerPID: 42,
|
||||
EventTypes: []string{"im.*", "approval.task"},
|
||||
Filter: `^im\.`,
|
||||
Compact: true,
|
||||
Role: HelloRoleStatus,
|
||||
CredentialMode: CredentialModeRuntimeToken,
|
||||
}
|
||||
var out Hello
|
||||
roundTrip(t, in, &out)
|
||||
if out.Type != in.Type || out.ConsumerPID != in.ConsumerPID || out.Filter != in.Filter ||
|
||||
out.Compact != in.Compact || out.Role != in.Role || len(out.EventTypes) != len(in.EventTypes) {
|
||||
out.Compact != in.Compact || out.Role != in.Role || out.CredentialMode != in.CredentialMode || len(out.EventTypes) != len(in.EventTypes) {
|
||||
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHello_OmitemptyForDefaults(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHelloOmitemptyForDefaults(t *testing.T) {
|
||||
// Default values should NOT appear in the wire form so old/new readers
|
||||
// stay tolerant of each other (each new field comes in with its
|
||||
// zero value by default).
|
||||
in := Hello{Type: FrameTypeHello, ConsumerPID: 1}
|
||||
b, _ := json.Marshal(in)
|
||||
s := string(b)
|
||||
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`} {
|
||||
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`, `"credential_mode"`} {
|
||||
if strings.Contains(s, k) {
|
||||
t.Errorf("zero-value field %s leaked into wire form: %s", k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloAck_Roundtrip(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHelloAckRoundtrip(t *testing.T) {
|
||||
in := HelloAck{
|
||||
Type: FrameTypeHelloAck,
|
||||
BusPID: 12345,
|
||||
SourceState: "connected",
|
||||
StateSource: "inferred",
|
||||
ClientIDSource: "env",
|
||||
ClientSecretSource: "env",
|
||||
IdleTimeoutSecs: 300,
|
||||
Type: FrameTypeHelloAck,
|
||||
BusPID: 12345,
|
||||
SourceState: "connected",
|
||||
StateSource: "inferred",
|
||||
ClientIDSource: "env",
|
||||
ClientSecretSource: "env",
|
||||
IdleTimeoutSecs: 300,
|
||||
Capabilities: []string{CapabilityRuntimeTokenV1},
|
||||
CredentialGeneration: 7,
|
||||
}
|
||||
var out HelloAck
|
||||
roundTrip(t, in, &out)
|
||||
if out != in {
|
||||
if out.Type != in.Type || out.BusPID != in.BusPID || out.SourceState != in.SourceState ||
|
||||
out.StateSource != in.StateSource || out.ClientIDSource != in.ClientIDSource ||
|
||||
out.ClientSecretSource != in.ClientSecretSource || out.IdleTimeoutSecs != in.IdleTimeoutSecs ||
|
||||
out.CredentialGeneration != in.CredentialGeneration || strings.Join(out.Capabilities, ",") != CapabilityRuntimeTokenV1 {
|
||||
t.Fatalf("HelloAck roundtrip: %+v != %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCredentialFramesRoundtripWithoutTokenInAck(t *testing.T) {
|
||||
update := CredentialUpdate{
|
||||
Type: FrameTypeCredentialUpdate,
|
||||
ExpectedGeneration: 4,
|
||||
Token: "canary-secret",
|
||||
}
|
||||
var decodedUpdate CredentialUpdate
|
||||
roundTrip(t, update, &decodedUpdate)
|
||||
if decodedUpdate != update {
|
||||
t.Fatal("CredentialUpdate roundtrip mismatch")
|
||||
}
|
||||
|
||||
ack := CredentialUpdateAck{
|
||||
Type: FrameTypeCredentialUpdateAck,
|
||||
Accepted: true,
|
||||
CredentialGeneration: 5,
|
||||
}
|
||||
var decodedAck CredentialUpdateAck
|
||||
roundTrip(t, ack, &decodedAck)
|
||||
if decodedAck != ack {
|
||||
t.Fatalf("CredentialUpdateAck roundtrip = %#v", decodedAck)
|
||||
}
|
||||
b, err := json.Marshal(ack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), update.Token) {
|
||||
t.Fatal("credential acknowledgement contained token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvent_Roundtrip(t *testing.T) {
|
||||
in := Event{
|
||||
Type: FrameTypeEvent,
|
||||
@@ -245,7 +284,7 @@ func TestStatusResp_Roundtrip(t *testing.T) {
|
||||
// PeekType is used by the daemon to dispatch incoming frames before
|
||||
// fully decoding into the typed struct. Behaviour at boundaries matters.
|
||||
|
||||
func TestPeekType_EachFrameVariant(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePeekTypeEachFrameVariant(t *testing.T) {
|
||||
cases := []struct {
|
||||
v any
|
||||
typ FrameType
|
||||
@@ -258,6 +297,8 @@ func TestPeekType_EachFrameVariant(t *testing.T) {
|
||||
{Bye{Type: FrameTypeBye, Reason: "x"}, FrameTypeBye},
|
||||
{StatusReq{Type: FrameTypeStatusReq}, FrameTypeStatusReq},
|
||||
{StatusResp{Type: FrameTypeStatusResp}, FrameTypeStatusResp},
|
||||
{CredentialUpdate{Type: FrameTypeCredentialUpdate}, FrameTypeCredentialUpdate},
|
||||
{CredentialUpdateAck{Type: FrameTypeCredentialUpdateAck}, FrameTypeCredentialUpdateAck},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, _ := json.Marshal(c.v)
|
||||
|
||||
+173
-44
@@ -27,6 +27,79 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("chat", toolName, args)
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args)
|
||||
if err != nil {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) && cliErr.Operation == "" {
|
||||
withOperation := *cliErr
|
||||
withOperation.Operation = "chat/" + toolName
|
||||
return &withOperation
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewAPI("MCP read tool returned no non-empty text content",
|
||||
apperrors.WithOperation("chat/"+toolName),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithReason("empty_tool_response"),
|
||||
)
|
||||
}
|
||||
data := map[string]any{}
|
||||
if err := unmarshalJSONUseNumber(text, &data); err != nil {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
return writeCommandPayload(cmd, projectChatMessagesPayload(data, search))
|
||||
}
|
||||
|
||||
func projectChatMessagesPayload(data map[string]any, search bool) map[string]any {
|
||||
items := chatmsg.ListMessageItems(data)
|
||||
if search {
|
||||
items = chatmsg.SearchItems(data)
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
projected := make(map[string]any, len(item)+8)
|
||||
for key, value := range item {
|
||||
projected[key] = value
|
||||
}
|
||||
for key, value := range chatmsg.ProjectMessageV1(item, true) {
|
||||
if key == "messageId" || key == "text" {
|
||||
projected[key] = value
|
||||
continue
|
||||
}
|
||||
if _, exists := projected[key]; !exists {
|
||||
projected[key] = value
|
||||
}
|
||||
}
|
||||
if value, exists := item["openMessageId"]; exists {
|
||||
projected["openMessageId"] = value
|
||||
} else if value, exists := projected["messageId"]; exists {
|
||||
projected["openMessageId"] = value
|
||||
}
|
||||
if value, exists := item["content"]; exists {
|
||||
projected["content"] = value
|
||||
} else if value, exists := projected["text"]; exists {
|
||||
projected["content"] = value
|
||||
}
|
||||
messages = append(messages, projected)
|
||||
}
|
||||
|
||||
payload := make(map[string]any, len(data)+1)
|
||||
for key, value := range data {
|
||||
payload[key] = value
|
||||
}
|
||||
payload["messages"] = messages
|
||||
return payload
|
||||
}
|
||||
|
||||
func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) {
|
||||
forwardStr, _ := cmd.Flags().GetString("forward")
|
||||
forward := forwardStr != "false"
|
||||
@@ -137,13 +210,14 @@ func runConversationScopedPagedMessageSearch(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runConversationScopedMessageSearch(
|
||||
return runConversationScopedMessageSearchWithProjector(
|
||||
cmd,
|
||||
cfg.ServerID,
|
||||
cfg.ToolName,
|
||||
scopeParam,
|
||||
toolArgs,
|
||||
conversationIDs,
|
||||
cfg.ProjectResult,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +226,24 @@ func runConversationScopedMessageSearch(
|
||||
serverID, toolName, scopeParam string,
|
||||
toolArgs map[string]any,
|
||||
conversationIDs []string,
|
||||
) error {
|
||||
return runConversationScopedMessageSearchWithProjector(
|
||||
cmd,
|
||||
serverID,
|
||||
toolName,
|
||||
scopeParam,
|
||||
toolArgs,
|
||||
conversationIDs,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func runConversationScopedMessageSearchWithProjector(
|
||||
cmd *cobra.Command,
|
||||
serverID, toolName, scopeParam string,
|
||||
toolArgs map[string]any,
|
||||
conversationIDs []string,
|
||||
projectResult func(map[string]any) map[string]any,
|
||||
) error {
|
||||
conversationIDs = uniqueNonEmptyStrings(conversationIDs)
|
||||
if len(conversationIDs) == 0 {
|
||||
@@ -303,6 +395,9 @@ func runConversationScopedMessageSearch(
|
||||
}
|
||||
payload["paging"] = paging
|
||||
}
|
||||
if projectResult != nil {
|
||||
payload = projectResult(payload)
|
||||
}
|
||||
return writeCommandPayload(cmd, payload)
|
||||
}
|
||||
|
||||
@@ -670,6 +765,17 @@ func pagedChatConversationMessagesConfig(toolName string, build func(*cobra.Comm
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pagedProjectedChatSearchConfig(cmd *cobra.Command, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
|
||||
cfg := pagedChatConversationMessagesConfig(toolName, build)
|
||||
cfg.Fallback = func(args map[string]any) error {
|
||||
return callProjectedChatMessages(cmd, toolName, args, true)
|
||||
}
|
||||
cfg.ProjectResult = func(payload map[string]any) map[string]any {
|
||||
return projectChatMessagesPayload(payload, true)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pagedChatConversationMessagesOnServerConfig(serverID, toolName string, build func(*cobra.Command) (map[string]any, error)) PagedMCPCommandConfig {
|
||||
cfg := pagedChatConversationMessagesConfig(toolName, build)
|
||||
cfg.ServerID = serverID
|
||||
@@ -1492,6 +1598,36 @@ func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
func buildChatGroupShareInviteArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if err := validateRequiredFlags(cmd, "source"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return nil, fmt.Errorf("--target or --receiver is required")
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return nil, fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
|
||||
}
|
||||
if target != "" {
|
||||
toolArgs["targetOpenConversationId"] = target
|
||||
}
|
||||
if receiver != "" {
|
||||
toolArgs["receiverOpenDingTalkId"] = receiver
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
|
||||
toolArgs["expiresSeconds"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
func appendChatChmodParams(cmd *cobra.Command, toolArgs map[string]any) error {
|
||||
conversationID, _ := cmd.Flags().GetString("conversation-id")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
@@ -1974,11 +2110,11 @@ func newChatCommand() *cobra.Command {
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "grantParams.openCid", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
|
||||
{Name: "open-dingtalk-id", Property: "grantParams.openDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "permParam", Property: "grantParams", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
|
||||
{Name: "user", Property: "grantParams.userId", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
@@ -2017,10 +2153,15 @@ func newChatCommand() *cobra.Command {
|
||||
chatDataAuthCrossOrgCmd.Flags().String("grant-type", "timed", "授权策略: once|session|timed|permanent")
|
||||
chatDataAuthCrossOrgCmd.Flags().String("ttl", "24h", "timed 授权有效期,如 1h/4h/24h/7d")
|
||||
chatDataAuthCrossOrgCmd.Flags().String("session-id", "", "session 授权的会话标识")
|
||||
chatDataAuthCrossOrgCmd.Flags().BoolP("yes", "y", false, "确认执行跨组织 chat 数据授权")
|
||||
DeclareLeafMetadata(chatDataAuthCrossOrgCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := buildChatCrossOrgDataAuthArgs(cmd)
|
||||
return err
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2045,10 +2186,10 @@ func newChatCommand() *cobra.Command {
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "agentCode", Property: "agentCode", Required: boolPtr(false)},
|
||||
{Name: "all", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false)},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false)},
|
||||
{Name: "grant-type", Property: "grantType", Required: boolPtr(false), Enum: []string{"once", "session", "timed", "permanent"}},
|
||||
{Name: "session-id", Property: "sessionId", Required: boolPtr(false), RequiredWhen: "grant-type is session"},
|
||||
{Name: "target-org-id", Property: "grantParams.targetOrgId", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false)},
|
||||
{Name: "ttl", Property: "ttl", Required: boolPtr(false), RequiredWhen: "grant-type is timed"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2464,11 +2605,12 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "拉取会话消息内容",
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
|
||||
Example: ` dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --direction older
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}'
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询 userId: dws contact user search --query "姓名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -2512,7 +2654,7 @@ func newChatCommand() *cobra.Command {
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
return callMCPTool("list_conversation_message_v2", toolArgs)
|
||||
return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"time": timeVal,
|
||||
@@ -2526,7 +2668,7 @@ func newChatCommand() *cobra.Command {
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
return callMCPTool("list_individual_chat_message", toolArgs)
|
||||
return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(chatMessageListCmd, LeafSpec{
|
||||
@@ -2552,7 +2694,10 @@ func newChatCommand() *cobra.Command {
|
||||
AgentSummary: "分页读取指定会话消息及其引用上下文",
|
||||
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
|
||||
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
|
||||
Examples: []string{"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"},
|
||||
Examples: []string{
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "direction", Property: "forward"},
|
||||
@@ -3625,17 +3770,18 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "按关键词搜索消息",
|
||||
Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`,
|
||||
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
|
||||
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
|
||||
dws chat message search --query "发布计划" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --page-all --max-items 300 --page-delay 0
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}'
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
return runConversationScopedPagedMessageSearch(
|
||||
cmd,
|
||||
pagedChatConversationMessagesConfig("search_messages_by_keyword", chatMessageSearchArgs),
|
||||
pagedProjectedChatSearchConfig(cmd, "search_messages_by_keyword", chatMessageSearchArgs),
|
||||
"openConversationId",
|
||||
[]string{groupID},
|
||||
)
|
||||
@@ -3666,7 +3812,7 @@ func newChatCommand() *cobra.Command {
|
||||
AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"},
|
||||
Examples: []string{
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"",
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300",
|
||||
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --page-all --max-items 300 --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
Parameters: append([]contract.ParamDecl{
|
||||
@@ -8227,12 +8373,16 @@ status 可选值:
|
||||
if err != nil {
|
||||
return fmt.Errorf("--record-id must be a valid integer: %w", err)
|
||||
}
|
||||
status := mustGetFlag(cmd, "status")
|
||||
if status != "AuditApprove" && status != "AuditDelete" {
|
||||
return fmt.Errorf("unsupported audit status %q, must be one of: AuditApprove, AuditDelete", status)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"applyRecordId": recordID,
|
||||
"applicantUid": mustGetFlag(cmd, "applicant"),
|
||||
"inviterUid": mustGetFlag(cmd, "inviter"),
|
||||
"status": mustGetFlag(cmd, "status"),
|
||||
"status": status,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
toolArgs["auditDescription"] = v
|
||||
@@ -8282,7 +8432,7 @@ status 可选值:
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "inviter", Property: "inviterUid", Required: boolPtr(true)},
|
||||
{Name: "record-id", Property: "applyRecordId", Required: boolPtr(true), InterfaceType: "integer"},
|
||||
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete", "AuditIgnore", "AuditRefuse", "AuditBlock"}},
|
||||
{Name: "status", Property: "status", Required: boolPtr(true), Enum: []string{"AuditApprove", "AuditDelete"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -9386,32 +9536,10 @@ status 可选值:
|
||||
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "source"); err != nil {
|
||||
toolArgs, err := buildChatGroupShareInviteArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return fmt.Errorf("--target or --receiver is required")
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
|
||||
}
|
||||
if target != "" {
|
||||
toolArgs["targetOpenConversationId"] = target
|
||||
}
|
||||
if receiver != "" {
|
||||
toolArgs["receiverOpenDingTalkId"] = receiver
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
|
||||
toolArgs["expiresSeconds"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
return callMCPToolOnServer("im", "share_group_invite_url", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -9421,10 +9549,11 @@ status 可选值:
|
||||
chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)")
|
||||
chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值")
|
||||
chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)")
|
||||
chatGroupShareInviteCmd.Flags().BoolP("yes", "y", false, "确认分享群邀请链接")
|
||||
DeclareLeafMetadata(chatGroupShareInviteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
server: "chat",
|
||||
tool: "search_messages_by_keyword",
|
||||
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
@@ -219,6 +219,21 @@ func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
if len(items) != 1 || len(messages) != 2 {
|
||||
t.Fatalf("conversation items = %#v", items)
|
||||
}
|
||||
if tt.name == "search" {
|
||||
projected, ok := got["messages"].([]any)
|
||||
if !ok || len(projected) != 2 {
|
||||
t.Fatalf("projected messages = %#v", got["messages"])
|
||||
}
|
||||
for i, wantID := range []string{"m1", "m2"} {
|
||||
message, ok := projected[i].(map[string]any)
|
||||
if !ok || message["messageId"] != wantID || message["openMessageId"] != wantID {
|
||||
t.Fatalf("projected message %d = %#v", i, projected[i])
|
||||
}
|
||||
if _, exists := message["text"]; !exists {
|
||||
t.Fatalf("projected message %d missing text: %#v", i, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(items) != 2 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
|
||||
@@ -701,9 +701,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
installExampleGlobalFlags(cmd)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
@@ -746,6 +744,22 @@ func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAuditRejectsUnsupportedStatus(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditRefuse")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported audit status error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unsupported audit status "AuditRefuse"`) {
|
||||
t.Fatalf("error = %v, want unsupported status", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unsupported status must not call MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{resolveUsers: true}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -14,14 +14,59 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// resolveCommandFormat 是 helpers 侧的 format 解析桥接(B44,WS1 改动点4):
|
||||
// 从 cmd flags 统一解析输出 format——显式非空 --format 恒优先,其次 --json
|
||||
// 布尔简写,两者皆无落 fallback json(轮11-DEV 裁决函数
|
||||
// output.ResolveFormatWithJSONShorthand,含未知值归一化降级)。与主漏斗
|
||||
// callMCPToolInternalOpts 的 output.ParseFormat(caller 字符串路径,轮8 A3)
|
||||
// 共用同一 normalizeFormat 归一化规则——cmd 路径与 caller 路径收敛于同一
|
||||
// 单一事实源,不重造第二套解析。nil cmd 返回 fallback(不 panic)。
|
||||
func resolveCommandFormat(cmd *cobra.Command) output.Format {
|
||||
return output.ResolveFormatWithJSONShorthand(cmd, output.FormatJSON)
|
||||
}
|
||||
|
||||
// writeCommandPayload 按当前 format 分发命令载荷(B57,WS1 改动点4):
|
||||
// format 经 resolveCommandFormat(B44 桥接)解析,替换原固定 FormatJSON 兜底;
|
||||
// --fields/--jq 全局过滤同路联动;数据出口走 cmd.OutOrStdout()(可被测试
|
||||
// 重定向,不硬编码 os.Stdout)。nil cmd 按 json 渲染进 io.Discard(与
|
||||
// output.WriteCommandPayload 的容错口径一致)。
|
||||
func writeCommandPayload(cmd *cobra.Command, payload any) error {
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
format := resolveCommandFormat(cmd)
|
||||
if cmd == nil {
|
||||
return output.Write(io.Discard, format, payload)
|
||||
}
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), format, payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
|
||||
}
|
||||
|
||||
// writeEnvelope 是 helpers 侧的统一信封装配出口(B58,WS1 改动点2):信封
|
||||
// 渲染先进 buffer 再写 cmd 流(buffer-first 由 internal/output 承载),按
|
||||
// outcome 分流:
|
||||
//
|
||||
// - success / pending / partial_failure → 数据通道:经 output.WriteEnvelope
|
||||
// 走完整 format 分发矩阵(含未知 format 降级 + stderr warning,AC-09);
|
||||
// - failure(含 nil 信封降级)→ 错误通道:经 Emitter 落 cmd.ErrOrStderr(),
|
||||
// stdout 严格零字节(AC-11),失败信封恒完整 JSON 且绕过 format/jq/fields
|
||||
// (轮8裁决⑪)。
|
||||
//
|
||||
// 本函数只做装配分流,不重定义信封类型或渲染规则。
|
||||
func writeEnvelope(cmd *cobra.Command, env *output.Envelope) error {
|
||||
if env != nil && env.Outcome != output.OutcomeFailure {
|
||||
return output.WriteEnvelope(cmd, env, output.FormatJSON)
|
||||
}
|
||||
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
|
||||
if cmd != nil {
|
||||
w = cmd.OutOrStdout()
|
||||
errW = cmd.ErrOrStderr()
|
||||
}
|
||||
return output.NewEmitter(w, errW, output.FormatJSON, "", "").Emit(env)
|
||||
}
|
||||
|
||||
func preferLegacyLeaf(cmd *cobra.Command) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -334,11 +335,22 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
// Release the child so the parent can exit without leaving a zombie.
|
||||
pid := child.Process.Pid
|
||||
_ = child.Process.Release()
|
||||
|
||||
// The foreground/daemon root remains one streaming legacy command in this
|
||||
// release. It cannot adopt a terminal result until the modes are split.
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectDaemonStartedResult 是 `dev connect --daemon` 父进程的结果 DTO
|
||||
// (队列 B110):pid/日志路径等机器事实进成功信封的 data 层。
|
||||
type connectDaemonStartedResult struct {
|
||||
Status string `json:"status"`
|
||||
Pid int `json:"pid"`
|
||||
LogPath string `json:"logPath,omitempty"`
|
||||
DirKey string `json:"dirKey,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
}
|
||||
|
||||
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
|
||||
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
|
||||
fmt.Fprintf(w, " logs: %s\n", logPath)
|
||||
@@ -587,21 +599,32 @@ func supervisedLabel(supervised bool) string {
|
||||
return "none (foreground or external)"
|
||||
}
|
||||
|
||||
// connectStopResult 是 `dev connect stop` 的结果 DTO(统一输出 dev 域试点,
|
||||
// 队列 B112):进成功信封的 data 层。status 是机器可分支的终态枚举。
|
||||
type connectStopResult struct {
|
||||
Status string `json:"status"`
|
||||
PreviewKind string `json:"preview_kind,omitempty"`
|
||||
Pid int `json:"pid,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
|
||||
// forwards to the worker, which releases the lock and Stream connection), poll
|
||||
// until it exits, escalate to SIGKILL on timeout, and clean up the pid file.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
// 人读进度行写 w(调用方传 stderr);机器可消费的结果作为返回值由 RunE 层
|
||||
// 包进统一信封写 stdout(契约规范 §5.1:stdout 只承载数据)。
|
||||
func daemonStopResult(w io.Writer, dirKey string) (*connectStopResult, error) {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
return nil, apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
return nil, apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil || st.Pid <= 0 {
|
||||
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
|
||||
return nil
|
||||
return &connectStopResult{Status: "not_running", Detail: "no daemon record found"}, nil
|
||||
}
|
||||
if !daemonProcessAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
@@ -625,17 +648,17 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "orphan_worker_stopped", Pid: hb.Pid, Detail: "supervisor was dead; orphan worker stopped"}, nil
|
||||
}
|
||||
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "cleaned_stale", Pid: st.Pid, Detail: "cleaned up stale pid file"}, nil
|
||||
}
|
||||
proc, err := daemonFindProcess(st.Pid)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
}
|
||||
if err := daemonSignalProcess(proc, syscall.SIGTERM); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
}
|
||||
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
|
||||
|
||||
@@ -644,7 +667,7 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
if !daemonProcessAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "stopped", Pid: st.Pid}, nil
|
||||
}
|
||||
helperSleep(200 * time.Millisecond)
|
||||
}
|
||||
@@ -653,7 +676,15 @@ func daemonStop(w io.Writer, dirKey string) error {
|
||||
helperSleep(200 * time.Millisecond)
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
|
||||
return nil
|
||||
return &connectStopResult{Status: "force_killed", Pid: st.Pid, Detail: "graceful stop timed out; SIGKILL sent"}, nil
|
||||
}
|
||||
|
||||
// daemonStop keeps the established helper contract for legacy callers and
|
||||
// platform coverage. Unified-result commands use daemonStopResult when they
|
||||
// need the typed terminal payload.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
_, err := daemonStopResult(w, dirKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStatusCommand implements `dws devapp robot connect
|
||||
@@ -680,6 +711,9 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// Preserve the published Schema safety tuple during the unified-result
|
||||
// pilot. Correcting this historical classification is a separate,
|
||||
// explicitly reviewed interface change.
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
@@ -692,6 +726,10 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
PrimaryCLIPath: "dev connect status",
|
||||
},
|
||||
Description: "查看后台连接器守护进程状态",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"本地连接器守护进程状态","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"},"lastError":{"type":"string","description":"最近一次连接错误;健康时可能缺省"}},"required":["state","supervised"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
@@ -722,17 +760,40 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStop(cmd.OutOrStdout(), dirKey)
|
||||
if commandDryRun(cmd) {
|
||||
preview := &connectStopResult{
|
||||
Status: "preview",
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
Detail: "would send SIGTERM to the selected daemon and escalate to SIGKILL only after the graceful timeout",
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
|
||||
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
|
||||
}
|
||||
result, err := daemonStopResult(cmd.ErrOrStderr(), dirKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 统一输出 dev 域试点(队列 B112):结果信封化(ok:true + data)。
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
|
||||
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{
|
||||
// Preserve the published Schema safety tuple during the unified-result
|
||||
// pilot. The stricter classification belongs in a separate interface
|
||||
// migration rather than this framework-only compatibility rollout.
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(c *cobra.Command, _ []string) error {
|
||||
_, err := connectDaemonDirKeyFromFlags(c)
|
||||
return err
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev",
|
||||
@@ -742,6 +803,7 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
PrimaryCLIPath: "dev connect stop",
|
||||
},
|
||||
Description: "优雅停止后台连接器守护进程",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
@@ -751,13 +813,66 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
AgentSummary: "优雅停止后台连接器守护进程",
|
||||
UseWhen: []string{"用户明确要求停止本地 Stream/连接器守护进程"},
|
||||
AvoidWhen: []string{"只想查看状态时用 dev connect status"},
|
||||
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>"},
|
||||
Examples: []string{"dws dev connect stop --unified-app-id <unifiedAppId>", "dws dev connect stop --unified-app-id <unifiedAppId> --dry-run --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// connectRestartResult 是 `dev connect restart` 的结果 DTO(队列 B113):
|
||||
// 进成功信封的 data 层。pid 是重启后新守护进程的 pid(从刷新后的
|
||||
// daemon-state.json 回读;子进程自身的结果信封由本命令信封取代,不转发)。
|
||||
type connectRestartResult struct {
|
||||
Status string `json:"status"`
|
||||
PreviewKind string `json:"preview_kind,omitempty"`
|
||||
Pid int `json:"pid,omitempty"`
|
||||
DirKey string `json:"dirKey,omitempty"`
|
||||
UnifiedAppID string `json:"unifiedAppId,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
func validateConnectRestart(cmd *cobra.Command) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// An explicit unified app ID is sufficient to produce a side-effect-free
|
||||
// restart plan. Runtime execution still requires persisted daemon state.
|
||||
if commandDryRun(cmd) && devAppStringFlag(cmd, "unified-app-id") != "" {
|
||||
return nil
|
||||
}
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil {
|
||||
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
|
||||
}
|
||||
if st.UnifiedAppID == "" {
|
||||
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConnectRestartPreview(cmd *cobra.Command, dirKey, unifiedAppID, channel string) error {
|
||||
preview := &connectRestartResult{
|
||||
Status: "preview",
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
DirKey: dirKey,
|
||||
UnifiedAppID: unifiedAppID,
|
||||
Channel: channel,
|
||||
Command: fmt.Sprintf("dws dev connect --daemon --unified-app-id %s", unifiedAppID),
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, DryRun: true, Data: preview}
|
||||
return writeDevRolloutResult(cmd, output.Success(preview, output.WithDryRun()), env, output.FormatJSON)
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectRestartCommand implements `dws devapp robot connect
|
||||
// restart`: stop the running daemon (if any) then re-launch it using the
|
||||
// persisted unifiedAppId so credentials are freshly fetched from the dev
|
||||
@@ -773,6 +888,7 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
explicitUnifiedAppID := devAppStringFlag(cmd, "unified-app-id")
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
@@ -781,6 +897,9 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil && commandDryRun(cmd) && explicitUnifiedAppID != "" {
|
||||
return writeConnectRestartPreview(cmd, dirKey, explicitUnifiedAppID, "")
|
||||
}
|
||||
if st == nil {
|
||||
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
|
||||
}
|
||||
@@ -788,10 +907,15 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if unifiedAppID == "" {
|
||||
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
return writeConnectRestartPreview(cmd, dirKey, unifiedAppID, st.Channel)
|
||||
}
|
||||
// Stop the running daemon first (ignore "not running" — that's fine).
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "stopping existing daemon...")
|
||||
if err := daemonStop(cmd.OutOrStdout(), dirKey); err != nil {
|
||||
fmt.Fprintf(cmd.OutOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
|
||||
// 统一输出 dev 域试点(队列 B113):stopping/restarting 进度行改走
|
||||
// stderr,stdout 只承载最终结果信封(契约规范 §5.1)。
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "stopping existing daemon...")
|
||||
if err := daemonStop(cmd.ErrOrStderr(), dirKey); err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
|
||||
}
|
||||
// Re-exec dws dev connect --daemon with the stored flags. An explicit
|
||||
// --profile on this invocation overrides the persisted one.
|
||||
@@ -816,24 +940,51 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
|
||||
if st.AlwaysOn {
|
||||
args = append(args, "--alwayson")
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "restarting connector: dws %s\n", strings.Join(args, " "))
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "restarting connector: dws %s\n", strings.Join(args, " "))
|
||||
// Run synchronously: `--daemon` itself detaches the supervisor and
|
||||
// returns quickly, so waiting here costs nothing and lets a failed
|
||||
// relaunch (e.g. credential fetch error) surface as a non-zero exit
|
||||
// instead of a silent success.
|
||||
// instead of a silent success. 子进程自己的输出(含它自己的结果
|
||||
// 信封)不再转发到本命令的 stdout——由本命令的信封统一承载结果,
|
||||
// 避免两个 JSON 文档拼接破坏 `| jq`(契约规范 §5.1)。
|
||||
restartCmd := daemonCommand(exe, args...)
|
||||
restartCmd.Stdout = cmd.OutOrStdout()
|
||||
restartCmd.Stderr = cmd.OutOrStderr()
|
||||
restartCmd.Stdout = cmd.ErrOrStderr()
|
||||
restartCmd.Stderr = cmd.ErrOrStderr()
|
||||
restartCmd.Stdin = nil
|
||||
if err := restartCmd.Run(); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("重启失败(旧守护进程已停止,连接器记录已清除);恢复请手动执行: dws %s", strings.Join(args, " ")))
|
||||
}
|
||||
return nil
|
||||
// 回读刷新后的 daemon-state.json 拿新 supervisor pid(restart 是同步
|
||||
// re-exec,子进程落盘早于返回;读不到则 pid 缺席,status 仍诚实)。
|
||||
result := &connectRestartResult{
|
||||
Status: "restarted",
|
||||
DirKey: dirKey,
|
||||
UnifiedAppID: unifiedAppID,
|
||||
Channel: st.Channel,
|
||||
Command: "dws " + strings.Join(args, " "),
|
||||
}
|
||||
if fresh, rerr := readDaemonState(dir); rerr == nil && fresh != nil {
|
||||
result.Pid = fresh.Pid
|
||||
}
|
||||
env := &output.Envelope{OK: true, Outcome: output.OutcomeSuccess, Data: result}
|
||||
return writeDevRolloutResult(cmd, output.Success(result), env, output.FormatJSON)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "not_required", Idempotency: "unknown"},
|
||||
Validate: func(c *cobra.Command, _ []string) error { return validateConnectRestart(c) },
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_restart", CanonicalPath: "dev.connect_restart", CLIPath: "dev connect restart", PrimaryCLIPath: "dev connect restart"},
|
||||
Description: "使用持久化配置重启本地连接器守护进程",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地守护进程管理与远端凭证重新获取,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "重启本地连接器守护进程", UseWhen: []string{"连接器需要恢复或重载配置"}, AvoidWhen: []string{"仅查看状态时使用 dev connect status"}, Examples: []string{"dws dev connect restart --unified-app-id <unifiedAppId>", "dws dev connect restart --unified-app-id <unifiedAppId> --dry-run --format json"}},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -855,6 +1006,9 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
|
||||
resolveAppNames(cmd, runner, reports)
|
||||
w := cmd.OutOrStdout()
|
||||
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
|
||||
if reports == nil {
|
||||
reports = []connectHealthReport{}
|
||||
}
|
||||
data, _ := json.MarshalIndent(reports, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
return nil
|
||||
@@ -868,6 +1022,19 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Bool("json", false, "以 JSON 数组输出(供脚本消费)")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "connect_list", CanonicalPath: "dev.connect_list", CLIPath: "dev connect list", PrimaryCLIPath: "dev connect list"},
|
||||
Description: "列出本机连接器及其健康状态",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"array","description":"本机连接器及其健康状态列表","items":{"type":"object","properties":{"state":{"type":"string","description":"归一化健康状态","enum":["healthy","degraded","down","not_running"]},"pid":{"type":"integer","description":"守护进程 PID;未运行时可能缺省"},"channel":{"type":"string","description":"连接器使用的通道"},"clientId":{"type":"string","description":"连接器客户端 ID"},"unifiedAppId":{"type":"string","description":"关联的统一应用 ID"},"supervised":{"type":"boolean","description":"是否由本地 supervisor 管理"}},"required":["state","supervised"],"additionalProperties":true}}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合本地连接器状态与可选远端应用名称解析,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "列出本机全部连接器及健康状态", UseWhen: []string{"需要查看本机连接器清单"}, AvoidWhen: []string{"只检查一个连接器时使用 dev connect status"}, Examples: []string{"dws dev connect list --json"}},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -164,13 +165,18 @@ func TestCrossPlatformCoverageStartDaemonLifecycleEdges(t *testing.T) {
|
||||
fixture := writeShellExecutable(t, t.TempDir(), "daemon-success", "exit 0\n")
|
||||
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(fixture) }
|
||||
cmd := daemonTestCommand()
|
||||
var out bytes.Buffer
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
if err := startDaemon(cmd, "key", "client", "app", "custom", "staff", "profile", true); err != nil {
|
||||
t.Fatalf("startDaemon() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "daemon started") {
|
||||
t.Fatalf("start output = %q", out.String())
|
||||
// The streaming root remains legacy until a dedicated stream contract.
|
||||
if !strings.Contains(out.String(), "daemon started") || !strings.Contains(out.String(), "pid") {
|
||||
t.Fatalf("legacy daemon start output = %q", out.String())
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("legacy daemon start stderr = %q", errOut.String())
|
||||
}
|
||||
|
||||
// startDaemon intentionally releases its detached child. On Windows the
|
||||
@@ -564,7 +570,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
if err := daemonStop(&bytes.Buffer{}, "key"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "key"); err == nil {
|
||||
t.Fatal("stop with blocked directory succeeded")
|
||||
}
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
@@ -572,7 +578,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := daemonStop(&bytes.Buffer{}, "corrupt"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "corrupt"); err == nil {
|
||||
t.Fatal("stop with corrupt state succeeded")
|
||||
}
|
||||
})
|
||||
@@ -586,7 +592,7 @@ func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
||||
}
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
||||
if err := daemonStop(&bytes.Buffer{}, "find-error"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "find-error"); err == nil {
|
||||
t.Fatal("find process error was ignored")
|
||||
}
|
||||
})
|
||||
@@ -634,7 +640,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
}
|
||||
sleeps := 0
|
||||
helperSleep = func(time.Duration) { sleeps++ }
|
||||
if err := daemonStop(&bytes.Buffer{}, "orphan-graceful"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-graceful"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 1 || signals[0] != syscall.SIGTERM || sleeps != 1 {
|
||||
@@ -660,7 +666,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
helperSleep = func(time.Duration) {}
|
||||
if err := daemonStop(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
||||
@@ -678,7 +684,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
base := time.Now()
|
||||
daemonNow = func() time.Time { return base }
|
||||
daemonSignalProcess = func(*os.Process, os.Signal) error { return nil }
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -687,7 +693,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
prepare(t, "live-signal-error-hooked", false)
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonSignalProcess = func(*os.Process, os.Signal) error { return errors.New("signal") }
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
|
||||
t.Fatal("signal error was ignored")
|
||||
}
|
||||
})
|
||||
@@ -710,7 +716,7 @@ func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
helperSleep = func(time.Duration) {}
|
||||
if err := daemonStop(&bytes.Buffer{}, "live-force-hooked"); err != nil {
|
||||
if _, err := daemonStopResult(&bytes.Buffer{}, "live-force-hooked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
||||
@@ -763,25 +769,25 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
|
||||
}
|
||||
resolveAppNames(cmd, connectResponseRunner{err: errors.New("offline")}, []connectHealthReport{{UnifiedAppID: "u-1"}})
|
||||
|
||||
list := newDevAppRobotConnectListCommand(runner)
|
||||
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
var out bytes.Buffer
|
||||
list.SetOut(&out)
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "no connectors") {
|
||||
t.Fatalf("empty list = %q, %v", out.String(), err)
|
||||
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "no connectors found" {
|
||||
t.Fatalf("empty list output = %q, %v", out.String(), err)
|
||||
}
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
out.Reset()
|
||||
list.SetOut(&out)
|
||||
list.SetArgs([]string{"--json"})
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "null") {
|
||||
t.Fatalf("json list = %q, %v", out.String(), err)
|
||||
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
||||
t.Fatalf("json list array = %q, %v", out.String(), err)
|
||||
}
|
||||
|
||||
seedHeartbeat(t, "listed", connectHeartbeat{
|
||||
Pid: os.Getpid(), ClientID: strings.Repeat("c", 80), Channel: strings.Repeat("x", 80),
|
||||
StartUnix: time.Now().Add(-time.Minute).Unix(), ConnectedUnix: time.Now().Add(-time.Minute).Unix(),
|
||||
})
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
out.Reset()
|
||||
list.SetOut(&out)
|
||||
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "STATE") {
|
||||
@@ -791,7 +797,7 @@ func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
|
||||
connectHealthReadDir = func(string) ([]os.DirEntry, error) {
|
||||
return nil, errors.New("read directory")
|
||||
}
|
||||
list = newDevAppRobotConnectListCommand(runner)
|
||||
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
||||
list.SetOut(&bytes.Buffer{})
|
||||
if err := list.Execute(); err == nil {
|
||||
t.Fatal("list with blocked directory succeeded")
|
||||
@@ -806,6 +812,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
defaultFindProcess := daemonFindProcess
|
||||
|
||||
for _, command := range []*cobra.Command{newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStopCommand(), newDevAppRobotConnectRestartCommand()} {
|
||||
command = prepareUnifiedTestCommand(command)
|
||||
command.SetArgs(nil)
|
||||
command.SetOut(&bytes.Buffer{})
|
||||
command.SetErr(&bytes.Buffer{})
|
||||
@@ -814,20 +821,20 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
status := newDevAppRobotConnectStatusCommand()
|
||||
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
|
||||
status.SetArgs([]string{"--robot-client-id", "missing", "--json"})
|
||||
status.SetOut(&bytes.Buffer{})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("status command = %v", err)
|
||||
}
|
||||
stop := newDevAppRobotConnectStopCommand()
|
||||
stop.SetArgs([]string{"--unified-app-id", "missing"})
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
stop.SetArgs([]string{"--unified-app-id", "missing", "--yes"})
|
||||
stop.SetOut(&bytes.Buffer{})
|
||||
if err := stop.Execute(); err != nil {
|
||||
t.Fatalf("stop command = %v", err)
|
||||
}
|
||||
|
||||
restart := newDevAppRobotConnectRestartCommand()
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "missing"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -840,7 +847,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "blocked"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -852,7 +859,7 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
if err := os.WriteFile(daemonStatePath(corruptDir), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "corrupt-restart"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
@@ -879,8 +886,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
|
||||
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
if err := restart.Execute(); err != nil {
|
||||
@@ -892,8 +899,8 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
}
|
||||
daemonProcessAlive = func(int) bool { return true }
|
||||
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
||||
restart.SetOut(&bytes.Buffer{})
|
||||
restart.SetErr(&bytes.Buffer{})
|
||||
if err := restart.Execute(); err != nil {
|
||||
@@ -906,10 +913,19 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(cmd)
|
||||
return err
|
||||
}
|
||||
restart = newDevAppRobotConnectRestartCommand()
|
||||
root.AddCommand(restart)
|
||||
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override"})
|
||||
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override", "--yes"})
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
if err := root.Execute(); err != nil {
|
||||
@@ -941,3 +957,50 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal("failing restart subprocess succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkConnectControlDryRunPlansAndLegacyListRollback(t *testing.T) {
|
||||
preserveDaemonHooks(t)
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
stop.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var stopOut bytes.Buffer
|
||||
stop.SetOut(&stopOut)
|
||||
if err := stop.Execute(); err != nil || !strings.Contains(stopOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("stop preview=%q err=%v", stopOut.String(), err)
|
||||
}
|
||||
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var restartOut bytes.Buffer
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
dir, err := connectDaemonDir("saved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeDaemonState(dir, daemonState{DirKey: "saved", UnifiedAppID: "saved-app", Channel: "custom"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "saved", "--dry-run"})
|
||||
restartOut.Reset()
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), "saved-app") {
|
||||
t.Fatalf("saved restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(connectResponseRunner{response: map[string]any{"items": []any{}, "hasMore": false}}))
|
||||
output.SetCommandRollout(list, output.RolloutLegacyOnly)
|
||||
for _, format := range []string{"json", "table", "pretty"} {
|
||||
var out bytes.Buffer
|
||||
list.SetOut(&out)
|
||||
list.SetArgs([]string{"--format", format})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("legacy list %s: %v", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestConnectStopAndRestartDoNotRequireConfirmation(t *testing.T) {
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newDevAppRobotConnectStopCommand(),
|
||||
newDevAppRobotConnectRestartCommand(),
|
||||
} {
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Safety == nil {
|
||||
t.Fatalf("%s missing final safety declaration", cmd.Name())
|
||||
}
|
||||
if got := final.Safety.Confirmation; got != "not_required" {
|
||||
t.Fatalf("%s confirmation = %q, want not_required", cmd.Name(), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectDaemonFamilyMissingDaemonErrorPaths 是队列 B116 的「daemon 不存在
|
||||
// 错误路径」分支:对不存在的守护进程,status/stop 不得把它当成失败(空态是合法
|
||||
// 载荷,AC-06——status 保持已发布的顶层 JSON 并如实标注 not_running),restart 因无法安全
|
||||
// 重建而报 validation 错误,缺定位标识则统一报 validation(错误路径继续走
|
||||
// apperrors 通道,不进信封)。命令级端到端,stdout/stderr 分流断言。
|
||||
func TestConnectDaemonFamilyMissingDaemonErrorPaths(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
// status:daemon 不存在 → 兼容顶层 JSON,state=not_running(非错误)。
|
||||
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
|
||||
var statusOut, statusErr bytes.Buffer
|
||||
status.SetOut(&statusOut)
|
||||
status.SetErr(&statusErr)
|
||||
status.SetArgs([]string{"--robot-client-id", "ghost", "--json"})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("status on missing daemon must not error, got %v\nstderr:\n%s", err, statusErr.String())
|
||||
}
|
||||
var statusReport connectHealthReport
|
||||
if err := json.Unmarshal(statusOut.Bytes(), &statusReport); err != nil {
|
||||
t.Fatalf("status output is not the published top-level JSON: %v\n%s", err, statusOut.String())
|
||||
}
|
||||
if statusReport.State != healthNotRunning {
|
||||
t.Fatalf("status state = %q, want %q: %s", statusReport.State, healthNotRunning, statusOut.String())
|
||||
}
|
||||
if strings.Contains(statusOut.String(), `"outcome"`) || strings.Contains(statusOut.String(), `"data"`) {
|
||||
t.Fatalf("status compatibility JSON was enveloped: %s", statusOut.String())
|
||||
}
|
||||
|
||||
// stop:daemon 不存在 → ok:true 信封,data.status=not_running;人读行走 stderr。
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
var stopOut, stopErr bytes.Buffer
|
||||
stop.SetOut(&stopOut)
|
||||
stop.SetErr(&stopErr)
|
||||
stop.SetArgs([]string{"--unified-app-id", "ghost"})
|
||||
if err := stop.Execute(); err != nil {
|
||||
t.Fatalf("stop on missing daemon must not error, got %v\nstderr:\n%s", err, stopErr.String())
|
||||
}
|
||||
stopEnv := decodePhaseFEnvelope(t, stopOut.Bytes())
|
||||
if !stopEnv.OK || stopEnv.Outcome != "success" {
|
||||
t.Fatalf("stop envelope ok/outcome = %v/%q, want true/success: %s",
|
||||
stopEnv.OK, stopEnv.Outcome, stopOut.String())
|
||||
}
|
||||
if st, _ := stopEnv.Data["status"].(string); st != "not_running" {
|
||||
t.Fatalf("stop data.status = %q, want not_running: %s", st, stopOut.String())
|
||||
}
|
||||
if !strings.Contains(stopErr.String(), "not running") {
|
||||
t.Fatalf("stop human-readable line missing from stderr: %q", stopErr.String())
|
||||
}
|
||||
|
||||
// restart:daemon 不存在 → validation 错误(无法安全重建),非信封。
|
||||
// SilenceUsage 对齐生产根命令(internal/app/root.go:SilenceUsage=true),
|
||||
// 否则单叶子 Execute() 报错时 Cobra 默认把 usage 打 stdout,污染断言。
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SilenceUsage = true
|
||||
var restartOut, restartErr bytes.Buffer
|
||||
restart.SetOut(&restartOut)
|
||||
restart.SetErr(&restartErr)
|
||||
restart.SetArgs([]string{"--robot-client-id", "ghost"})
|
||||
err := restart.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "未找到连接器记录") {
|
||||
t.Fatalf("restart on missing daemon error = %v, want 未找到连接器记录", err)
|
||||
}
|
||||
if restartOut.Len() != 0 {
|
||||
t.Fatalf("restart error path must keep stdout empty, got %q", restartOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectDaemonFamilyRequiresLocatorIdentity 是队列 B116 的配套断言:
|
||||
// status/stop/restart 无定位标识(--robot-client-id / --unified-app-id 均缺)
|
||||
// 时统一报 validation 错误,错误路径不进信封、stdout 零字节。
|
||||
func TestConnectDaemonFamilyRequiresLocatorIdentity(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
for _, build := range []func() *cobra.Command{
|
||||
func() *cobra.Command { return newDevAppRobotConnectStatusCommand() },
|
||||
func() *cobra.Command { return newDevAppRobotConnectStopCommand() },
|
||||
func() *cobra.Command { return newDevAppRobotConnectRestartCommand() },
|
||||
} {
|
||||
cmd := prepareUnifiedTestCommand(build())
|
||||
cmd.SilenceUsage = true
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "需要 --robot-client-id 或 --unified-app-id") {
|
||||
t.Fatalf("%s without locator error = %v, want 定位守护进程 validation", cmd.Name(), err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("%s validation error must keep stdout empty, got %q", cmd.Name(), out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,10 @@ func TestDaemonStatusJSON(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
// --json is a published supervisor API: health fields remain at top level.
|
||||
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"data"`) {
|
||||
t.Fatalf("status compatibility JSON was enveloped: %s", buf.String())
|
||||
}
|
||||
if report.State != healthHealthy {
|
||||
t.Errorf("state = %q, want %q", report.State, healthHealthy)
|
||||
}
|
||||
@@ -281,7 +285,7 @@ func TestDaemonStopNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "ghost"); err != nil {
|
||||
if _, err := daemonStopResult(&buf, "ghost"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
@@ -295,7 +299,7 @@ func TestDaemonStopStaleCleansPidFile(t *testing.T) {
|
||||
dir, _ := connectDaemonDir("stalestop")
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "stalestop"); err != nil {
|
||||
if _, err := daemonStopResult(&buf, "stalestop"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
|
||||
|
||||
@@ -84,7 +84,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand())
|
||||
doc.AddCommand(newDevDocSearchCommand(runner))
|
||||
|
||||
root.AddCommand(
|
||||
newDevAppCommand(runner),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type countingDevUnifiedRunner struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingDevUnifiedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls++
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"content": map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"id": "dev-1", "hasMore": false},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newDevUnifiedRoot(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(cmd)
|
||||
return err
|
||||
}
|
||||
root.AddCommand(devHandler{}.Command(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
func TestDevAppUnifiedActiveExecutesOnceAndReturnsFrameworkResult(t *testing.T) {
|
||||
runner := &countingDevUnifiedRunner{}
|
||||
root := newDevUnifiedRoot(runner)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs([]string{"dev", "app", "list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls=%d, want exactly 1", runner.calls)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || !bytes.Contains(stdout.Bytes(), []byte(`"id": "dev-1"`)) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedDevAppDefaultsToUnifiedFramework(t *testing.T) {
|
||||
runner := &countingDevUnifiedRunner{}
|
||||
root := newDevUnifiedRoot(runner)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"dev", "app", "list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls=%d, want 1", runner.calls)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"outcome": "success"`)) || bytes.Contains(stdout.Bytes(), []byte(`"contract_version"`)) {
|
||||
t.Fatalf("migrated dev command did not use unified output by default: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevDocSearchStaysLegacyUntilPagePaginationContractExists(t *testing.T) {
|
||||
cmd := newDevDocSearchCommand(&countingDevUnifiedRunner{})
|
||||
if got := output.CommandRollout(cmd); got != output.RolloutLegacyOnly {
|
||||
t.Fatalf("dev doc search rollout=%s, want legacy_only until page pagination is modeled", got)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatal("dev doc search is missing ContractFinal")
|
||||
}
|
||||
if final.Result != nil || final.Pagination != nil {
|
||||
t.Fatalf("legacy dev doc search must not publish unified result/pagination schema: %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevTerminalRolloutKeepsPublishedConnectStatusLegacy(t *testing.T) {
|
||||
root := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
dev, _, err := root.Find([]string{"dev"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var walk func(*cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
children := cmd.Commands()
|
||||
if cmd.Runnable() && len(children) == 0 {
|
||||
want := output.RolloutUnifiedActive
|
||||
switch cmd.CommandPath() {
|
||||
case "dws dev connect status", "dws dev connect list", "dws dev doc search":
|
||||
want = output.RolloutLegacyOnly
|
||||
}
|
||||
if got := output.CommandRollout(cmd); got != want {
|
||||
t.Errorf("%s rollout=%s, want %s", cmd.CommandPath(), got, want)
|
||||
}
|
||||
}
|
||||
if cmd.CommandPath() == "dws dev connect" && output.CommandRollout(cmd) != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s must remain legacy until a streaming contract exists", cmd.CommandPath())
|
||||
}
|
||||
for _, child := range children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(dev)
|
||||
}
|
||||
|
||||
func TestDevConnectStatusPreservesPublishedHumanAndJSONShapes(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
humanRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
var humanOut bytes.Buffer
|
||||
humanRoot.SetOut(&humanOut)
|
||||
humanRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing"})
|
||||
if err := humanRoot.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(humanOut.Bytes(), []byte("not_running")) || bytes.Contains(humanOut.Bytes(), []byte(`"outcome"`)) {
|
||||
t.Fatalf("default status output no longer uses the legacy human view: %s", humanOut.String())
|
||||
}
|
||||
|
||||
jsonRoot := newDevUnifiedRoot(&countingDevUnifiedRunner{})
|
||||
var jsonOut bytes.Buffer
|
||||
jsonRoot.SetOut(&jsonOut)
|
||||
jsonRoot.SetArgs([]string{"dev", "connect", "status", "--robot-client-id", "missing", "--json"})
|
||||
if err := jsonRoot.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("legacy --json output is invalid: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if payload["state"] != "not_running" || payload["supervised"] != false {
|
||||
t.Fatalf("legacy health fields moved or changed: %#v", payload)
|
||||
}
|
||||
for _, forbidden := range []string{"ok", "outcome", "data"} {
|
||||
if _, exists := payload[forbidden]; exists {
|
||||
t.Fatalf("legacy --json unexpectedly gained unified key %q: %#v", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+490
-11
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -79,6 +80,36 @@ const (
|
||||
devAppVersionStatusTool = "get_dev_app_version_status"
|
||||
)
|
||||
|
||||
func devAppPaginatedItemsResult(description, itemDescription string) *contract.ResultSpec {
|
||||
schema, _ := json.Marshal(map[string]any{
|
||||
"type": "object",
|
||||
"description": description,
|
||||
"additionalProperties": true,
|
||||
"properties": map[string]any{
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": itemDescription + ";分页控制信息只读取 meta.pagination",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"items"},
|
||||
})
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
func devAppCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
}
|
||||
}
|
||||
|
||||
// newDevAppCommand builds the `app` subtree of `dws dev`. The cobra path is
|
||||
// `dws dev app ...` while the MCP product id stays "devapp" — the id is a
|
||||
// backend contract (SupplementServers/StaticServers injection key and the
|
||||
@@ -289,6 +320,8 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询应用已订阅的事件列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页应用订阅事件查询结果", "当前页事件记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出或搜索应用可订阅的事件",
|
||||
@@ -402,7 +435,12 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询开放平台企业内部应用列表",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"当前页应用查询结果","properties":{"items":{"type":"array","description":"当前页应用记录","items":{"type":"object","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"}},"additionalProperties":true}}},"required":["items"],"additionalProperties":true}`),
|
||||
},
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按条件分页查询开放平台应用",
|
||||
UseWhen: []string{"需要按名称、创建人或应用键筛选应用时"},
|
||||
@@ -445,7 +483,11 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询开放平台企业内部应用详情",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台企业内部应用详情","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"},"agentId":{"description":"应用 Agent ID;具体类型由服务端返回"},"status":{"description":"应用当前状态;具体类型由服务端返回"}},"required":["unifiedAppId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定开放平台应用详情",
|
||||
UseWhen: []string{"已知 unifiedAppId 或 appKey 并需要核对应用配置或状态时"},
|
||||
@@ -565,7 +607,12 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "读取开放平台应用凭证",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"开放平台应用凭证;敏感字段必须经过统一脱敏策略","properties":{"clientId":{"type":"string","description":"OAuth 客户端 ID"},"clientSecret":{"type":"string","description":"OAuth 客户端密钥"},"appKey":{"type":"string","description":"应用 AppKey"},"appSecret":{"type":"string","description":"应用 AppSecret"}},"additionalProperties":true}`),
|
||||
SensitivePaths: []string{"appSecret", "clientSecret"},
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取指定应用的客户端凭证",
|
||||
UseWhen: []string{"已知 unifiedAppId 且需要 clientId 或 clientSecret 时"},
|
||||
@@ -863,9 +910,12 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||||
CanonicalPath: "dev.list_dev_app_permissions",
|
||||
CLIPath: "dev app permission list",
|
||||
PrimaryCLIPath: "dev app permission list",
|
||||
Aliases: []string{"dev app permission search"},
|
||||
},
|
||||
Description: "查询开放平台应用权限列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页开放平台应用权限查询结果", "当前页权限记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询应用权限及其授权状态",
|
||||
@@ -1537,6 +1587,8 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "分页查询应用版本列表",
|
||||
DryRun: devAppDryRun,
|
||||
Result: devAppPaginatedItemsResult("当前页开放平台应用版本查询结果", "当前页版本记录"),
|
||||
Pagination: devAppCursorPagination(),
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页列出应用的历史和待发布版本",
|
||||
@@ -1596,8 +1648,23 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command
|
||||
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
|
||||
},
|
||||
ConstParams: map[string]any{"precheckOnly": true},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMeta(devAppVersionPublishTool),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "version_check_approval", CanonicalPath: "dev.version_check_approval",
|
||||
CLIPath: "dev app version check-approval", PrimaryCLIPath: "dev app version check-approval",
|
||||
},
|
||||
Description: "预检版本发布是否需要审批,不执行发布",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "预检应用版本的审批要求和候选审批人",
|
||||
UseWhen: []string{"发布版本前确认是否需要审批及候选审批人"},
|
||||
AvoidWhen: []string{"实际发布版本时使用 dev app version publish"},
|
||||
Examples: []string{"dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId>"},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMeta(devAppVersionPublishTool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1661,7 +1728,11 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
Description: "查询版本发布/审批状态",
|
||||
DryRun: devAppDryRun,
|
||||
Interface: devAppCompositeInterface(),
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomePending, contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"应用版本发布或审批状态","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"versionId":{"type":"string","description":"应用版本 ID"},"status":{"type":"string","description":"归一化版本状态"},"versionStatus":{"type":"string","description":"服务端版本状态"},"approvalStatus":{"type":"string","description":"版本审批状态"},"nextCommand":{"type":"string","description":"状态未终结时可执行的下一条命令"}},"required":["versionId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: devAppCompositeInterface(),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定应用版本的发布或审批状态",
|
||||
UseWhen: []string{"需要判断版本是否已发布、审核中或受阻时"},
|
||||
@@ -1703,6 +1774,9 @@ func devAppLeafMeta(cmd *cobra.Command, tool string) {
|
||||
cmd.DisableAutoGenTag = true
|
||||
preferLegacyLeaf(cmd)
|
||||
annotateDevAppTool(cmd, tool)
|
||||
// This leaf has migrated to the unified result framework. Consumers keep using --format;
|
||||
// the active contract is a release property, not an Agent-selected flag.
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
}
|
||||
|
||||
// devAppCall 返回统一派发闭包(替代各命令重复的 Call: runDevAppTool 透传)。
|
||||
@@ -1776,6 +1850,9 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The requested tool is the local contract authority. Do not let an empty or
|
||||
// stale runner echo disable tool-specific fail-closed projection rules.
|
||||
result.Invocation.Tool = tool
|
||||
// Unwrap the ServiceResult envelope and apply per-tool response fixes before
|
||||
// rendering, so agents read the inner payload directly and pretty-annotation
|
||||
// walks the already-normalized content.
|
||||
@@ -1783,7 +1860,409 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para
|
||||
if devAppPrettyWanted(cmd) {
|
||||
devAppPrettyAnnotate(tool, result.Response)
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
return writeDevAppEnvelope(cmd, result)
|
||||
}
|
||||
|
||||
func devAppCommandResult(result executor.Result) output.CommandResult {
|
||||
data := devAppEnvelopeData(result)
|
||||
if content, ok := data.(map[string]any); ok {
|
||||
if partial := devAppMultiProfileResult(content); partial != nil {
|
||||
return partial
|
||||
}
|
||||
if failure := devAppFailureResult(content); failure != nil {
|
||||
return failure
|
||||
}
|
||||
// check-approval reports what a later publish would require; it does not
|
||||
// itself accept an asynchronous operation.
|
||||
precheckOnly, _ := result.Invocation.Params["precheckOnly"].(bool)
|
||||
if !precheckOnly {
|
||||
if pending := devAppPendingResult(content); pending != nil {
|
||||
return pending
|
||||
}
|
||||
}
|
||||
}
|
||||
successOptions := make([]output.ResultOption, 0, 2)
|
||||
if result.Invocation.DryRun {
|
||||
successOptions = append(successOptions, output.WithDryRun())
|
||||
}
|
||||
if meta, err := devAppPaginationMeta(data); err != nil {
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(),
|
||||
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
|
||||
})
|
||||
} else if meta != nil {
|
||||
successOptions = append(successOptions, output.WithMeta(meta))
|
||||
return output.Success(devAppDataWithoutPagination(data), successOptions...)
|
||||
} else if devAppToolRequiresPagination(result.Invocation.Tool) && !result.Invocation.DryRun {
|
||||
// A dry-run payload is a completed local invocation preview, not a
|
||||
// server list response. Only real responses must prove pagination.
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Subtype: "pagination_inconsistent",
|
||||
Message: "declared paginated response is missing hasMore and nextCursor",
|
||||
Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。",
|
||||
})
|
||||
}
|
||||
return output.Success(data, successOptions...)
|
||||
}
|
||||
|
||||
func devAppToolRequiresPagination(tool string) bool {
|
||||
switch strings.TrimSpace(tool) {
|
||||
case devAppListTool, devAppPermissionListTool, devAppEventListTool, devAppVersionListTool:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DevAppCommandResultFromPayload is the shared dingtalk-dev outcome mapper for
|
||||
// the native `dev ...` tree and the existing `devapp +...` shortcut tree. Both
|
||||
// entry points must classify the same upstream payload into the same unified
|
||||
// outcome; only command routing and projection differ.
|
||||
func DevAppCommandResultFromPayload(tool string, payload any, dryRun bool) output.CommandResult {
|
||||
response := map[string]any{"content": payload}
|
||||
if object, ok := payload.(map[string]any); ok {
|
||||
if _, wrapped := object["content"]; wrapped {
|
||||
response = object
|
||||
}
|
||||
}
|
||||
result := executor.Result{
|
||||
Invocation: executor.Invocation{
|
||||
Implemented: true,
|
||||
Kind: "helper_invocation",
|
||||
DryRun: dryRun,
|
||||
Tool: tool,
|
||||
},
|
||||
Response: response,
|
||||
}
|
||||
result = normalizeDevAppServiceResult(result)
|
||||
if strings.TrimSpace(tool) != "" {
|
||||
result = normalizeDevAppToolResult(tool, result)
|
||||
}
|
||||
return devAppCommandResult(result)
|
||||
}
|
||||
|
||||
// writeDevRolloutResult is the gradual migration seam shared by dingtalk-dev.
|
||||
// The operation is executed exactly once; only the renderer changes. Legacy
|
||||
// remains active only for commands that have not advanced to unified_active.
|
||||
func writeDevRolloutResult(cmd *cobra.Command, result output.CommandResult, legacy *output.Envelope, fallback output.Format) error {
|
||||
if output.UsesUnifiedResult(cmd) {
|
||||
return output.StoreResult(cmd.Context(), result)
|
||||
}
|
||||
if output.CommandRollout(cmd) == output.RolloutDualValidate {
|
||||
// Shadow-build/validate the unified result without a second business invocation and
|
||||
// without changing stdout. Metrics can be added around this seam later.
|
||||
if err := output.ValidateResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return output.WriteEnvelope(cmd, legacy, fallback)
|
||||
}
|
||||
|
||||
// writeDevAppEnvelope 是 dev app 全树的统一信封出口(统一输出 dev 域试点,
|
||||
// 队列 Phase F)。成功 → ok:true/outcome:success + data;--dry-run →
|
||||
// ok:true/outcome:success + dry_run:true:dry-run 是已完成的预演,不是异步未终态。
|
||||
// exit 0,参数非法仍走 validation 报错——错误路径继续由 apperrors 通道承载)。
|
||||
// json(默认)输出完整信封(唯一 JSON 契约);其余 format 渲染业务数据。
|
||||
// 复用 internal/output 的权威 Envelope 类型与 WriteEnvelope 出口函数。
|
||||
func writeDevAppEnvelope(cmd *cobra.Command, result executor.Result) error {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Outcome: output.OutcomeSuccess,
|
||||
Data: devAppEnvelopeData(result),
|
||||
}
|
||||
if result.Invocation.DryRun {
|
||||
env.DryRun = true
|
||||
} else {
|
||||
env.Meta, _ = devAppPaginationMeta(env.Data)
|
||||
}
|
||||
return writeDevRolloutResult(cmd, devAppCommandResult(result), env, output.FormatJSON)
|
||||
}
|
||||
|
||||
// devAppEnvelopeData 从工具调用结果中提取业务载荷(L2):已实现的
|
||||
// helper/compat 调用把载荷放在 response.content 下;其余形态(如 dry-run
|
||||
// 预演的 Result 整体)原样透传,与 output.unwrapCompatRuntimePayload 的
|
||||
// 解包规则保持一致,保证 data 即既有消费方看到的载荷。
|
||||
func devAppEnvelopeData(result executor.Result) any {
|
||||
if result.Invocation.Implemented {
|
||||
switch result.Invocation.Kind {
|
||||
case "compat_invocation", "helper_invocation":
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// devAppPaginationMeta 把列表载荷里的 cursor 分页字段投影到 meta.pagination
|
||||
// (契约规范 §3:分页元数据挂 meta 层)。CLI 只观察服务端
|
||||
// 返回的 hasMore/nextCursor,不做合成。hasMore=true 且带 nextCursor →
|
||||
// endpoint_exhausted:false + next_token(可续跑);hasMore=false →
|
||||
// endpoint_exhausted:true。hasMore=true 却无 cursor 时不产出分页元数据,
|
||||
// 避免违反「endpoint_exhausted:false 必须携带 next_token」。统一结果通过
|
||||
// devAppDataWithoutPagination 从 data 剥离源控制字段;legacy renderer 不变。
|
||||
func devAppPaginationMeta(payload any) (*output.Meta, error) {
|
||||
m, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rawHasMore, hasFlag := m["hasMore"]
|
||||
hasMore, hasMoreBool := rawHasMore.(bool)
|
||||
if hasFlag && !hasMoreBool {
|
||||
return nil, fmt.Errorf("pagination hasMore must be a JSON boolean")
|
||||
}
|
||||
cursor := ""
|
||||
rawCursor, hasCursor := m["nextCursor"]
|
||||
if hasCursor {
|
||||
value, stringOK := rawCursor.(string)
|
||||
if !stringOK {
|
||||
return nil, fmt.Errorf("pagination nextCursor must be a JSON string")
|
||||
}
|
||||
cursor = strings.TrimSpace(value)
|
||||
}
|
||||
if !hasFlag && !hasCursor {
|
||||
return nil, nil
|
||||
}
|
||||
pg := &output.Pagination{}
|
||||
switch {
|
||||
case hasMore && cursor != "":
|
||||
pg.EndpointExhausted = false
|
||||
pg.NextToken = cursor
|
||||
case hasFlag && hasMore:
|
||||
return nil, fmt.Errorf("pagination hasMore=true is missing nextCursor")
|
||||
case hasFlag && !hasMore:
|
||||
// DingTalk may echo a terminal cursor even when hasMore=false. The
|
||||
// boolean is the authoritative exhaustion signal; never expose that
|
||||
// non-resumable cursor as meta.pagination.next_token.
|
||||
pg.EndpointExhausted = true
|
||||
case !hasFlag && hasCursor && cursor != "":
|
||||
pg.EndpointExhausted = false
|
||||
pg.NextToken = cursor
|
||||
default:
|
||||
return nil, fmt.Errorf("pagination nextCursor is empty without an exhaustion signal")
|
||||
}
|
||||
return &output.Meta{Pagination: pg}, nil
|
||||
}
|
||||
|
||||
func devAppDataWithoutPagination(payload any) any {
|
||||
object, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return payload
|
||||
}
|
||||
data := make(map[string]any, len(object))
|
||||
for key, value := range object {
|
||||
switch key {
|
||||
case "hasMore", "nextCursor":
|
||||
continue
|
||||
default:
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func devAppMultiProfileResult(content map[string]any) output.CommandResult {
|
||||
if !devAppContentBool(content, "multiProfile") {
|
||||
return nil
|
||||
}
|
||||
profiles, ok := content["profiles"].([]any)
|
||||
if !ok || len(profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
succeeded := make([]any, 0, len(profiles))
|
||||
failed := make([]output.PartialFailedEntry, 0)
|
||||
unknown := make([]output.PartialUnknownEntry, 0)
|
||||
for i, raw := range profiles {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
unknown = append(unknown, output.PartialUnknownEntry{
|
||||
ID: fmt.Sprintf("profile-%d", i+1),
|
||||
Reason: "profile result is malformed; terminal state cannot be confirmed",
|
||||
})
|
||||
continue
|
||||
}
|
||||
id := devAppFirstContentString(entry, "selector", "profile")
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("profile-%d", i+1)
|
||||
}
|
||||
if devAppContentBool(entry, "ok") {
|
||||
preserved := make(map[string]any, len(entry)+1)
|
||||
for key, value := range entry {
|
||||
preserved[key] = value
|
||||
}
|
||||
if _, exists := preserved["id"]; !exists {
|
||||
preserved["id"] = id
|
||||
}
|
||||
succeeded = append(succeeded, preserved)
|
||||
continue
|
||||
}
|
||||
errorInfo := &output.ErrorInfo{Type: "api", Message: "profile execution failed"}
|
||||
if errorMap, ok := entry["error"].(map[string]any); ok {
|
||||
errorInfo = devAppErrorInfo(errorMap, "profile execution failed")
|
||||
if category := devAppFirstContentString(errorMap, "type", "category"); category != "" {
|
||||
errorInfo.Type = devAppWireErrorType(category)
|
||||
}
|
||||
errorInfo.Subtype = devAppFirstContentString(errorMap, "subtype", "reason")
|
||||
errorInfo.Stage = devAppFirstContentString(errorMap, "stage")
|
||||
errorInfo.Origin = devAppFirstContentString(errorMap, "origin")
|
||||
errorInfo.Operation = devAppFirstContentString(errorMap, "operation")
|
||||
errorInfo.RequestID = devAppFirstContentString(errorMap, "request_id", "requestId")
|
||||
errorInfo.TraceID = devAppFirstContentString(errorMap, "trace_id", "traceId")
|
||||
errorInfo.Hint = devAppFirstContentString(errorMap, "hint")
|
||||
if retryable, present := errorMap["retryable"].(bool); present {
|
||||
errorInfo.Retryable = retryable
|
||||
}
|
||||
if executionStarted, present := errorMap["execution_started"].(bool); present {
|
||||
errorInfo.ExecutionStarted = &executionStarted
|
||||
}
|
||||
if actions, present := errorMap["actions"].([]string); present {
|
||||
errorInfo.Actions = append([]string(nil), actions...)
|
||||
} else if rawActions, present := errorMap["actions"].([]any); present {
|
||||
for _, rawAction := range rawActions {
|
||||
if action, ok := rawAction.(string); ok {
|
||||
errorInfo.Actions = append(errorInfo.Actions, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if details, present := errorMap["details"].(map[string]any); present {
|
||||
errorInfo.Details = details
|
||||
}
|
||||
}
|
||||
failed = append(failed, output.PartialFailedEntry{ID: id, Error: errorInfo})
|
||||
}
|
||||
if len(failed) == 0 && len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(succeeded) == 0 {
|
||||
details := make([]any, 0, len(failed)+len(unknown))
|
||||
for _, entry := range failed {
|
||||
details = append(details, map[string]any{"id": entry.ID, "error": entry.Error})
|
||||
}
|
||||
for _, entry := range unknown {
|
||||
details = append(details, map[string]any{"id": entry.ID, "unknown_reason": entry.Reason})
|
||||
}
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Message: "no selected profile has a confirmed success",
|
||||
Details: map[string]any{"profiles": details},
|
||||
})
|
||||
}
|
||||
partial, err := output.NewPartialData(len(profiles), succeeded, failed, unknown)
|
||||
if err != nil {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: err.Error()})
|
||||
}
|
||||
return output.Partial(partial)
|
||||
}
|
||||
|
||||
func devAppFailureResult(content map[string]any) output.CommandResult {
|
||||
status := strings.ToUpper(devAppFirstContentString(content, "status", "taskStatus", "versionStatus", "processStatus"))
|
||||
if rawSuccess, present := content["success"]; present {
|
||||
success, isBool := rawSuccess.(bool)
|
||||
if !isBool {
|
||||
return output.Failure(&output.ErrorInfo{
|
||||
Type: "api",
|
||||
Subtype: "invalid_success_type",
|
||||
Message: "dev response success field must be a JSON boolean",
|
||||
Hint: "写操作先核查目标状态;读取操作保留脱敏响应证据后排查上游。",
|
||||
Operation: "devapp.response_projection",
|
||||
})
|
||||
}
|
||||
if !success {
|
||||
return output.Failure(devAppErrorInfo(content, "dev operation failed"))
|
||||
}
|
||||
}
|
||||
if status == "FAIL" || status == "FAILED" || status == "EXPIRED" {
|
||||
return output.Failure(devAppErrorInfo(content, "dev operation "+strings.ToLower(status)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func devAppErrorInfo(content map[string]any, fallback string) *output.ErrorInfo {
|
||||
message := devAppFirstContentString(content, "errorMsg", "errorMessage", "message")
|
||||
if message == "" {
|
||||
message = fallback
|
||||
}
|
||||
info := &output.ErrorInfo{Type: "api", Message: message}
|
||||
if code, ok := content["errorCode"]; ok {
|
||||
info.UpstreamCode = code
|
||||
} else if code, ok := content["code"]; ok {
|
||||
info.UpstreamCode = code
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func devAppWireErrorType(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "api", "auth", "validation", "permission", "discovery", "internal":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
case "authorization", "forbidden":
|
||||
return "permission"
|
||||
default:
|
||||
// Upstream transport labels such as network/timeout are subtypes of an
|
||||
// API operation failure, not new top-level Agent branch keys.
|
||||
return "api"
|
||||
}
|
||||
}
|
||||
|
||||
func devAppPendingResult(content map[string]any) output.CommandResult {
|
||||
state := strings.ToUpper(devAppFirstContentString(content,
|
||||
"completionState", "status", "taskStatus", "versionStatus", "processStatus", "approvalStatus"))
|
||||
nonTerminal := !devAppContentBool(content, "terminal") && (devAppContentBool(content, "mustContinue") || devAppContentBool(content, "mustAskUser"))
|
||||
approvalPending := devAppContentBool(content, "approvalSubmitted") && !devAppContentBool(content, "published")
|
||||
isPendingState := state == "WAITING" || state == "PENDING" || state == "PROCESSING" || state == "AUDIT" ||
|
||||
state == "UNDER_REVIEW" || strings.HasPrefix(state, "WAITING_") || strings.HasPrefix(state, "BLOCKED_BY_")
|
||||
if !isPendingState && !nonTerminal && !approvalPending {
|
||||
return nil
|
||||
}
|
||||
if state == "" {
|
||||
state = "WAITING_FOR_ACTION"
|
||||
}
|
||||
id := devAppFirstContentString(content, "taskId", "versionId", "unifiedAppId", "requestId")
|
||||
if id == "" {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing an operation identifier"})
|
||||
}
|
||||
next := devAppFirstNextCommand(content)
|
||||
if next == "" {
|
||||
next = devAppRecoveryCommand(content)
|
||||
}
|
||||
if next == "" {
|
||||
return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing a recovery command"})
|
||||
}
|
||||
return output.Pending(content, &output.OperationInfo{ID: id, State: strings.ToLower(state), NextCommand: next})
|
||||
}
|
||||
|
||||
func devAppRecoveryCommand(content map[string]any) string {
|
||||
if taskID := devAppFirstContentString(content, "taskId"); taskID != "" {
|
||||
return fmt.Sprintf("dws dev app robot result --task-id %s --format json", taskID)
|
||||
}
|
||||
appID := devAppFirstContentString(content, "unifiedAppId")
|
||||
versionID := devAppFirstContentString(content, "versionId")
|
||||
if appID != "" && versionID != "" {
|
||||
return fmt.Sprintf("dws dev app version status --unified-app-id %s --version-id %s --format json", appID, versionID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func devAppFirstNextCommand(content map[string]any) string {
|
||||
steps, ok := content["nextSteps"].([]map[string]any)
|
||||
if ok {
|
||||
for _, step := range steps {
|
||||
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
|
||||
return command
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSteps, ok := content["nextSteps"].([]any); ok {
|
||||
for _, raw := range rawSteps {
|
||||
if step, ok := raw.(map[string]any); ok {
|
||||
if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" {
|
||||
return command
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeDevAppServiceResult unwraps the op-app ServiceResult envelope
|
||||
@@ -1909,7 +2388,7 @@ func normalizeDevAppVersionApproval(content map[string]any) {
|
||||
},
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "publish_version",
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --yes --format json", unifiedAppID, versionID),
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --format json", unifiedAppID, versionID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id <selectedUserId> --dry-run --format json", unifiedAppID, versionID),
|
||||
DoneWhen: "approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;published=true 表示已发布",
|
||||
Blocking: true,
|
||||
@@ -2031,7 +2510,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
|
||||
steps := []map[string]any{
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "create_version",
|
||||
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --yes --format json", appID),
|
||||
Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --format json", appID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --dry-run --format json", appID),
|
||||
DoneWhen: "返回 versionId",
|
||||
Blocking: true,
|
||||
@@ -2044,7 +2523,7 @@ func devAppRobotPublishSteps(appID string) []map[string]any {
|
||||
}),
|
||||
devAppNextStep(devAppStep{
|
||||
ID: "publish_version",
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --yes --format json", appID),
|
||||
Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --format json", appID),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id <versionId> --dry-run --format json", appID),
|
||||
DoneWhen: "published=true 表示已发布;approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;SELECT_APPROVER 时必须先让用户从 approvalCandidates 选择审批人后追加 --approver-user-id",
|
||||
RequiresUserInput: true,
|
||||
@@ -2095,7 +2574,7 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any {
|
||||
}
|
||||
return devAppNextStep(devAppStep{
|
||||
ID: "retry_robot_submit",
|
||||
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --yes --format json", taskIDFlag),
|
||||
Command: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --format json", taskIDFlag),
|
||||
DryRunCommand: fmt.Sprintf("dws dev app robot submit --name <name> --robot-name <robotName> --desc <desc>%s --dry-run --format json", taskIDFlag),
|
||||
DoneWhen: "返回新的 WAITING taskId;FAIL 场景优先复用原 taskId,EXPIRED 场景重新提交",
|
||||
Blocking: true,
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -417,11 +419,9 @@ func connectLocalDebugNotice() string {
|
||||
return "[connect] 提示:本地调试,不代表线上发布完成;dev connect 只建立本地 Stream,不会提交版本发布。若机器人来自 APPROVAL_REQUIRED,仍需继续执行 version create → check-approval → publish → status。\n"
|
||||
}
|
||||
|
||||
// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that
|
||||
// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top
|
||||
// level), so an agent can parse "is this a dry-run preview" the same way across
|
||||
// all dev commands. The connect-specific fields (channel/cli/connect/...) sit
|
||||
// inside, since connect is a linking pre-check, not an MCP tool call.
|
||||
// connectPreviewEnvelope preserves the established streaming-command preview
|
||||
// shape. `dev connect` remains legacy until a dedicated streaming contract is
|
||||
// available; terminal child commands migrate independently.
|
||||
func connectPreviewEnvelope(fields map[string]any) map[string]any {
|
||||
fields["kind"] = "connect_preview"
|
||||
fields["dry_run"] = true
|
||||
@@ -489,6 +489,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
daemonMode, _ := cmd.Flags().GetBool(daemonFlag)
|
||||
|
||||
// Credential resolution: explicit pair wins; otherwise reuse dev app's
|
||||
// credentials get against --unified-app-id.
|
||||
@@ -558,7 +559,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
// --daemon: detach into a background supervisor that keeps the
|
||||
// connector alive 7x24. We resolve credentials/channel first (above) so
|
||||
// the parent fails fast on bad input before forking, then re-exec.
|
||||
if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode {
|
||||
if daemonMode {
|
||||
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
|
||||
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
|
||||
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
|
||||
@@ -614,6 +615,29 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
|
||||
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
|
||||
cmd.Flags().String("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
// Foreground connect is a long-lived stream and remains legacy until a
|
||||
// dedicated streaming contract exists. Terminal child commands migrate
|
||||
// independently.
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "connect", CanonicalPath: "dev.connect",
|
||||
CLIPath: "dev connect", PrimaryCLIPath: "dev connect",
|
||||
},
|
||||
Description: "把现有机器人连接到本地 agent;该命令同时承载前台流与 --daemon,整体暂留 legacy,终态子命令独立迁移",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合远端凭证获取、Stream 建连与本地守护进程管理,不对应单一 MCP 接口"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把现有机器人连接到本地 agent 进行调试",
|
||||
UseWhen: []string{"需要启动本地机器人 Stream 调试连接"},
|
||||
AvoidWhen: []string{"创建或发布应用版本时使用 dev app"},
|
||||
Examples: []string{"dws dev connect --daemon --unified-app-id <unifiedAppId>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDevConnectListPreservesPublishedTableAndJSONArray(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
healthyDir, err := connectDaemonDir(daemonDirKey("dingAAA", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir(healthy): %v", err)
|
||||
}
|
||||
writeJSON(t, connectHeartbeatPath(healthyDir), connectHeartbeat{
|
||||
Pid: os.Getpid(), Channel: "codex", ClientID: "dingAAA",
|
||||
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
|
||||
})
|
||||
downDir, err := connectDaemonDir(daemonDirKey("dingBBB", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir(down): %v", err)
|
||||
}
|
||||
writeJSON(t, connectHeartbeatPath(downDir), connectHeartbeat{
|
||||
Pid: deadPid(t), Channel: "opencode", ClientID: "dingBBB",
|
||||
StartUnix: 1_000_000, ConnectedUnix: 1_000_010, UpdatedUnix: 2_000_000,
|
||||
})
|
||||
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
tableOut, tableErr, err := runRootBuffered(t, root, "dev", "connect", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list error = %v\nstderr:\n%s", err, tableErr.String())
|
||||
}
|
||||
table := tableOut.String()
|
||||
for _, value := range []string{"STATE", "CLIENT", "PID", "CHANNEL", "dingAAA", "dingBBB", "codex", "opencode"} {
|
||||
if !strings.Contains(table, value) {
|
||||
t.Fatalf("legacy table missing %q:\n%s", value, table)
|
||||
}
|
||||
}
|
||||
if strings.Contains(table, `"outcome"`) || strings.Contains(table, `"ok"`) {
|
||||
t.Fatalf("legacy table was enveloped:\n%s", table)
|
||||
}
|
||||
|
||||
jsonRoot := newDevAppTestRoot(&captureRunner{})
|
||||
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list --json error = %v\nstderr:\n%s", err, jsonErr.String())
|
||||
}
|
||||
var reports []connectHealthReport
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
|
||||
t.Fatalf("legacy --json is not a top-level array: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if len(reports) != 2 || strings.Contains(jsonOut.String(), `"outcome"`) || strings.Contains(jsonOut.String(), `"data"`) {
|
||||
t.Fatalf("legacy --json changed shape: %s", jsonOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevConnectListPreservesPublishedEmptyState(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
humanOut, humanErr, err := runRootBuffered(t, root, "dev", "connect", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list empty error = %v\nstderr:\n%s", err, humanErr.String())
|
||||
}
|
||||
if strings.TrimSpace(humanOut.String()) != "no connectors found" {
|
||||
t.Fatalf("legacy empty output=%q", humanOut.String())
|
||||
}
|
||||
|
||||
jsonRoot := newDevAppTestRoot(&captureRunner{})
|
||||
jsonOut, jsonErr, err := runRootBuffered(t, jsonRoot, "dev", "connect", "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("connect list empty --json error = %v\nstderr:\n%s", err, jsonErr.String())
|
||||
}
|
||||
var reports []connectHealthReport
|
||||
if err := json.Unmarshal(jsonOut.Bytes(), &reports); err != nil {
|
||||
t.Fatalf("legacy empty --json is not an array: %v\n%s", err, jsonOut.String())
|
||||
}
|
||||
if reports == nil || len(reports) != 0 {
|
||||
t.Fatalf("legacy empty --json=%s, want []", jsonOut.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// phaseFConnectRoot 构造 stdout/stderr 分流的可观测命令树根,供 connect 族
|
||||
// 流纪律断言使用(统一输出 dev 域试点,队列 B108/B109/B116)。
|
||||
func phaseFConnectRoot(t *testing.T, runner *captureRunner) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(runner)
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
return root, &stdout, &stderr
|
||||
}
|
||||
|
||||
type phaseFEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func decodePhaseFEnvelope(t *testing.T, raw []byte) *phaseFEnvelope {
|
||||
t.Helper()
|
||||
var env phaseFEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("stdout is not a single valid JSON envelope: %v\n%s", err, raw)
|
||||
}
|
||||
if (env.Outcome == "success" || env.Outcome == "pending") && !env.OK {
|
||||
t.Fatalf("envelope invariant I1 violated: ok=false with outcome=%q\n%s", env.Outcome, raw)
|
||||
}
|
||||
return &env
|
||||
}
|
||||
|
||||
// decodePhaseFConnectPreview parses the established legacy preview. The
|
||||
// streaming root remains legacy while terminal children migrate independently.
|
||||
func decodePhaseFConnectPreview(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(raw, &document); err != nil {
|
||||
t.Fatalf("stdout is not a single valid JSON document: %v\n%s", err, raw)
|
||||
}
|
||||
preview, ok := document["invocation"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("legacy connect preview missing invocation: %s", raw)
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
// TestDevConnectCustomChannelPreviewEnvelope 是队列 B108:--agent-cmd 自定义
|
||||
// 渠道(custom)的 dry-run 预览信封断言。--agent-cmd 是 custom 渠道的语法糖:
|
||||
// 未显式 --channel 时强制 custom,预览保持原有 invocation JSON shape。
|
||||
func TestDevConnectCustomChannelPreviewEnvelope(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
// RunE 内部用 os.Setenv 写 DWS_AGENT_CMD(不随 t.Setenv 恢复),显式清理防串测。
|
||||
t.Cleanup(func() { os.Unsetenv("DWS_AGENT_CMD") })
|
||||
|
||||
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--agent-cmd", "lobster -p",
|
||||
"--robot-client-id", "cid-1",
|
||||
"--robot-client-secret", "sec-1",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
data := decodePhaseFConnectPreview(t, stdout.Bytes())
|
||||
if data == nil {
|
||||
t.Fatalf("preview envelope data is nil: %s", stdout.String())
|
||||
}
|
||||
if data["channel"] != "custom" {
|
||||
t.Fatalf("channel = %#v, want custom (--agent-cmd 糖强制 custom): %s", data["channel"], stdout.String())
|
||||
}
|
||||
if data["kind"] != "connect_preview" || data["scope"] != "local_debug_only" {
|
||||
t.Fatalf("preview markers kind/scope = %#v/%#v: %s", data["kind"], data["scope"], stdout.String())
|
||||
}
|
||||
if data["clientId"] != "cid-1" {
|
||||
t.Fatalf("clientId = %#v, want cid-1: %s", data["clientId"], stdout.String())
|
||||
}
|
||||
if cred, _ := data["credentialSource"].(string); !strings.HasPrefix(cred, "flag:") {
|
||||
t.Fatalf("credentialSource = %#v, want flag:* 前缀: %s", data["credentialSource"], stdout.String())
|
||||
}
|
||||
if data["terminal"] != false || data["doesNotPublish"] != true {
|
||||
t.Fatalf("terminal/doesNotPublish = %#v/%#v, want false/true: %s",
|
||||
data["terminal"], data["doesNotPublish"], stdout.String())
|
||||
}
|
||||
if _, ok := data["connect"].(map[string]any); !ok {
|
||||
t.Fatalf("connect plan missing from preview data: %s", stdout.String())
|
||||
}
|
||||
|
||||
// secret 落 argv 的人读警告只走 stderr;stdout 不得混入人读文案。
|
||||
if !strings.Contains(stderr.String(), "--robot-client-secret 出现在命令行") {
|
||||
t.Fatalf("argv secret warning missing from stderr: %q", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "[connect]") {
|
||||
t.Fatalf("stdout must carry only the envelope, human-readable text leaked: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd 是队列 B116 的 dry-run
|
||||
// 端到端分支:--unified-app-id 路径的 dry-run 必须跳过 credentials get
|
||||
// (runner 零调用),预览信封如实标注凭证来源为「unified-app-id(skipped)」。
|
||||
func TestDevConnectDryRunUnifiedAppIDPreviewEndToEnd(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
runner := &captureRunner{}
|
||||
root, stdout, stderr := phaseFConnectRoot(t, runner)
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--channel", "hermes",
|
||||
"--unified-app-id", "u-9",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
data := decodePhaseFConnectPreview(t, stdout.Bytes())
|
||||
if data == nil {
|
||||
t.Fatalf("preview envelope data is nil: %s", stdout.String())
|
||||
}
|
||||
if data["channel"] != "hermes" {
|
||||
t.Fatalf("channel = %#v, want hermes: %s", data["channel"], stdout.String())
|
||||
}
|
||||
if data["unifiedAppId"] != "u-9" {
|
||||
t.Fatalf("unifiedAppId = %#v, want u-9: %s", data["unifiedAppId"], stdout.String())
|
||||
}
|
||||
if data["credentialSource"] != "unified-app-id (credentials get, skipped in dry-run)" {
|
||||
t.Fatalf("credentialSource = %#v, want dry-run skipped marker: %s", data["credentialSource"], stdout.String())
|
||||
}
|
||||
// dry-run 禁止真实调用 credentials get。
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("dry-run must not invoke any tool, got %q", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectForegroundStdoutDiscipline 是队列 B109:前台启动路径的
|
||||
// channel/凭证来源提示行与本地调试声明保持 stderr,stdout 严格零字节
|
||||
// (契约规范 §5.1:stdout 只承载数据;前台 connector 的后续输出由被 stub 的
|
||||
// stream 层承载,本测试验证 dws 自身不向 stdout 写任何人读文案)。
|
||||
func TestDevConnectForegroundStdoutDiscipline(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_CMD", "")
|
||||
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origStream := devAppRunStreamConnector
|
||||
t.Cleanup(func() { devAppRunStreamConnector = origStream })
|
||||
devAppRunStreamConnector = func(context.Context, string, string, string, forwarder, *aiCardClient, *connectExtras) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
root, stdout, stderr := phaseFConnectRoot(t, &captureRunner{})
|
||||
root.SetArgs([]string{
|
||||
"dev", "connect",
|
||||
"--channel", "custom",
|
||||
"--robot-client-id", "cid-1",
|
||||
"--robot-client-secret", "sec-1",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("foreground connect must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
errText := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[connect] channel=custom",
|
||||
"凭证来源=flag:--robot-client-id/--robot-client-secret",
|
||||
"不代表线上发布完成",
|
||||
} {
|
||||
if !strings.Contains(errText, want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", want, errText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
// registerDevAppCursorFlags adds the two cursor flags every list/search command
|
||||
// exposes. pageSize defaults to 20.
|
||||
func registerDevAppCursorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次出参的 nextCursor")
|
||||
cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次 meta.pagination.next_token")
|
||||
cmd.Flags().Int("page-size", 20, "单页条数,默认 20")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// collectDevLeafCommands 递归收集命令树中所有叶子命令(无子命令者)。
|
||||
func collectDevLeafCommands(cmd *cobra.Command, out *[]*cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if len(cmd.Commands()) == 0 {
|
||||
*out = append(*out, cmd)
|
||||
return
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
collectDevLeafCommands(sub, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevRepresentativeResultContractsReachContractFinal(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
finals := make(map[string]contract.ContractFinalPayload)
|
||||
for _, leaf := range leaves {
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if ok && final.Identity != nil {
|
||||
finals[final.Identity.CanonicalPath] = final
|
||||
}
|
||||
}
|
||||
|
||||
paginationCheck := func(t *testing.T, final contract.ContractFinalPayload) {
|
||||
t.Helper()
|
||||
if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor ||
|
||||
final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
||||
t.Fatalf("paginated list contract = %#v", final)
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
canonical string
|
||||
outcomes []contract.ResultOutcome
|
||||
check func(*testing.T, contract.ContractFinalPayload)
|
||||
}{
|
||||
{
|
||||
canonical: "dev.list_dev_app",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
check: paginationCheck,
|
||||
},
|
||||
{canonical: "dev.list_dev_app_permissions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.list_dev_app_events", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.list_dev_app_versions", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, check: paginationCheck},
|
||||
{canonical: "dev.get_dev_app", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
{
|
||||
canonical: "dev.get_dev_app_credentials",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
check: func(t *testing.T, final contract.ContractFinalPayload) {
|
||||
result := final.Result
|
||||
if want := []string{"appSecret", "clientSecret"}; !reflect.DeepEqual(result.SensitivePaths, want) {
|
||||
t.Fatalf("sensitive paths = %#v, want %#v", result.SensitivePaths, want)
|
||||
}
|
||||
},
|
||||
},
|
||||
{canonical: "dev.get_dev_app_version_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure}},
|
||||
{canonical: "dev.connect_status", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
{canonical: "dev.connect_list", outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.canonical, func(t *testing.T) {
|
||||
final, ok := finals[test.canonical]
|
||||
if !ok || final.Result == nil {
|
||||
t.Fatalf("representative leaf %s has no final Result", test.canonical)
|
||||
}
|
||||
if !reflect.DeepEqual(final.Result.Outcomes, test.outcomes) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, test.outcomes)
|
||||
}
|
||||
if len(final.Result.DataSchema) == 0 {
|
||||
t.Fatal("data_schema is empty")
|
||||
}
|
||||
if test.check != nil {
|
||||
test.check(t, final)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafDeclarationCompleteness 是队列 B190 的 dev 叶子声明完整性自查:
|
||||
// 每一个**注册了 ContractFinal** 的 dev 叶子都必须满足 Identity+Safety 最小集
|
||||
// (无半壳:有注册但字段缺失)。对未注册 ContractFinal 的叶子(裸 Cobra 迁移态
|
||||
// 或 NewLeafCommand 漏声明)不硬失败,而是汇总为 findings 清单打印,供人工判断
|
||||
// 是否属「空壳入册」。
|
||||
func TestDevLeafDeclarationCompleteness(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
|
||||
if len(leaves) == 0 {
|
||||
t.Fatalf("no dev leaves collected under test root")
|
||||
}
|
||||
t.Logf("dev domain leaves under test: %d", len(leaves))
|
||||
|
||||
var noFinal []string
|
||||
for _, leaf := range leaves {
|
||||
path := leaf.CommandPath()
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok {
|
||||
noFinal = append(noFinal, path)
|
||||
continue
|
||||
}
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if final.Identity == nil {
|
||||
t.Fatalf("leaf %q ContractFinal.Identity is nil", path)
|
||||
}
|
||||
if final.Identity.ProductID == "" {
|
||||
t.Fatalf("leaf %q Identity.ProductID empty", path)
|
||||
}
|
||||
if final.Identity.Name == "" {
|
||||
t.Fatalf("leaf %q Identity.Name empty", path)
|
||||
}
|
||||
if final.Identity.CanonicalPath == "" {
|
||||
t.Fatalf("leaf %q Identity.CanonicalPath empty", path)
|
||||
}
|
||||
if final.Safety == nil {
|
||||
t.Fatalf("leaf %q ContractFinal.Safety is nil (Safety minimal set missing)", path)
|
||||
}
|
||||
if final.Description == "" {
|
||||
t.Fatalf("leaf %q ContractFinal.Description empty (no description declared)", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(noFinal) > 0 {
|
||||
sort.Strings(noFinal)
|
||||
t.Logf("B190 findings: %d dev leaves without ContractFinal (may be intentional bare-Cobra migration or empty-shell NewLeafCommand):", len(noFinal))
|
||||
for _, p := range noFinal {
|
||||
t.Logf(" - %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafDeclarationNoEmptyShellNewLeaf 是队列 B190 的核心断言:NewLeafCommand
|
||||
// 完全托管的叶子必须注册 ContractFinal——声明了 LeafSpec 却无 ContractFinal 就是
|
||||
// 「空壳入册」。本测试通过 Verify 一个已知的既存漏洞(version check-approval 用
|
||||
// NewLeafCommand 但 LeafSpec 未声明 Contract)来固化当前现状,并断言这类「声明了
|
||||
// Shell 却无最终契约」的问题在受控已知集中,而非蔓延到其它托管叶子。
|
||||
func TestDevLeafDeclarationNoEmptyShellNewLeaf(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var leaves []*cobra.Command
|
||||
collectDevLeafCommands(root, &leaves)
|
||||
|
||||
knownEmptyShell := map[string]bool{}
|
||||
|
||||
var unexpected []string
|
||||
for _, leaf := range leaves {
|
||||
path := leaf.CommandPath()
|
||||
if _, ok := contractfinal.RuntimeContractFinal(leaf); ok {
|
||||
continue // 正常托管叶子已有 ContractFinal。
|
||||
}
|
||||
// 无 ContractFinal:裸 Cobra 迁移态(connect 系列)或已知空壳。
|
||||
if knownEmptyShell[path] {
|
||||
continue
|
||||
}
|
||||
// 裸 Cobra 命令(不经 NewLeafCommand)不要求 ContractFinal,排除。
|
||||
if isBareDevCobraLeaf(leaf) {
|
||||
continue
|
||||
}
|
||||
unexpected = append(unexpected, path)
|
||||
}
|
||||
if len(unexpected) > 0 {
|
||||
t.Fatalf("unexpected empty-shell NewLeafCommand leaves without ContractFinal: %v", unexpected)
|
||||
}
|
||||
}
|
||||
|
||||
// isBareDevCobraLeaf 判断某 dev 叶子是否为裸 Cobra 命令(不经 NewLeafCommand,
|
||||
// 不注册 ContractFinal)。通过多次构造命令并检查其是否有本地 flags 来间接识别
|
||||
// 不可靠,改用命令名白名单:connect 本地运维子命令为已知裸 Cobra 迁移态。
|
||||
func isBareDevCobraLeaf(cmd *cobra.Command) bool {
|
||||
switch cmd.Name() {
|
||||
case "list", "status", "restart", "stop":
|
||||
// 仅当命令位于 dev connect 子树下才算裸 Cobra(避免误伤 dev app 的
|
||||
// version list 等托管叶子)。
|
||||
if strings.Contains(cmd.CommandPath(), "dev connect") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
_ = contractfinal.HasRuntimeContractFinal
|
||||
_ = sort.Strings
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// devAppRegressionLeafPaths 遍历 dev app 命令树,收集全部叶子路径(无子命令
|
||||
// 即叶子)。核销清单的「清单 ⇆ 命令树」双向绑定依赖它:新增叶子若不在清单
|
||||
// 里,清单测试失败,强制信封覆盖随叶子同批落盘(AC-28/M1.8)。
|
||||
func devAppRegressionLeafPaths(t *testing.T) []string {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{}))
|
||||
appCmd, _, err := root.Find([]string{"dev", "app"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(dev app) error = %v", err)
|
||||
}
|
||||
var leaves []string
|
||||
var traverse func(c *cobra.Command, prefix []string)
|
||||
traverse = func(c *cobra.Command, prefix []string) {
|
||||
if !c.HasSubCommands() {
|
||||
leaves = append(leaves, strings.Join(prefix, " "))
|
||||
return
|
||||
}
|
||||
for _, sub := range c.Commands() {
|
||||
traverse(sub, append(append([]string{}, prefix...), sub.Name()))
|
||||
}
|
||||
}
|
||||
traverse(appCmd, nil)
|
||||
sort.Strings(leaves)
|
||||
return leaves
|
||||
}
|
||||
|
||||
// devAppEnvelopeRegressionChecklist 是 dev app 树 32 叶子的信封核销清单
|
||||
// (队列 B138,AC-28/M1.8):每条给出可执行 argv。读叶子走成功路径
|
||||
// (outcome=success),写叶子带 --dry-run 走预览路径(outcome=success +
|
||||
// dry_run=true)。argv 与轮 11-B0 族验证测试同源(各叶子 flag 已逐一核对)。
|
||||
func devAppEnvelopeRegressionChecklist() []struct {
|
||||
path string
|
||||
args []string
|
||||
write bool
|
||||
} {
|
||||
return []struct {
|
||||
path string
|
||||
args []string
|
||||
write bool
|
||||
}{
|
||||
{"list", []string{"dev", "app", "list", "--name", "DemoApp"}, false},
|
||||
{"get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"create", []string{"dev", "app", "create", "--name", "DemoApp", "--dry-run"}, true},
|
||||
{"update", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}, true},
|
||||
{"delete", []string{"dev", "app", "delete", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"disable", []string{"dev", "app", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"enable", []string{"dev", "app", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"credentials get", []string{"dev", "app", "credentials", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"webapp get", []string{"dev", "app", "webapp", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"webapp config", []string{"dev", "app", "webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com", "--dry-run"}, true},
|
||||
{"permission list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"permission add", []string{"dev", "app", "permission", "add", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
|
||||
{"permission remove", []string{"dev", "app", "permission", "remove", "--unified-app-id", "u-1", "--scope-values", "Contact.User.mobile", "--dry-run"}, true},
|
||||
{"member list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"member add", []string{"dev", "app", "member", "add", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
|
||||
{"member remove", []string{"dev", "app", "member", "remove", "--unified-app-id", "u-1", "--user-ids", "user-1", "--member-type", "DEVELOPER", "--dry-run"}, true},
|
||||
{"security config", []string{"dev", "app", "security", "config", "--unified-app-id", "u-1", "--redirect-urls", "https://cb.example.invalid/cb", "--dry-run"}, true},
|
||||
{"robot submit", []string{"dev", "app", "robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--dry-run"}, true},
|
||||
{"robot result", []string{"dev", "app", "robot", "result", "--task-id", "t-1"}, false},
|
||||
{"robot get", []string{"dev", "app", "robot", "get", "--unified-app-id", "u-1"}, false},
|
||||
{"robot config", []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--dry-run"}, true},
|
||||
{"robot enable", []string{"dev", "app", "robot", "enable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"robot disable", []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1", "--dry-run"}, true},
|
||||
{"version create", []string{"dev", "app", "version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人", "--dry-run"}, true},
|
||||
{"version list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"version get", []string{"dev", "app", "version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"version check-approval", []string{"dev", "app", "version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"version publish", []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--dry-run"}, true},
|
||||
{"version status", []string{"dev", "app", "version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}, false},
|
||||
{"event list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, false},
|
||||
{"event subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
|
||||
{"event unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--dry-run"}, true},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppEnvelopeRegressionLeafInventory 是核销清单的清单 ⇆ 命令树双向
|
||||
// 绑定:dev app 树叶子集合必须与清单完全一致。新增叶子未补信封断言、或
|
||||
// 清单残留已删除叶子,都会在此失败。
|
||||
func TestDevAppEnvelopeRegressionLeafInventory(t *testing.T) {
|
||||
tree := devAppRegressionLeafPaths(t)
|
||||
|
||||
want := make([]string, 0)
|
||||
for _, entry := range devAppEnvelopeRegressionChecklist() {
|
||||
want = append(want, entry.path)
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
if len(tree) != len(want) {
|
||||
t.Fatalf("leaf count = %d, checklist = %d\ntree: %v\nchecklist: %v",
|
||||
len(tree), len(want), tree, want)
|
||||
}
|
||||
for i := range tree {
|
||||
if tree[i] != want[i] {
|
||||
t.Fatalf("leaf[%d] = %q, checklist = %q\ntree: %v\nchecklist: %v",
|
||||
i, tree[i], want[i], tree, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// regressionEnvelope 是核销断言的信封解码形状:ok 用 *bool——缺席(未走
|
||||
// 统一出口)为 nil,`"ok":"true"` 字符串形态(违反 AC-02)直接解码失败。
|
||||
type regressionEnvelope struct {
|
||||
OK *bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// TestDevAppEnvelopeRegressionUnifiedExit 逐一执行 32 叶子并断言 stdout 恰为
|
||||
// 一个统一信封(走 writeDevAppEnvelope 唯一出口):ok 为 JSON 布尔 true、
|
||||
// outcome 与读/写形态一致、data 非空。任何绕过信封直写 stdout 的叶子
|
||||
// (legacy 裸 JSON/人读文案)都会因缺 ok 键或 JSON 解析失败而暴露。
|
||||
func TestDevAppEnvelopeRegressionUnifiedExit(t *testing.T) {
|
||||
for _, entry := range devAppEnvelopeRegressionChecklist() {
|
||||
t.Run(entry.path, func(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}),
|
||||
entry.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
|
||||
var env regressionEnvelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout is not a single JSON envelope (unified exit bypass?): %v\n%s", err, out.String())
|
||||
}
|
||||
if env.OK == nil {
|
||||
t.Fatalf("envelope missing ok key (unified exit bypass?): %s", out.String())
|
||||
}
|
||||
if !*env.OK {
|
||||
t.Fatalf("ok = false on success/pending path (I1 violated): %s", out.String())
|
||||
}
|
||||
if entry.write {
|
||||
if env.Outcome != "success" || !env.DryRun {
|
||||
t.Fatalf("write leaf dry-run outcome/dry_run = %q/%v, want success/true: %s",
|
||||
env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
} else {
|
||||
if env.Outcome != "success" || env.DryRun {
|
||||
t.Fatalf("read leaf outcome/dry_run = %q/%v, want success/false: %s",
|
||||
env.Outcome, env.DryRun, out.String())
|
||||
}
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("envelope data is nil: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// runDevDomainErrorCase 在生产口径的静默根下执行错误路径用例(队列
|
||||
// B128~B137)。生产根命令设 SilenceUsage/SilenceErrors=true
|
||||
// (internal/app/root.go),错误时 Cobra 不向 stdout 打 usage/error;测试根
|
||||
// 对齐同一口径,使「错误路径 stdout 零字节」断言与真实调用一致。
|
||||
func runDevDomainErrorCase(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
root := newDevAppTestRoot(runner)
|
||||
root.SilenceUsage = true
|
||||
root.SilenceErrors = true
|
||||
var out, errBuf bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errBuf)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return &out, &errBuf, err
|
||||
}
|
||||
|
||||
// requireDevValidationError 断言错误是结构化 apperrors validation(AC-03):
|
||||
// Category=validation + rc=3 + 消息可定位。
|
||||
func requireDevValidationError(t *testing.T, err error, wantMsg string) *apperrors.Error {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want structured validation error containing %q", wantMsg)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) {
|
||||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", err.Error(), wantMsg)
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
// TestDevAppGetMissingLocatorStructuredValidation 是队列 B128:dev app get
|
||||
// 无参数二选一报结构化 validation(rc=3),stdout 零字节。
|
||||
func TestDevAppGetMissingLocatorStructuredValidation(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, "dev", "app", "get")
|
||||
requireDevValidationError(t, err, "请传入 --unified-app-id 或 --app-key")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectUnknownChannelStructuredValidation 是队列 B129:dev connect
|
||||
// 未知渠道报结构化 validation(NewValidation,rc=3),stdout 零字节。
|
||||
func TestDevConnectUnknownChannelStructuredValidation(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "--channel", "bogus")
|
||||
requireDevValidationError(t, err, `未知渠道 "bogus"`)
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectMissingCredentialsNonInteractiveStructuredValidation 是队列
|
||||
// B130:非交互环境缺凭证不进引导流程,直接报结构化 validation(rc=3)。
|
||||
// stdin 交互判定经 seam 显式置 false,保证脚本/CI 环境断言确定性。
|
||||
func TestDevConnectMissingCredentialsNonInteractiveStructuredValidation(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
origInteractive := devAppConnectStdinInteractive
|
||||
t.Cleanup(func() { devAppConnectStdinInteractive = origInteractive })
|
||||
devAppConnectStdinInteractive = func() bool { return false }
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "--channel", "hermes")
|
||||
requireDevValidationError(t, err, "需要 --robot-client-id/--robot-client-secret")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectRestartMissingRecordStructuredValidation 是队列 B131 的结构化
|
||||
// 增量:restart 无连接器记录报结构化 validation(rc=3)。错误消息与 stdout 零
|
||||
// 字节断言已由轮 11-B0 B116(TestConnectDaemonFamilyMissingDaemonErrorPaths)
|
||||
// 覆盖,本测试补 AC-03 的 Category/rc 映射面。
|
||||
func TestDevConnectRestartMissingRecordStructuredValidation(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "restart", "--robot-client-id", "ghost")
|
||||
requireDevValidationError(t, err, "未找到连接器记录")
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevConnectStatusDaemonDirResolutionFailure 是队列 B133:status 的
|
||||
// daemon 目录解析失败(override 指向普通文件使 MkdirAll 失败)报结构化
|
||||
// internal 错误(rc=5),stdout 零字节。
|
||||
func TestDevConnectStatusDaemonDirResolutionFailure(t *testing.T) {
|
||||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("setup blocked file: %v", err)
|
||||
}
|
||||
connectDaemonDirOverride = blocked
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "connect", "status", "--robot-client-id", "ghost", "--json")
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want daemon dir resolution failure")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) {
|
||||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryInternal {
|
||||
t.Fatalf("category = %q, want internal", appErr.Category)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 5 {
|
||||
t.Fatalf("exit code = %d, want 5 (internal)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolve daemon dir") {
|
||||
t.Fatalf("message = %q, want contains resolve daemon dir", err.Error())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppCreateMissingNameErrorPath 是队列 B134:create 缺 --name 报错、
|
||||
// stdout 零字节。LeafSpec RequiredHint 由 corecmd 统一转成 typed validation,
|
||||
// 与手写参数校验共享 rc=3。
|
||||
func TestDevAppCreateMissingNameErrorPath(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "create", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--name 为必填") {
|
||||
t.Fatalf("error = %v, want --name 为必填", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppVersionPublishMissingParamsErrorPath 是队列 B135:version publish
|
||||
// 参数缺失报错、stdout 零字节。ValidateRequired 按声明顺序报第一个缺失的
|
||||
// 必填参数(先 unified-app-id 后 version-id);两者均为 typed validation rc=3。
|
||||
func TestDevAppVersionPublishMissingParamsErrorPath(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "version", "publish", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
|
||||
out2, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", "version", "publish", "--yes", "--unified-app-id", "u-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--version-id 为必填") {
|
||||
t.Fatalf("error = %v, want --version-id 为必填", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out2.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppDeleteDisableEnableMissingUnifiedAppID 是队列 B136:
|
||||
// delete/disable/enable 缺统一应用标识报错、stdout 零字节,均由统一 required
|
||||
// preflight 返回 typed validation rc=3。
|
||||
func TestDevAppDeleteDisableEnableMissingUnifiedAppID(t *testing.T) {
|
||||
for _, leaf := range []string{"delete", "disable", "enable"} {
|
||||
t.Run(leaf, func(t *testing.T) {
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||||
"dev", "app", leaf, "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevDomainErrorPathsKeepStdoutEmpty 是队列 B137 的总断言(AC-11):
|
||||
// dev 域全部错误路径 stdout 严格零字节。dev 域失败走 apperrors 通道(生产
|
||||
// root 错误处理器写 stderr),Phase F 不引入 failure 信封(轮 8 裁决⑪),
|
||||
// 故 AC-11 在测试层的投影即「stdout 零字节」。各用例的结构化/消息断言见
|
||||
// 上述分测试,本表只锁流纪律。
|
||||
func TestDevDomainErrorPathsKeepStdoutEmpty(t *testing.T) {
|
||||
daemonDirSetup := func(t *testing.T) {
|
||||
t.Helper()
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setup func(t *testing.T)
|
||||
}{
|
||||
{"app get missing locator", []string{"dev", "app", "get"}, nil},
|
||||
{"app update no field", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--dry-run"}, nil},
|
||||
{"app create missing name", []string{"dev", "app", "create", "--yes"}, nil},
|
||||
{"app delete missing id", []string{"dev", "app", "delete", "--yes"}, nil},
|
||||
{"app disable missing id", []string{"dev", "app", "disable", "--yes"}, nil},
|
||||
{"app enable missing id", []string{"dev", "app", "enable", "--yes"}, nil},
|
||||
{"version publish missing params", []string{"dev", "app", "version", "publish", "--yes"}, nil},
|
||||
{"connect unknown channel", []string{"dev", "connect", "--channel", "bogus"}, nil},
|
||||
{"connect status missing locator", []string{"dev", "connect", "status"}, nil},
|
||||
{"connect restart missing record", []string{"dev", "connect", "restart", "--robot-client-id", "ghost"}, daemonDirSetup},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.setup != nil {
|
||||
tc.setup(t)
|
||||
}
|
||||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, tc.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil\nstdout:\n%s", out.String())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("error path must keep stdout empty (AC-11), got %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
func TestDevFormerExclusionsHaveFinalContractAndUnifiedRollout(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
for _, tc := range []struct {
|
||||
path []string
|
||||
rollout output.RolloutState
|
||||
}{
|
||||
{[]string{"dev", "connect", "list"}, output.RolloutLegacyOnly},
|
||||
{[]string{"dev", "connect", "restart"}, output.RolloutUnifiedActive},
|
||||
{[]string{"dev", "app", "version", "check-approval"}, output.RolloutUnifiedActive},
|
||||
} {
|
||||
cmd, _, err := root.Find(tc.path)
|
||||
if err != nil || cmd == nil || !cmd.Runnable() {
|
||||
t.Fatalf("%v is not a runnable command: cmd=%v err=%v", tc.path, cmd, err)
|
||||
}
|
||||
if _, ok := contractfinal.RuntimeContractFinal(cmd); !ok {
|
||||
t.Fatalf("%s has no ContractFinal", cmd.CommandPath())
|
||||
}
|
||||
if got := output.CommandRollout(cmd); got != tc.rollout {
|
||||
t.Fatalf("%s rollout=%s, want %s", cmd.CommandPath(), got, tc.rollout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestDevLeafFormatFlagVisibility 是队列 B189 的 dev 域叶子 --format flag 可见性
|
||||
// 断言。--format 是生产根命令的持久 flag(internal/app/flags.go bindPersistentFlags),
|
||||
// 叶子自身不声明 local --format,而是经 cmd.InheritedFlags() 继承可见。本测试:
|
||||
//
|
||||
// 1. 在完整命令树下执行 dev app/dev connect 叶子,--format 必须被 resolveCommandFormat
|
||||
// 识别(table 生效),证明其可见;
|
||||
//
|
||||
// 2. 叶子 --help 必须展示 --format 于 Global Flags 段(继承可见性的人读证据)。
|
||||
func TestDevLeafFormatFlagVisibility(t *testing.T) {
|
||||
leaves := []struct {
|
||||
name string
|
||||
args []string
|
||||
helpPath []string
|
||||
}{
|
||||
{"dev-app-get", []string{"dev", "app", "get", "--unified-app-id", "u-1"}, []string{"dev", "app", "get"}},
|
||||
{"dev-app-list", []string{"dev", "app", "list", "--name", "DemoApp"}, []string{"dev", "app", "list"}},
|
||||
{"dev-version-list", []string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "version", "list"}},
|
||||
{"dev-event-list", []string{"dev", "app", "event", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "event", "list"}},
|
||||
{"dev-permission-list", []string{"dev", "app", "permission", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "permission", "list"}},
|
||||
{"dev-member-list", []string{"dev", "app", "member", "list", "--unified-app-id", "u-1"}, []string{"dev", "app", "member", "list"}},
|
||||
}
|
||||
|
||||
for _, lc := range leaves {
|
||||
t.Run(lc.name, func(t *testing.T) {
|
||||
// --format table 必须生效:输出为表(含列头/业务值),且不泄漏信封外壳键。
|
||||
args := append(append([]string{}, lc.args...), "--format", "table")
|
||||
root := newDevAppTestRoot(devAppFamilyContentRunner(map[string]any{"items": []any{map[string]any{"id": "one"}}}))
|
||||
out, errBuf, err := runRootBuffered(t, root, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok":`) {
|
||||
t.Fatalf("--format table not effective (envelope shell leaked):\n%s", s)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
t.Fatalf("--format table produced empty stdout (format not visible?):\n%s", s)
|
||||
}
|
||||
|
||||
// --help 必须展示 --format 于 Global Flags 段。
|
||||
helpArgs := append(append([]string{}, lc.helpPath...), "--help")
|
||||
helpRoot := newDevAppTestRoot(&captureRunner{})
|
||||
helpOut, _, herr := runRootBuffered(t, helpRoot, helpArgs...)
|
||||
if herr != nil {
|
||||
t.Fatalf("--help error = %v", herr)
|
||||
}
|
||||
if !strings.Contains(helpOut.String(), "--format") {
|
||||
t.Fatalf("leaf --help must expose --format (global inherited flag):\n%s", helpOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevLeafFormatFlagHelpShowsGlobalSection 是队列 B189 的补充观察断言:dev 叶子
|
||||
// 的 --help 中 --format 出现在 "Global Flags" 段而非本地 Flags 段(flag 可见性来源
|
||||
// 为根命令持久继承,而非叶子自声明)。这是 B189 结论的形式化——若某叶子把
|
||||
// --format 放回本地段,本测试会因 --format 出现在非 Global 段而暴露。
|
||||
func TestDevLeafFormatFlagHelpShowsGlobalSection(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
out, _, err := runRootBuffered(t, root, "dev", "app", "get", "--help")
|
||||
if err != nil {
|
||||
t.Fatalf("--help error = %v", err)
|
||||
}
|
||||
help := out.String()
|
||||
globalIdx := strings.Index(help, "Global Flags")
|
||||
if globalIdx < 0 {
|
||||
t.Fatalf("leaf help missing 'Global Flags' section:\n%s", help)
|
||||
}
|
||||
formatIdx := strings.LastIndex(help, "--format")
|
||||
if formatIdx < 0 {
|
||||
t.Fatalf("leaf help missing --format in Global Flags:\n%s", help)
|
||||
}
|
||||
if formatIdx < globalIdx {
|
||||
t.Fatalf("--format found before 'Global Flags' section (should be inherited global):\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = cobra.Command{}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// devAppFormatMatrixCases 是 B117 分发表驱动用例集:读叶子(成功路径)与
|
||||
// 写叶子(--dry-run pending 路径)各取一个代表,覆盖 json/table/pretty/
|
||||
// csv/ndjson/raw 六种 format。契约规范 §5.2:json = 完整信封(唯一 JSON
|
||||
// 契约);table/pretty/csv/ndjson/raw = 仅 data 通道(不输出信封外壳)。
|
||||
func TestDevAppFormatMatrixDispatching(t *testing.T) {
|
||||
readArgs := []string{"dev", "app", "get", "--unified-app-id", "u-1"}
|
||||
writeArgs := []string{"dev", "app", "update", "--unified-app-id", "u-1", "--name", "NewName", "--dry-run"}
|
||||
|
||||
readContent := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "DemoApp",
|
||||
"appStatus": "ENABLED",
|
||||
}
|
||||
writeContent := map[string]any{"unifiedAppId": "u-1", "name": "NewName"}
|
||||
|
||||
// 每个 format 的断言:'envelope' 表示 stdout 必须是完整信封(默认 json、
|
||||
// 未知降级);'data' 表示必须只渲染 data(无信封外壳键)。
|
||||
cases := []struct {
|
||||
name string
|
||||
format string
|
||||
wantKind string // "envelope" | "data"
|
||||
}{
|
||||
{"json", "json", "envelope"},
|
||||
{"table", "table", "data"},
|
||||
{"pretty", "pretty", "data"},
|
||||
{"csv", "csv", "data"},
|
||||
{"ndjson", "ndjson", "data"},
|
||||
{"raw", "raw", "data"},
|
||||
}
|
||||
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
args []string
|
||||
content map[string]any
|
||||
wantMarker string
|
||||
}{
|
||||
{"read", readArgs, readContent, "DemoApp"},
|
||||
{"write-dryrun", writeArgs, writeContent, "NewName"},
|
||||
} {
|
||||
for _, tc := range cases {
|
||||
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, mode.args...)
|
||||
args = append(args, "--format", tc.format)
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(mode.content), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if !strings.Contains(s, mode.wantMarker) {
|
||||
t.Fatalf("stdout missing business value %q:\n%s", mode.wantMarker, s)
|
||||
}
|
||||
switch tc.wantKind {
|
||||
case "envelope":
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("-f %s stdout is not a JSON envelope: %v\n%s", tc.format, err, s)
|
||||
}
|
||||
if env["ok"] != true {
|
||||
t.Fatalf("-f %s envelope ok = %#v, want true", tc.format, env["ok"])
|
||||
}
|
||||
case "data":
|
||||
// 非 json 只渲染 data:不得泄漏信封外壳键。
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`, `"dry_run"`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("-f %s leaked envelope wrapper key %s:\n%s", tc.format, banned, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 成功/dry-run 数据路径 stderr 应无噪声(除 dry-run 外的
|
||||
// 无 format warning 场景)。
|
||||
if strings.Contains(errBuf.String(), "[WARN]") {
|
||||
t.Fatalf("-f %s unexpected warning on stderr: %q", tc.format, errBuf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFormatMatrixUnknownDegradesToJSON 是 B117 的未知值降级断言:
|
||||
// 未知 --format 走 AC-09 降级为完整 JSON 信封 + stderr 单行 warning,数据不丢。
|
||||
// 与轮12 全链路降级回归(TestUnknownFormatFullChainDegradesOverDevAppCommand)
|
||||
// 同口径,此处作为分发表驱动矩阵的一行固化。
|
||||
func TestDevAppFormatMatrixUnknownDegradesToJSON(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "bogus")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
var env regressionEnvelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unknown format must degrade to JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if env.OK == nil || !*env.OK || env.Outcome != "success" {
|
||||
t.Fatalf("degraded envelope malformed: %s", out.String())
|
||||
}
|
||||
if env.Data["name"] != "DemoApp" {
|
||||
t.Fatalf("business payload lost in degradation: %s", out.String())
|
||||
}
|
||||
warning := errBuf.String()
|
||||
if !strings.Contains(warning, "[WARN]") || !strings.Contains(warning, "bogus") {
|
||||
t.Fatalf("stderr warning missing: %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppFormatMatrixRawPassThrough 是 B117 raw 语义断言:raw 直接透传
|
||||
// data 载荷(不输出信封外壳)。dev app 成功载荷为 map,raw 即其紧凑 JSON。
|
||||
func TestDevAppFormatMatrixRawPassThrough(t *testing.T) {
|
||||
out, errBuf, err := runDevAppFamily(t,
|
||||
devAppFamilyContentRunner(map[string]any{"unifiedAppId": "u-1", "name": "DemoApp"}),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1", "--format", "raw")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
if !strings.Contains(s, "DemoApp") {
|
||||
t.Fatalf("raw output missing business value:\n%s", s)
|
||||
}
|
||||
for _, banned := range []string{`"ok"`, `"outcome"`} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("raw leaked envelope wrapper key %s:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
_ = errBuf
|
||||
}
|
||||
|
||||
var _ output.Format = output.FormatJSON
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// TestDevAppEnvelopeJSONMatchesJSONUtilEscaping 是队列 B121 的信封 JSON 与
|
||||
// jsonutil 转义一致性断言:信封 JSON 经 jsonutil.MarshalIndent(HTML 转义
|
||||
// 关闭)序列化,& < > 与 URL 查询分隔符原样保留,不出现 \u003c/\u0026 等
|
||||
// HTML 转义序列。业务载荷含 URL 时不应被转义破坏。
|
||||
func TestDevAppEnvelopeJSONMatchesJSONUtilEscaping(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"homepageUrl": "https://example.com/redirect?dest=a&from=b&raw=<payload>",
|
||||
"name": "Demo<App>&Co",
|
||||
}
|
||||
out, errBuf, err := runDevAppFamily(t, devAppFamilyContentRunner(content),
|
||||
"dev", "app", "get", "--unified-app-id", "u-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
|
||||
}
|
||||
s := out.String()
|
||||
|
||||
// ① 载荷 URL/特殊字符原样保留(HTML 转义关闭)。
|
||||
for _, want := range []string{"https://example.com/redirect?dest=a&from=b&raw=<payload>", "Demo<App>&Co"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("envelope JSON must preserve raw %q (HTML escaping disabled):\n%s", want, s)
|
||||
}
|
||||
}
|
||||
// ② 不得出现 HTML 转义序列。
|
||||
for _, banned := range []string{"\\u003c", "\\u003e", "\\u0026", "\\u003d", "\\u002f"} {
|
||||
if strings.Contains(s, banned) {
|
||||
t.Fatalf("envelope JSON must not HTML-escape to %q:\n%s", banned, s)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 与 jsonutil.MarshalIndent 对同一数据载荷的序列化逐字一致(转义规则同源)。
|
||||
env := map[string]any{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": content,
|
||||
}
|
||||
wantJSON, err := jsonutil.MarshalIndent(env, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent: %v", err)
|
||||
}
|
||||
// 信封 Data 是 content 的强类型 map,序列化键序稳定:比对 data 片段即可。
|
||||
var gotData struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &gotData); err != nil {
|
||||
t.Fatalf("stdout not a JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
wantDataStruct := struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}{Data: content}
|
||||
wantDataJSON, err := jsonutil.MarshalIndent(wantDataStruct, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent(data): %v", err)
|
||||
}
|
||||
gotDataJSON, err := jsonutil.MarshalIndent(gotData, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("jsonutil.MarshalIndent(got): %v", err)
|
||||
}
|
||||
if string(gotDataJSON) != string(wantDataJSON) {
|
||||
t.Fatalf("envelope data serialization diverges from jsonutil:\nGOT:\n%s\nWANT:\n%s",
|
||||
gotDataJSON, wantDataJSON)
|
||||
}
|
||||
_ = wantJSON
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user