Files
dingtalk-workspace-cli/scripts/run_shortcut_real_write_matrix.py
Dennis 8687d68567 feat: gate unverified shortcuts for release
Hide shortcuts that did not pass real testing from public help/list discovery while keeping commands available for internal retest.

Record real shortcut inputs/outputs, backend issue summaries, next-release hidden list, and refresh skill guidance to mirror the lark-cli shortcut integration pattern.
2026-07-15 16:45:51 +08:00

456 lines
17 KiB
Python

#!/usr/bin/env python3
"""Run write/high-risk shortcuts against the real CLI with safe negative inputs.
This is intentionally not a mock and not a dry-run. It launches the built CLI
for every write/high-risk shortcut and records the real stdout/stderr/exit code.
To avoid broad irreversible side effects, target identifiers and people are
rewritten to obviously-nonexistent test values. Commands that have no
resource/user target and would necessarily mutate the current account state are
held and recorded rather than executed.
"""
from __future__ import annotations
import datetime as dt
import argparse
import json
import os
import shlex
import subprocess
import time
from pathlib import Path
from shortcut_real_result import (
classify_failure,
sanitize_result,
summarize_failure_categories,
classify_real_status,
summarize_results,
)
ROOT = Path(__file__).resolve().parents[1]
MATRIX_PATH = Path("/private/tmp/dws-shortcut-matrix.json")
OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json"
BIN = os.environ.get("DWS_REAL_TEST_BIN", "/private/tmp/dws-real-test")
NO_SAFE_NEGATIVE_TARGET = {
("chat", "+conversation-clear-all-red-point"):
"该命令没有目标 ID,会直接修改当前账号所有会话红点状态;需要逐项授权后单独跑。",
("chat", "+category-create"):
"该命令只需要标题,真实执行会在当前账号创建会话分类;需要逐项授权后单独跑。",
}
def ensure_matrix() -> dict:
if not MATRIX_PATH.exists():
env = os.environ.copy()
env.setdefault("GOCACHE", "/private/tmp/dws_gocache")
raw = subprocess.check_output(
["go", "run", "./scripts/gen_shortcut_test_matrix.go"],
cwd=ROOT,
env=env,
text=True,
)
MATRIX_PATH.write_text(raw, encoding="utf-8")
return json.loads(MATRIX_PATH.read_text(encoding="utf-8"))
def replace_flag_values(args: list[str], now: str, service: str = "", command: str = "") -> list[str]:
future_start = (
dt.datetime.now() + dt.timedelta(days=7)
).replace(hour=10, minute=0, second=0, microsecond=0).isoformat() + "+08:00"
future_end = (
dt.datetime.now() + dt.timedelta(days=7)
).replace(hour=10, minute=15, second=0, microsecond=0).isoformat() + "+08:00"
no_user = "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__"
no_id = "DWSREALTESTNOSUCHID0000000000000"
no_conv = "cidDWSREALTESTNOSUCHCONV"
huge_num = "999999999999"
text = f"DWS shortcut 真实测试 {now},安全负向输入,请忽略。"
replacements = {
# People / users.
"to": no_user,
"with": no_user,
"owner": no_user,
"target": no_user,
"user": no_user,
"users": no_user,
"user-ids": no_user,
"new-owner": no_user,
"applicant": no_user,
"inviter": no_user,
"approver-user-id": no_user,
"add-users": no_user,
"remove-users": "",
"add-extra-users": "",
"remove-extra-users": "",
"at-user-ids": no_user,
"at-users": no_user,
"at-open-dingtalk-ids": "",
"open-dingtalk-ids": "",
# Conversation / message / Ding targets.
"group": no_conv,
"id": no_id,
"conversation-id": no_conv,
"open-conversation-id": no_conv,
"src-conversation-id": no_conv,
"dest-conversation-id": no_conv,
"message-id": no_id,
"msg-id": no_id,
"msg-ids": no_id,
"src-msg-id": no_id,
"src-thread-id": no_id,
"keys": no_id,
"biz-id": no_id,
"category-ids": huge_num,
"receiver": no_user,
"robot-code": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__",
"bot-id": no_id,
"token": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_WEBHOOK_TOKEN__",
# DingTalk document / app / aitable / wiki ids.
"base-id": no_id,
"base": no_id,
"table-id": no_id,
"table": no_id,
"field-id": no_id,
"record-id": huge_num,
"record-ids": no_id,
"view-id": no_id,
"dashboard-id": no_id,
"chart-id": no_id,
"workflow-id": no_id,
"role-id": no_id,
"role-ids": no_id,
"section-id": no_id,
"parent-section-id": no_id,
"new-parent-section-id": no_id,
"node-id": no_id,
"node": no_id,
"doc": no_id,
"folder": no_id,
"workspace": no_id,
"template-id": no_id,
"comment-key": no_id,
"block-id": no_id,
"unified-app-id": no_id,
"icon-media-id": no_id,
"media-id": no_id,
"session-id": no_id,
"import-id": no_id,
"space": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__",
# Numeric identifiers.
"category-id": huge_num,
"record-id-num": huge_num,
"record-id-int": huge_num,
"class-id": huge_num,
"group-id": huge_num,
"plan-id": huge_num,
"result-id": huge_num,
# Text-like values.
"title": f"DWS shortcut 真实测试 {now}",
"name": f"DWS shortcut 真实测试 {now}",
"new-name": f"DWS shortcut 真实测试 {now}",
"alias-title": f"DWS shortcut 真实测试 {now}",
"nick": f"DWS测试{now}",
"desc": "DWS shortcut 真实测试描述,可删除",
"description": "DWS shortcut 真实测试描述,可删除",
"field-description": "DWS shortcut 真实测试字段说明",
"text": text,
"content": text,
"comment": text,
"note": text,
"remark": text,
"selected-text": "DWS测试",
"task": f"DWS shortcut 真实测试待办 {now}",
"keyword": f"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_{now}__",
"reason": "DWS shortcut 真实测试安全负向清理",
"uuid": f"dws-shortcut-real-negative-{now}",
# Times.
"due": future_end,
"at": future_start,
"start": future_start,
"end": future_end,
"time": future_start,
# Structured values.
"config": "{}",
"layout": "{}",
"json": "{}",
"records": "[]",
"ai-config": "{}",
"field-mapping": "{}",
"class-vo": '{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}',
"group-vo": '{"defaultClassId":999999999999,"workDayClassList":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}',
"visibility-rules": '[{"type":"dept","visible":["-1"]}]',
"sub-roles": "[]",
"skills": "[]",
"scope-values": "[]",
"event-codes": "[]",
"schedules": '[{"userId":"202397","workDate":"2026-07-16 09:00:00","classId":999999999999,"isRest":"N"}]',
"class-ids": "[999999999999]",
"add-depts": "",
"remove-depts": "",
# URLs / app config.
"homepage-url": "https://example.invalid/dws-shortcut-real-test",
"pc-homepage-url": "https://example.invalid/dws-shortcut-real-test",
"omp-url": "https://example.invalid/dws-shortcut-real-test",
"outgoing-url": "https://example.invalid/dws-shortcut-real-test",
"event-callback-url": "https://example.invalid/dws-shortcut-real-test",
"redirect-urls": "https://example.invalid/dws-shortcut-real-test",
"sso-urls": "https://example.invalid/dws-shortcut-real-test",
"ip-whitelist": "127.0.0.1",
"url": "https://example.invalid/dws-shortcut-real-test",
# Misc.
"type": "FIXED",
"record-name-key": "task",
"file-name": f"dws-shortcut-real-{now}.txt",
"mime-type": "text/plain",
"version": "0.0.1-dws-real-test",
"version-id": no_id,
"member-type": "USER",
"emotion-id": no_id,
"emotion-name": "DWS测试表情",
"background-id": no_id,
"emoji": "👍",
"pair": "测试=>测试",
"leave-code": no_id,
"num": "1",
}
if service == "chat" and command == "+messages-send-card":
replacements["receiver"] = "103262"
if service == "chat" and command == "+chat-audit-join":
replacements["applicant"] = "103262"
replacements["inviter"] = "519019"
if service == "doc" and command == "+comment-create-inline":
replacements["start"] = "0"
replacements["end"] = "1"
if service == "attendance" and command == "+save-leave-balance":
replacements["start"] = (dt.date.today() + dt.timedelta(days=1)).isoformat()
replacements["end"] = (dt.date.today() + dt.timedelta(days=30)).isoformat()
if service == "aitable" and command == "+record-update":
replacements["records"] = '[{"recordId":"recDWSREALTEST","cells":{}}]'
if service == "aitable" and command == "+view-update":
replacements["desc"] = "{}"
out = list(args)
if "--format" not in out:
out.extend(["--format", "json"])
i = 0
while i < len(out) - 1:
if out[i].startswith("--"):
key = out[i][2:]
if key in replacements and not out[i + 1].startswith("--"):
out[i + 1] = replacements[key]
i += 2
continue
i += 1
return out
def shell_join(cmd: list[str]) -> str:
return " ".join(shlex.quote(x) for x in cmd)
def drop_flag(args: list[str], flag: str) -> list[str]:
out: list[str] = []
i = 0
while i < len(args):
if args[i] == flag:
i += 2 if i + 1 < len(args) and not args[i + 1].startswith("--") else 1
continue
out.append(args[i])
i += 1
return out
def adjust_command_args(args: list[str], service: str, command: str) -> list[str]:
if service == "chat" and command == "+messages-send-card":
# --group and --receiver are mutually exclusive; exercise the direct-message path.
return drop_flag(args, "--group")
return args
def setup_prerequisite(service: str, command: str, now: str) -> dict | None:
if service == "todo" and command == "+todo-done":
title = f"DWS shortcut 真实测试待办 {now}"
due = (
dt.datetime.now() + dt.timedelta(days=7)
).replace(hour=10, minute=15, second=0, microsecond=0).isoformat() + "+08:00"
cmd = [
BIN,
"todo",
"+remind",
"--task",
title,
"--at",
due,
"--yes",
"--format",
"json",
]
started = time.time()
p = subprocess.run(cmd, text=True, capture_output=True, timeout=25)
return {
"purpose": "为 todo +todo-done 创建当前账号自己的临时待办 fixture",
"input": shell_join(cmd),
"stdout": p.stdout.strip(),
"stderr": p.stderr.strip(),
"exit_code": p.returncode,
"duration_ms": int((time.time() - started) * 1000),
}
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--service", action="append", help="Only run shortcuts from this service; may repeat")
parser.add_argument("--command", action="append", help="Only run this shortcut command; may repeat")
parser.add_argument("--missing-only", action="store_true", help="Skip shortcuts already present in the output report")
parser.add_argument("--failed-only", action="store_true", help="Only rerun shortcuts currently marked non-success in the output report")
ns = parser.parse_args()
services = set(ns.service or [])
commands = set(ns.command or [])
matrix = ensure_matrix()
rows = [r for r in matrix["results"] if r.get("risk") != "read"]
if services:
rows = [r for r in rows if r["service"] in services]
if commands:
rows = [r for r in rows if r["command"] in commands]
if ns.failed_only and OUT_PATH.exists():
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
failed = {
(r.get("service"), r.get("command"))
for r in existing.get("results", [])
if r.get("status") != "real-ok"
}
rows = [r for r in rows if (r["service"], r["command"]) in failed]
if ns.missing_only and OUT_PATH.exists():
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
done = {(r.get("service"), r.get("command")) for r in existing.get("results", [])}
rows = [r for r in rows if (r["service"], r["command"]) not in done]
now = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
results = []
batch_summary = {"total": len(rows), "ok": 0, "error": 0, "timeout": 0, "held": 0}
for idx, r in enumerate(rows, 1):
key = (r["service"], r["command"])
if key in NO_SAFE_NEGATIVE_TARGET:
batch_summary["held"] += 1
item = {
"service": r["service"],
"command": r["command"],
"risk": r["risk"],
"method": "held; no safe negative target",
"status": "held",
"input": "",
"args": [],
"stdout": "",
"stderr": NO_SAFE_NEGATIVE_TARGET[key],
"exit_code": None,
"duration_ms": 0,
}
item = sanitize_result(item)
category, fixability, note = classify_failure(item)
item["failure_category"] = category
item["fixability"] = fixability
item["diagnosis"] = note
results.append(item)
continue
setup = None
try:
setup = setup_prerequisite(r["service"], r["command"], now)
except subprocess.TimeoutExpired as e:
setup = {
"purpose": "创建前置真实资源超时",
"input": "",
"stdout": e.stdout.strip() if isinstance(e.stdout, str) else "",
"stderr": e.stderr.strip() if isinstance(e.stderr, str) else "",
"exit_code": None,
"duration_ms": 25000,
}
args = adjust_command_args(replace_flag_values(r["args"], now, r["service"], r["command"]), r["service"], r["command"])
cmd = [BIN] + args
started = time.time()
status = "real-error"
stdout = ""
stderr = ""
exit_code = None
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=25)
stdout = p.stdout.strip()
stderr = p.stderr.strip()
exit_code = p.returncode
status = classify_real_status(exit_code, stdout)
if status == "real-ok":
status = "real-ok"
batch_summary["ok"] += 1
else:
batch_summary["error"] += 1
except subprocess.TimeoutExpired as e:
status = "timeout"
batch_summary["timeout"] += 1
if isinstance(e.stdout, str):
stdout = e.stdout.strip()
if isinstance(e.stderr, str):
stderr = e.stderr.strip()
duration_ms = int((time.time() - started) * 1000)
item = {
"service": r["service"],
"command": r["command"],
"risk": r["risk"],
"method": "real-backend-safe-negative-write; no --mock; no --dry-run",
"status": status,
"input": shell_join(cmd),
"args": args,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_ms": duration_ms,
}
if setup is not None:
item["setup"] = setup
item = sanitize_result(item)
category, fixability, note = classify_failure(item)
if category != "passed":
item["failure_category"] = category
item["fixability"] = fixability
item["diagnosis"] = note
results.append(item)
if idx % 10 == 0 or idx == len(rows):
print(
f"progress {idx}/{len(rows)} ok={batch_summary['ok']} "
f"error={batch_summary['error']} timeout={batch_summary['timeout']} held={batch_summary['held']}",
flush=True,
)
if OUT_PATH.exists():
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
else:
existing = {"results": []}
replace_keys = {(r["service"], r["command"]) for r in results}
merged = [
r for r in existing.get("results", [])
if (r.get("service"), r.get("command")) not in replace_keys
]
merged.extend(results)
summary = summarize_results(merged, include_held=True)
failure_categories = summarize_failure_categories(merged)
OUT_PATH.write_text(json.dumps({
"generated_at": dt.datetime.now().isoformat(),
"summary": summary,
"failure_categories": failure_categories,
"results": merged,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"saved {OUT_PATH} batch={batch_summary} merged={summary}")
return 0
if __name__ == "__main__":
raise SystemExit(main())