diff --git a/docs/batch-auth-prd.json b/docs/batch-auth-prd.json new file mode 100644 index 00000000..01cb0d3a --- /dev/null +++ b/docs/batch-auth-prd.json @@ -0,0 +1,379 @@ +{ + "title": "DWS Batch Authorization and Login Recommend Flow", + "version": "2026-06-16", + "owners": { + "cli": "dingtalk-workspace-cli", + "gateway": "lippi-open-mcpserver-gw", + "patCore": "lippi-pat-core-wukong-capability", + "bff": "lippi-open-app-dev", + "frontend": "lippi-open-dev-front" + }, + "context": { + "problem": "DWS login --recommend and product-line batch authorization need a user-visible authorization page instead of silent bulk grants or local --yes-only blocking.", + "reference": { + "larkCli": { + "loginRecommend": "lark-cli auth login --recommend", + "domainAll": "lark-cli auth login --domain all", + "noWait": "lark-cli auth login --no-wait --json", + "deviceCode": "lark-cli auth login --device-code ", + "exclude": "lark-cli auth login --exclude ", + "scopeCatalog": "lark-cli auth scopes", + "scopeCheck": "lark-cli auth check --scope ", + "qrCode": "lark-cli auth qrcode " + }, + "ralphCli": { + "model": "PRD-driven long-running development with explicit task ledger, progress notes, verification gates, and failure analysis.", + "evidence": "ralph v0.16.0 help and /Users/xuan/knowledge/META-ralph-reference.md", + "mapping": "This PRD keeps a single SSOT for goals, contracts, tasks, security gates, acceptance criteria, and blocked evidence so implementation can continue across multiple repositories without redefining done." + }, + "existingDingTalkPage": "/src/app/personalAuthorization/index.jsx", + "targetPage": "Reuse the personalAuthorization route and render multiple scopes grouped by product." + }, + "accessNotes": [ + "The frontend GitLab repo was cloned through SSH to /Users/xuan/lippi-open-dev-front and implemented on branch codex/batch-auth-page-flow.", + "The DWS PAT BFF lives in /Users/xuan/lippi-open-app-dev and needs to be part of the chain because it owns /dingtalk-workspace-cli/oauth/device/action.", + "Acceptance is not complete after local build. It is complete only after prepub package/build succeeds for every changed repo and prepub functional verification passes.", + "The referenced DingTalk minutes URL could not be fetched because local dws is not logged in." + ] + }, + "goals": [ + "After dws auth login --recommend succeeds, DWS starts a PAT recommended batch plan and opens the authorization page.", + "For PAT batch authorization commands through dws pat chmod without --yes, including multi-scope, product-line, and recommend modes, DWS opens the same authorization page instead of returning a local validation error.", + "The server, not the CLI, selects grantable scopes. CLI must grant only selectedScopes from pat.batch_plan.", + "Recommended login must never auto-select destructive scopes such as delete, recall, remove, destroy, or drop.", + "High-risk scopes remain pending and must not be silently granted." + ], + "nonGoals": [ + "Do not trust CLI-provided orgId or uid.", + "Do not add a second authorization UI path.", + "Do not make high-risk destructive scopes part of default login recommend." + ], + "userFlows": [ + { + "name": "loginRecommend", + "steps": [ + "User runs dws auth login --recommend.", + "CLI completes OAuth loopback or device login and saves token.", + "CLI calls pat.batch_plan with recommend=true, grantType=permanent, caller=auth_login_recommend, dryRun=true.", + "PAT core loads the recommended set, filters destructive scopes server-side, and returns selectedScopes plus warnings/skipped scopes.", + "CLI calls pat.batch_grant with scopes=selectedScopes, startFlow=true, noWait=true, caller=auth_login_recommend.", + "PAT core creates a single batch Device Code Flow and returns PAT_BATCH_AUTH_PENDING with flowId/userCode/uri.", + "CLI opens uri, polls flow status, and retries the original grant after approval." + ] + }, + { + "name": "batchChmodWithoutYes", + "steps": [ + "User runs dws pat chmod --recommend --product without --yes.", + "CLI plans first, then starts a batch Device Code Flow instead of direct grant.", + "The authorization page displays all grantable selected scopes grouped by product.", + "Approve writes selected grants; reject leaves all grants unchanged." + ] + }, + { + "name": "batchChmodWithYes", + "steps": [ + "User runs the same command with --yes.", + "CLI continues the existing non-interactive grant path.", + "PAT core writes grantable low/medium-risk scopes directly and returns high-risk scopes as pending." + ] + } + ], + "larkCliParity": [ + { + "lark": "auth login --recommend", + "dws": "dws auth login --recommend", + "serverContract": "pat.batch_plan recommend=true caller=auth_login_recommend, then pat.batch_grant selectedScopes startFlow=true noWait=true", + "status": "implemented locally in CLI, gateway, PAT core, BFF, and frontend; prepub package and verification pending" + }, + { + "lark": "auth login --domain |all", + "dws": "dws pat chmod --product or --products <...>", + "serverContract": "pat.batch_plan productCodes=[...] resolves service-owned product scope templates", + "status": "implemented for PAT chmod product-line flow" + }, + { + "lark": "auth login --scope ", + "dws": "dws pat chmod ", + "serverContract": "one scope preserves direct grant behavior; multiple scopes enter batch plan/flow when --yes is absent", + "status": "implemented for multi-scope PAT chmod" + }, + { + "lark": "auth login --exclude ", + "dws": "batch authorization page selectedScopes", + "serverContract": "frontend sends selectedScopes; PAT core rejects any selected scope outside flow scopes and treats unchecked flow scopes as rejectedScopes", + "status": "implemented locally in PAT core, BFF selectedScopes passthrough, and frontend grouped selection; prepub verification pending" + }, + { + "lark": "auth login --no-wait --json and later --device-code", + "dws": "PAT_BATCH_AUTH_PENDING JSON with uri/authUrl/flowId/userCode plus polling retry", + "serverContract": "host-owned JSON mode returns structured pending and does not block; local interactive mode opens browser and polls", + "status": "implemented in CLI retry path and PAT pending response" + }, + { + "lark": "auth scopes and auth check", + "dws": "pat.batch_plan dryRun and existing PAT auth check result", + "serverContract": "plan exposes selected/skipped/pending/already-granted scope sets; check/auth retry carries missing scope and grant options", + "status": "partially aligned; no new public scope-catalog command added in this slice" + } + ], + "ralphExecutionPlan": { + "project": "dws-batch-authorization-page", + "tasks": [ + { + "id": "1", + "title": "Build PRD and command parity map", + "description": "Use product-design style requirements plus Ralph-style SSOT to map DWS batch authorization against lark-cli.", + "steps": [ + "Define user flows for login recommend, product-line grant, multi-scope grant, yes direct grant, and JSON no-wait mode.", + "Record frontend access and DingTalk minutes access blockers explicitly.", + "Keep acceptance criteria tied to command outputs and service contracts." + ], + "done": true + }, + { + "id": "2", + "title": "Implement DWS CLI batch authorization entry points", + "description": "dws auth login --recommend and dws pat chmod batch/product/recommend without --yes start the page-based flow.", + "steps": [ + "Add --recommend to auth login and run PAT recommended plan after login.", + "Route product-line, recommend, and multi-scope chmod without --yes to pat.batch_grant startFlow.", + "Keep --yes as the explicit direct-write path." + ], + "done": true + }, + { + "id": "3", + "title": "Harden gateway identity and caller allowlist", + "description": "The gateway must derive orgId/uid from trusted identity and allow intended DWS batch callers.", + "steps": [ + "Keep CLI-provided orgId/uid untrusted.", + "Allow dws-cli alongside openclaw for intended batch write callers.", + "Preserve caller/source checks for default grant writes." + ], + "done": true + }, + { + "id": "4", + "title": "Implement PAT core recommended filtering and batch DCF", + "description": "PAT core owns selectedScopes, destructive filtering, batch Device Code Flow, selected approval, and idempotent writes.", + "steps": [ + "Filter destructive recommended scopes server-side for auth_login_recommend.", + "Create/reuse one batch flow for selected scopes.", + "Approve only selectedScopes and reject out-of-flow selections.", + "Fail closed on critical storage failures and leave flow retryable." + ], + "done": true + }, + { + "id": "5", + "title": "Implement lippi-open-dev-front batch authorization UI", + "description": "Reuse /src/app/personalAuthorization/index.jsx and render grouped product permissions with selectable scopes.", + "steps": [ + "Load flow detail and detect multiple scopes.", + "Default-check all server-selected flow scopes.", + "Allow product and individual-scope deselection.", + "Submit selectedScopes on approve and no orgId/uid.", + "Handle PAT_BATCH_PARTIAL_FAILURE by keeping the page retryable." + ], + "done": true + }, + { + "id": "6", + "title": "Submit all repos to prepub and verify package plus behavior", + "description": "Acceptance only passes after prepub package/build succeeds and prepub functional verification is OK.", + "steps": [ + "Push or submit branches for CLI, gateway, PAT core, BFF, and frontend.", + "Trigger prepub package/build for every changed repo.", + "Verify prepub deployed versions include the current change set.", + "Run login recommend and batch chmod functional checks on prepub.", + "Open the prepub authorization page and verify layout and approve/reject callbacks." + ], + "done": false + } + ] + }, + "prepubReleaseGate": { + "overallStatus": "not_passed_until_prepub_package_and_functional_verification_ok", + "changedRepos": [ + { + "repo": "dingtalk-workspace-cli", + "localPath": "/Users/xuan/dingtalk-workspace-cli", + "branch": "codex/dws-auth-login-recommend", + "prepubPackage": "pending", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-mcpserver-gw", + "localPath": "/Users/xuan/lippi-open-mcpserver-gw", + "branch": "codex/pat-batch-identity-args-gw", + "prepubPackage": "pending", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-pat-core-wukong-capability", + "localPath": "/Users/xuan/lippi-pat-core-wukong-capability", + "branch": "codex/batch-auth-page-flow", + "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-app-dev", + "localPath": "/Users/xuan/lippi-open-app-dev", + "branch": "codex/batch-auth-bff-selected-scopes", + "prepubPackage": "blocked_locally_by_internal_maven_http_mirror_until_release_system_or_correct_settings_runs", + "prepubFunctionalVerification": "pending" + }, + { + "repo": "lippi-open-dev-front", + "localPath": "/Users/xuan/lippi-open-dev-front", + "branch": "codex/batch-auth-page-flow", + "prepubPackage": "local_npm_build_passed_prepub_package_pending", + "prepubFunctionalVerification": "pending" + } + ], + "requiredEvidence": [ + "Every changed repo has a pushed branch or prepub release submission tied to the current change set.", + "Prepub package/build succeeds for CLI, gateway, PAT core, BFF, and frontend.", + "Prepub deployed versions include the selectedScopes frontend-to-BFF-to-PAT core chain.", + "Prepub functional verification covers login recommend, chmod without --yes page flow, chmod with --yes direct flow, selectedScopes subset approval, reject, destructive-scope filtering, high-risk pending, and JSON no-wait pending response.", + "Prepub UI screenshot matches visualDesignTarget." + ] + }, + "serviceContract": { + "pat.batch_plan": { + "request": { + "scopes": "optional string[]", + "productCodes": "optional string[]", + "recommend": "boolean", + "grantType": "once|session|permanent", + "dryRun": "boolean", + "caller": "auth_login_recommend|batch_chmod_interactive|..." + }, + "response": { + "selectedScopes": "server-selected grantable scopes", + "skippedScopes": "not grantable or filtered scopes", + "pendingScopes": "high-risk scopes requiring separate confirmation", + "warnings": "destructive filter and partial selection warnings" + } + }, + "pat.batch_grant": { + "request": { + "scopes": "required string[], must be selectedScopes from plan", + "grantType": "once|session|permanent", + "startFlow": "boolean; true opens authorization page", + "noWait": "boolean; true returns URL immediately", + "clientRequestId": "idempotency key", + "caller": "auth_login_recommend|batch_chmod_interactive|..." + }, + "pendingResponse": { + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "data": { + "flowId": "batch device flow id", + "userCode": "device user code", + "uri": "opaque authorization URL", + "authUrl": "same URL for compatibility", + "pollIntervalSeconds": 3, + "expiresInSeconds": "PatSwitch.deviceCodeFlowTtlSeconds" + } + } + }, + "deviceFlowDetail": { + "route": "personalAuthorization", + "fields": [ + "agentCode", + "appName", + "appIcon", + "userName", + "userAvatar", + "status", + "batchFlow", + "defaultGrantType", + "scopes[]" + ], + "scopeFields": [ + "scope", + "displayName", + "productCode", + "productName", + "riskLevel", + "effectiveRiskLevel", + "riskDesc", + "operationSummary" + ] + }, + "handleFlowAction": { + "request": { + "flowId": "required string", + "userCode": "required string", + "action": "approve|reject", + "grantType": "optional once|session|permanent; batch page omits it so PAT core uses defaultGrantType from the flow", + "selectedScopes": "required for the new batch page when action=approve; must be a non-empty subset of flow.scopes" + }, + "compatibility": { + "selectedScopesNull": "legacy clients approve all flow-stored scopes", + "selectedScopesEmpty": "INVALID_PARAMETER", + "selectedScopesOutsideFlow": "INVALID_PARAMETER" + }, + "response": { + "status": "APPROVED|REJECTED", + "approvedScopes": "scopes written by this approve action", + "rejectedScopes": "flow scopes not approved by this action" + }, + "errorBehavior": { + "approveStorageFailure": "PAT_BATCH_PARTIAL_FAILURE; flow remains PENDING and the page can retry. Once tokens written by the current flow are cleaned by flowId ownership; permanent/session grants rely on transaction rollback and grant idempotency." + } + } + }, + "frontendRequirements": [ + "Reuse personalAuthorization page and API detail/action callbacks.", + "When scopes length is greater than one, render a grouped batch authorization surface.", + "Group by productCode/productName with product-level checkbox and expandable scope details.", + "Default checked state is all safe selected scopes.", + "Users can uncheck a product group or individual scopes; approving with zero selected scopes is disabled.", + "Approve must send selectedScopes matching the current checked scope list.", + "Reject rejects the whole flow.", + "Do not expose orgId or uid as trusted client input." + ], + "visualDesignTarget": { + "sourceImage": "/var/folders/hz/2sm0ql493cv6jngtjk7xsby00000gp/T/codex-clipboard-7792e4ca-7e4b-446b-8527-f35609d4df12.png", + "page": "A white authorization card centered on a light #f5f6fa page, with the footer links below the card.", + "card": "Single white card with subtle #f2f2f6 border, soft shadow, 12px radius, desktop visual width about 660px and enough height to show account, permissions, manage tip, and two bottom buttons without scrolling for four product groups.", + "header": "Top centered CLI app icon, title DingtalkWorkSpace, and subtitle 请求以下钉钉账号进行授权.", + "accountBlock": "Light gray rounded account row below the header, with a blue square DingTalk avatar/tag at left and the current account name next to it.", + "permissionBlock": "Light gray rounded permission panel titled 可授权的权限. Product rows use a blue checked checkbox, bold product name, gray count such as 12 项, gray description text, and a right chevron for expandable details.", + "selectionBehavior": "All recommended groups are checked by default. Product checkbox toggles the entire group; expanding a product reveals individual scope checkboxes.", + "manageTip": "Under the permission panel, show the existing 个人数据授权管理 link text for later review or revoke.", + "buttons": "Bottom two-button layout: left outlined white 拒绝 button, right primary blue 同意并授权 button, same height and rounded pill style.", + "mobile": "On narrow screens the same hierarchy must remain visible without text overlap; buttons share the available width." + }, + "securityRules": [ + "Gateway derives orgId and uid from trusted token/header only.", + "Gateway allowlist must include openclaw and dws-cli for batch grant callers that are intended to write.", + "PAT core applies destructive-scope hard filtering for recommend=true and caller=auth_login_recommend.", + "PAT core must use flow-stored scopes as the allowlist and reject any selectedScopes value outside the flow.", + "PAT core must only clear pending keys owned by the current flow and leave the flow pending if approve write fails after precheck, so the same authorization page can retry safely.", + "Critical approve/reject storage writes must fail closed with PAT_BATCH_PARTIAL_FAILURE instead of reporting success on partial persistence.", + "Batch flow approval must be idempotent; repeated approval or retry must not duplicate grants." + ], + "acceptanceCriteria": [ + "Prepub package/build succeeds for every changed repo: dingtalk-workspace-cli, lippi-open-mcpserver-gw, lippi-pat-core-wukong-capability, lippi-open-app-dev, and lippi-open-dev-front.", + "Prepub deployed versions include the current branch commits for CLI, gateway, PAT core, BFF, and frontend.", + "On prepub, dws auth login --recommend opens the batch authorization URL after successful login.", + "On prepub, dws pat chmod --recommend --product calendar without --yes opens the authorization URL.", + "On prepub, dws pat chmod --recommend --product calendar --yes preserves the existing direct grant behavior.", + "On prepub, a recommended config containing a delete/recall/remove/destroy/drop scope returns warnings/skipped scopes and never grants that scope.", + "On prepub, approving a batch flow grants only selectedScopes and rejects scopes outside the flow.", + "On prepub, rejecting a batch flow writes no new grants.", + "On prepub, JSON mode returns structured PAT_BATCH_AUTH_PENDING with uri/openBrowser fields and does not block.", + "On prepub, the authorization page visual result matches visualDesignTarget and the provided screenshot style." + ], + "reviewPlan": { + "implementationCommander": "Check end-to-end CLI, gateway, PAT core integration contracts.", + "aiNativeUiArchitect": "Requested by goal but no local skill was available; use Product Design and existing frontend constraints as fallback.", + "qinzePuaReviewer": "Review security, idempotency, tests, and failure modes.", + "muskChiefOperator": "Review the product flow for speed, minimum surface area, and user clarity.", + "computerUse": "Verify prepub/front-end page only after frontend repo and build credentials are available." + } +} diff --git a/docs/lippi-open-dev-front-batch-auth-handoff.md b/docs/lippi-open-dev-front-batch-auth-handoff.md new file mode 100644 index 00000000..94c4c96a --- /dev/null +++ b/docs/lippi-open-dev-front-batch-auth-handoff.md @@ -0,0 +1,398 @@ +# lippi-open-dev-front 批量授权页改造交接稿 + +日期: 2026-06-16 + +## 当前状态 + +- 目标仓库: `http://gitlab.alibaba-inc.com/dingding/lippi-open-dev-front.git` +- 目标页面: `/src/app/personalAuthorization/index.jsx` +- 已通过 SSH clone 到: `/Users/xuan/lippi-open-dev-front` +- 前端实现分支: `codex/batch-auth-page-flow` +- 已在真实页面落代码,并通过 `npm ci` 与 `npm run build`;build 只有既有 asset size warning。 +- 额外链路修复: `/Users/xuan/lippi-open-app-dev` 的 BFF 需要透传 `selectedScopes`,否则页面取消勾选会被 PAT core 兼容语义批准为全部 scopes。 + +## 链路目标 + +`dws auth login --recommend` 和所有 PAT 批量授权指令缺少 `--yes` 时,都进入同一条 Device Code Flow。 +这里的“批量授权指令”限定为 `dws pat chmod` 的多 scope、`--product/--products/--domain/--domains` 产品线授权、`--recommend` 推荐授权;业务层 `batch-delete`、`batch-send`、AI 表格批量更新等仍走各自的业务确认或敏感操作逻辑,不进入 PAT 授权页。 + +1. CLI 调 `pat.batch_plan`,只拿服务端选出的 `selectedScopes`。 +2. CLI 调 `pat.batch_grant`,带 `startFlow=true,noWait=true`。 +3. PAT core 返回 `PAT_BATCH_AUTH_PENDING`,包含 `flowId/userCode/uri/authUrl`。 +4. CLI 打开 `uri`,页面通过 `flowId/userCode` 拉取 flow 详情。 +5. 页面展示所有 flow 中保存的 scopes,点击同意后调用 approve。 +6. CLI 轮询到 `APPROVED` 后重试原授权动作。 + +这和 lark-cli 的 agent split-flow 保持同一类能力: lark-cli 有 `auth login --recommend`、`auth login --domain `、`auth login --scope `、`auth login --exclude `、`auth login --no-wait --json` 返回授权 URL/设备码,再用 `--device-code` 完成;DWS 本地 CLI 场景可直接打开页面并轮询,JSON/host-owned 场景仍输出结构化 pending。 + +Ralph-cli 参考点不是页面能力,而是工程编排方式: 用 PRD 作为 SSOT,显式记录任务账本、验收项、阻塞证据和失败分析。本次 PRD 也采用这种结构,避免跨 CLI/GW/PAT/frontend 多仓改造时把“已实现的服务端链路”和“待拿权限落地的前端页面”混在一起。 + +## 服务端契约 + +### `pat.batch_grant` pending 返回 + +```json +{ + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "message": "部分权限需要用户确认", + "data": { + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "uri": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", + "authUrl": "https://...#/personalAuthorization?flowId=flow-xxx&userCode=ABCD-EFGH", + "pollIntervalSeconds": 3, + "expiresInSeconds": 600 + } +} +``` + +### `queryFlowDetail` + +页面只可信任 `flowId/userCode`,不要从 URL 或客户端参数里拼 `orgId/uid`。BFF/HSF 继续由登录态派生 `uid/orgId`。 + +```json +{ + "agentCode": "qoderwork", + "appName": "DingtalkWorkSpace", + "appIcon": "https://...", + "userName": "小钉", + "userAvatar": "https://...", + "status": "PENDING", + "batchFlow": true, + "defaultGrantType": "permanent", + "scopes": [ + { + "scope": "calendar.event:read", + "displayName": "读取日程", + "productCode": "calendar", + "productName": "日历", + "riskLevel": 0, + "effectiveRiskLevel": 0, + "riskDesc": "低风险", + "operationSummary": "读取你的日历数据" + } + ] +} +``` + +### `handleFlowAction` + +approve 必须携带页面当前选中的 `selectedScopes`。旧前端不传该字段时,PAT core 兼容为批准 flow 中全部 scopes;新批量页必须显式传,保证 UI 勾选状态与授权真值一致。 + +```json +{ + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "action": "approve", + "selectedScopes": [ + "calendar.event:read", + "aitable.record:write" + ] +} +``` + +拒绝: + +```json +{ + "flowId": "flow-xxx", + "userCode": "ABCD-EFGH", + "action": "reject" +} +``` + +服务端规则: + +- `selectedScopes == null`: 兼容旧前端,批准 flow 中服务端持久化的全部 scopes。 +- `selectedScopes.isEmpty()`: 返回 `INVALID_PARAMETER`,前端应在 0 项时禁用“同意并授权”并引导用户拒绝。 +- 非空: 必须全部属于 flow scopes,否则返回 `INVALID_PARAMETER`。 +- approve/reject 完成后只清理当前 flow 拥有的 pending keys,避免误删同 uid/agent/scope 的其它 pending flow。 +- partial approve 返回 `approvedScopes` 与 `rejectedScopes`;未选 scope 会进入 `rejectedScopes`,表示被本次用户操作取消。 +- 如果 approve 写入阶段失败,PAT core 返回 `PAT_BATCH_PARTIAL_FAILURE` 并保持 flow 为 `PENDING`,前端展示错误并允许重试;once token 会按 flowId 归属清理,permanent/session grant 依赖事务与幂等逻辑吸收重试。 + +## 页面改造建议 + +### 1. 识别批量 flow + +在旧 `personalAuthorization/index.jsx` 查询详情后: + +```jsx +const scopes = Array.isArray(detail?.scopes) ? detail.scopes : []; +const isBatch = scopes.length > 1; +``` + +单 scope 保持旧页面。多 scope 进入批量布局。 + +### 2. scope 分组适配 + +```jsx +function groupScopes(scopes = []) { + const map = new Map(); + scopes.forEach((item) => { + const productCode = item.productCode || "unknown"; + const productName = item.productName || item.productCode || "其他权限"; + if (!map.has(productCode)) { + map.set(productCode, { + productCode, + productName, + summary: item.operationSummary || item.displayName || "", + scopes: [], + }); + } + map.get(productCode).scopes.push(item); + }); + return Array.from(map.values()); +} +``` + +### 3. 批量权限组件 + +默认全选,允许取消产品组或单个 scope。至少选中 1 项才允许点击“同意并授权”;如果用户不想授权任何项,点击“拒绝”。 + +```jsx +function BatchScopeList({ scopes, checkedScopes, onCheckedScopesChange }) { + const [expanded, setExpanded] = React.useState({}); + const groups = React.useMemo(() => groupScopes(scopes), [scopes]); + const checkedSet = React.useMemo(() => new Set(checkedScopes), [checkedScopes]); + + const toggleScope = (scope, checked) => { + const next = new Set(checkedSet); + if (checked) { + next.add(scope); + } else { + next.delete(scope); + } + onCheckedScopesChange(Array.from(next)); + }; + + const toggleGroup = (group, checked) => { + const next = new Set(checkedSet); + group.scopes.forEach((item) => { + if (checked) { + next.add(item.scope); + } else { + next.delete(item.scope); + } + }); + onCheckedScopesChange(Array.from(next)); + }; + + return ( +
+
可授权的权限
+ {groups.map((group) => { + const open = expanded[group.productCode]; + const groupScopes = group.scopes.map((item) => item.scope); + const checkedCount = groupScopes.filter((scope) => checkedSet.has(scope)).length; + const groupChecked = checkedCount === groupScopes.length; + const groupIndeterminate = checkedCount > 0 && checkedCount < groupScopes.length; + return ( +
+
+ { + if (node) { + node.indeterminate = groupIndeterminate; + } + }} + onChange={(event) => toggleGroup(group, event.target.checked)} + aria-label={`${group.productName} ${checkedCount}/${groupScopes.length} 已选择`} + /> + +
+
+ {group.summary || group.scopes.map((scope) => scope.displayName || scope.scope).slice(0, 2).join("、")} +
+ {open && ( +
+ {group.scopes.map((scope) => ( +
+ toggleScope(scope.scope, event.target.checked)} + aria-label={`${scope.displayName || scope.scope} 已选择`} + /> +
+
{scope.displayName || scope.scope}
+
{scope.scope}
+ {scope.riskDesc &&
{scope.riskDesc}
} +
+
+ ))} +
+ )} +
+ ); + })} +
+ ); +} +``` + +如果目标项目有 Checkbox/Icon/Button 组件,应替换原生 input 和字符箭头,保持旧授权页和钉钉 Design Token 的视觉一致性,不要引入新视觉体系。 + +### 4. approve/reject 调用 + +复用旧页面已有的 action API,只改 payload: + +```jsx +const [checkedScopes, setCheckedScopes] = React.useState([]); + +React.useEffect(() => { + setCheckedScopes(scopes.map((item) => item.scope).filter(Boolean)); +}, [scopes]); + +async function approve() { + if (checkedScopes.length === 0) { + return; + } + await handleFlowAction({ + flowId, + userCode, + action: "approve", + selectedScopes: checkedScopes, + // grantType 可不传,PAT core 会使用 flow 创建时持久化的 defaultGrantType。 + ...(grantType ? { grantType } : {}), + }); + setStatus("APPROVED"); +} + +async function reject() { + await handleFlowAction({ flowId, userCode, action: "reject" }); + setStatus("REJECTED"); +} +``` + +不要把 `orgId/uid/agentCode` 作为可信入参传给 action。`selectedScopes` 只是用户选择结果,服务端必须校验它是 flow scopes 的子集。 + +### 5. 文案与状态 + +- 标题: `{appName || "DingtalkWorkSpace"}` +- 副标题: `请求以下钉钉账号进行授权` +- 列表标题: `可授权的权限` +- 主按钮: `同意并授权` +- 次按钮: `拒绝` +- 0 项选中: 主按钮禁用,辅助文案“请选择至少 1 项权限,或拒绝本次授权”。 +- 空 scope: 展示“没有需要授权的权限”,主按钮禁用。 +- `APPROVED`: 标题“授权已完成”,说明“可回到命令行继续”,禁用操作按钮。 +- `REJECTED`: 标题“已拒绝授权”,说明“命令行将收到拒绝结果”,禁用操作按钮。 +- `EXPIRED`: 标题“授权已过期”,说明“请回到命令行重新发起授权”,禁用操作按钮。 +- `ERROR`: 展示错误说明,保留“重试加载”按钮;不要重复提交 approve。 + +### 6. 样式方向 + +保持截图中的居中白色授权卡片、8px 左右圆角、浅灰权限面板、DingTalk 蓝色主按钮。不要做营销页或额外 hero。页面首屏应直接是授权确认。 + +AI-native / 钉钉授权页约束: + +- 首屏元素控制在 7 个以内: 应用图标、应用名、账号行、权限面板、辅助说明、拒绝按钮、同意按钮。 +- 权限面板内部滚动,卡片最大高度建议 `min(720px, calc(100vh - 160px))`。 +- 多产品默认只展示产品组摘要,明细折叠;展开不推动底部按钮离屏。 +- 底部操作固定在卡片底部,移动端 375px 宽度下仍可直接拒绝/同意。 +- 终态页不再展示可编辑 checkbox,避免用户误以为还能修改。 + +建议 CSS 类: + +```css +.pa-scope-panel { + margin-top: 12px; + padding: 12px 16px; + background: #f7f8fa; + border-radius: 8px; + max-height: min(420px, calc(100vh - 360px)); + overflow-y: auto; +} + +.pa-product-row { + padding: 12px 8px; +} + +.pa-product-main { + display: flex; + align-items: center; + gap: 12px; +} + +.pa-product-button { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + border: 0; + background: transparent; + padding: 0; + text-align: left; + cursor: pointer; +} + +.pa-product-name { + font-weight: 600; + color: #1f2329; +} + +.pa-product-count { + margin-left: 8px; + color: #8f959e; +} + +.pa-product-arrow { + margin-left: auto; + color: #8f959e; +} + +.pa-product-desc, +.pa-scope-code, +.pa-scope-risk { + color: #8f959e; + font-size: 13px; + line-height: 20px; +} + +.pa-scope-detail-list { + margin: 10px 0 0 32px; + padding: 8px 12px; + border-radius: 6px; + background: #fff; +} + +.pa-scope-detail + .pa-scope-detail { + border-top: 1px solid #eff0f2; +} + +.pa-scope-detail { + display: flex; + gap: 10px; + padding: 8px 0; +} + +.pa-scope-copy { + min-width: 0; +} +``` + +### 7. 验收用例 + +1. 单 scope flow 仍渲染旧授权页。 +2. 多 scope flow 按 `productCode/productName` 分组,默认全部勾选展示。 +3. 点击产品组 checkbox 可整组选择/取消;点击单 scope checkbox 可单项选择/取消。 +4. 点击产品行可展开/收起 scope 明细。 +5. 选中数为 0 时“同意并授权”禁用。 +6. 点击“同意并授权”发送 `flowId/userCode/action/selectedScopes`,不发送 `orgId/uid`;批量授权时长由 flow detail 的 `defaultGrantType` 展示并由 PAT core 兜底使用。 +7. 服务端拒绝 flow 外 scope,前端展示错误且不进入成功终态。 +8. 点击“拒绝”只发送 reject action。 +9. `APPROVED/REJECTED/EXPIRED/ERROR` 终态不可重复提交。 +10. 移动端 375px 宽度下产品名、数量、描述不溢出,底部按钮不离屏。 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 99dd26b5..a5d8075b 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -30,18 +30,20 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) type authLoginConfig struct { - Token string - Force bool - Device bool + Token string + Force bool + Device bool + Recommend bool } -func buildAuthCommand() *cobra.Command { +func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "认证管理", @@ -55,7 +57,7 @@ func buildAuthCommand() *cobra.Command { } if !edition.Get().HideAuthLogin { - cmd.AddCommand(newAuthLoginCommand()) + cmd.AddCommand(newAuthLoginCommand(patCaller)) } cmd.AddCommand( newAuthLogoutCommand(), @@ -68,7 +70,7 @@ func buildAuthCommand() *cobra.Command { return cmd } -func newAuthLoginCommand() *cobra.Command { +func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "登录钉钉(自动刷新 token,必要时扫码)", @@ -91,7 +93,8 @@ func newAuthLoginCommand() *cobra.Command { dws auth login # 本机扫码登录 (loopback 流) dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 强制重新登录 (忽略缓存 token) - dws auth login --token xxx # 使用指定 token`, + dws auth login --token xxx # 使用指定 token + dws auth login --recommend # 登录成功后打开推荐权限批量授权页`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := resolveAuthLoginConfig(cmd) @@ -137,10 +140,32 @@ func newAuthLoginCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() + format, _ := cmd.Root().PersistentFlags().GetString("format") + runRecommend := func() error { + if !cfg.Recommend { + return nil + } + run := func(ctx context.Context) error { + return pat.RunLoginRecommendAuthorization(ctx, patCaller, cmd.ErrOrStderr()) + } + err := run(cmd.Context()) + if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { + return runDirectPATAuthCheck( + cmd.Context(), + &GlobalFlags{Format: format}, + patErr, + run, + cmd.ErrOrStderr(), + ) + } + return err + } // Check if JSON output is requested - format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { + if err := runRecommend(); err != nil { + return err + } return writeAuthLoginJSON(w, tokenData, cfg.Force) } @@ -166,12 +191,13 @@ func newAuthLoginCommand() *cobra.Command { } } fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n") - return nil + return runRecommend() }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)") + cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") @@ -572,10 +598,15 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") } + recommend, err := cmd.Flags().GetBool("recommend") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") + } return authLoginConfig{ - Token: strings.TrimSpace(token), - Force: force, - Device: device, + Token: strings.TrimSpace(token), + Force: force, + Device: device, + Recommend: recommend, }, nil } diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index e894c74f..278d29ac 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -32,6 +32,7 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/fatih/color" ) @@ -229,7 +230,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { } data["openBrowser"] = openBrowser - encoded, err := json.Marshal(payload) + encoded, err := jsonutil.Marshal(payload) if err != nil { return raw } @@ -349,6 +350,12 @@ type patRetryingKeyType struct{} var patRetryingKey = patRetryingKeyType{} +type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error) + +func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + return f(ctx, invocation) +} + // IsPatRetrying returns true if the current context is already in a PAT retry. func IsPatRetrying(ctx context.Context) bool { v, _ := ctx.Value(patRetryingKey).(bool) @@ -381,6 +388,41 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) { fmt.Fprintf(output, " %s\n", trimmed) } +func runDirectPATAuthCheck( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + retry func(context.Context) error, + output io.Writer, +) error { + if retry == nil { + return patErr + } + runner := &runtimeRunner{ + globalFlags: globalFlags, + fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) { + if err := retry(retryCtx); err != nil { + return executor.Result{}, err + } + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "ok": true, + }, + }, nil + }), + } + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + Kind: "direct_pat_authorization", + Stage: "auth_login_recommend", + CanonicalProduct: defaultPATProductID, + Tool: "pat.batch_grant", + CanonicalPath: "pat.batch_grant", + }, patErr, defaultConfigDir(), output) + return err +} + // handlePatAuthCheck is called by runner.executeInvocation when a PAT // authorization error is detected. It injects the server-assigned clientId // as x-robot-uid header, prints authorization details, opens the browser, @@ -401,6 +443,7 @@ func handlePatAuthCheck( Desc string `json:"desc"` FlowID string `json:"flowId"` URI string `json:"uri"` + AuthURL string `json:"authUrl"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` } `json:"data"` @@ -408,6 +451,9 @@ func handlePatAuthCheck( if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { return executor.Result{}, patErr } + if patData.Data.URI == "" { + patData.Data.URI = patData.Data.AuthURL + } slog.Debug("PAT auth check", "clientId", patData.Data.ClientID, @@ -595,7 +641,7 @@ func enrichPATErrorForHostControl(raw string) string { apperrors.ApplyHostMutations(payload) // stderr JSON MUST be single-line. - encoded, err := json.Marshal(payload) + encoded, err := jsonutil.Marshal(payload) if err != nil { return raw } @@ -629,7 +675,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string "data": data, } // stderr JSON MUST be single-line. - b, err := json.Marshal(payload) + b, err := jsonutil.Marshal(payload) if err != nil { return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}` } diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 5e978083..da3225c8 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -633,6 +633,71 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) { } } +func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, _ := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-direct", "test-client-id")} + var retried atomic.Bool + var retryHadKey atomic.Bool + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error { + retried.Store(true) + retryHadKey.Store(IsPatRetrying(ctx)) + return nil + }, &bytes.Buffer{}) + if err != nil { + t.Fatalf("runDirectPATAuthCheck error = %v", err) + } + if !retried.Load() { + t.Fatal("expected direct PAT auth retry callback to run") + } + if !retryHadKey.Load() { + t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying") + } +} + +func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH" + raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}` + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"}, + &apperrors.PATError{RawJSON: raw}, + func(ctx context.Context) error { + t.Fatal("retry callback should not run in structured PAT output mode") + return nil + }, + &bytes.Buffer{}, + ) + if err == nil { + t.Fatal("expected structured PATError") + } + patOut, ok := err.(*apperrors.PATError) + if !ok { + t.Fatalf("expected *PATError, got %T: %v", err, err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { + t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) + } + if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" { + t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got) + } + data, _ := payload["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if got, ok := data["openBrowser"].(bool); !ok || got { + t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) + } +} + func TestHandlePatAuthCheck_Rejected(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") server, configDir := setupHandlePATServer(t, "REJECTED", "") @@ -928,6 +993,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T if !ok { t.Fatalf("expected *PATError, got %T: %v", err, err) } + if strings.Contains(patOut.RawJSON, `\u0026`) { + t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON) + } + if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") { + t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON) + } var payload map[string]any if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) diff --git a/internal/app/root.go b/internal/app/root.go index 86726148..bb4de9f8 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -323,9 +323,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) genSkillsCmd.Hidden = true mcpCmd := newMCPCommand(rootCtx, loader, runner, engine) mcpCmd.Hidden = true + patCaller := newToolCallerAdapter(runner, flags) utilityCommands := []*cobra.Command{ - newAuthCommand(), + newAuthCommand(patCaller), newAPICommand(flags), newSkillCommand(), newCacheCommand(), @@ -354,7 +355,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } // PAT authorization commands (open-source core) - patCaller := newToolCallerAdapter(runner, flags) pat.RegisterCommands(root, patCaller) if fn := edition.Get().RegisterExtraCommands; fn != nil { @@ -372,8 +372,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) return root } -func newAuthCommand() *cobra.Command { - return buildAuthCommand() +func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command { + return buildAuthCommand(patCaller) } func newSkillCommand() *cobra.Command { diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 3617e84f..05b385c0 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -15,10 +15,10 @@ package app import ( "context" - "encoding/json" "log/slog" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult { contentRaw, ok := resp["content"] if !ok { // Dry-run or echo mode: serialize the whole response as text. - data, _ := json.Marshal(resp) + data, _ := jsonutil.Marshal(resp) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } @@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult { } return &edition.ToolResult{Content: blocks} case map[string]any: - data, _ := json.Marshal(v) + data, _ := jsonutil.Marshal(v) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } default: - data, _ := json.Marshal(contentRaw) + data, _ := jsonutil.Marshal(contentRaw) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6f0be969..1c8c2240 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -21,6 +21,7 @@ import ( "io" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -309,7 +310,7 @@ func PrintJSON(w io.Writer, err error) error { } payload := map[string]any{"error": errorPayload} - data, marshalErr := json.MarshalIndent(payload, "", " ") + data, marshalErr := jsonutil.MarshalIndent(payload, "", " ") if marshalErr != nil { _, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n") return writeErr diff --git a/internal/errors/pat.go b/internal/errors/pat.go index 934ef06c..febc6e4d 100644 --- a/internal/errors/pat.go +++ b/internal/errors/pat.go @@ -20,6 +20,8 @@ import ( "net/url" "strings" "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" ) // hostControlProvider returns the host-owned clawType for the current @@ -146,6 +148,7 @@ var patNoPermissionCodes = map[string]bool{ // `dws auth login --scope `. var patAuthRequiredCodes = map[string]bool{ "AGENT_CODE_NOT_EXISTS": true, + "PAT_BATCH_AUTH_PENDING": true, "PAT_SCOPE_AUTH_REQUIRED": true, } @@ -240,7 +243,7 @@ func isBusinessError(body map[string]any) bool { // Check order: DWS gateway auth > PAT permission. func ClassifyToolResultContent(content map[string]any) error { if _, ok := getDWSGatewayErrorCode(content); ok { - raw, _ := json.Marshal(content) + raw, _ := jsonutil.Marshal(content) return NewAuth(string(raw), WithReason("gateway_auth_expired"), WithHint(authExpiredHint()), @@ -462,7 +465,7 @@ func cleanPATJSON(body map[string]any, code string) string { // stderr JSON MUST be a single-line, directly json.Unmarshal-able // payload — pretty-printing would break naïve host parsers that read // stderr line-by-line and fail on leading whitespace. - b, err := json.Marshal(out) + b, err := jsonutil.Marshal(out) if err != nil { return fmt.Sprintf(`{"success":false,"code":"%s"}`, code) } diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go index cce90bdd..46247905 100644 --- a/internal/errors/pat_test.go +++ b/internal/errors/pat_test.go @@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) { } } +func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") { + t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON) + } + if !strings.Contains(patErr.RawJSON, "flow-1") { + t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON) + } +} + func TestClassifyMCPResponseText_BusinessError(t *testing.T) { t.Parallel() text := `{"success":false,"errorMsg":"搜索内容不能为空"}` @@ -729,6 +748,12 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result) + } + if !strings.Contains(result, "&userCode=Q8RY-X6E9") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { @@ -810,6 +835,12 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result) + } + if !strings.Contains(result, "&userCode=CFTC-27ZN") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { diff --git a/internal/jsonutil/json.go b/internal/jsonutil/json.go new file mode 100644 index 00000000..0dbecb0f --- /dev/null +++ b/internal/jsonutil/json.go @@ -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 jsonutil + +import ( + "bytes" + "encoding/json" +) + +// Marshal matches json.Marshal but keeps &, <, and > unchanged. CLI JSON is +// consumed by shells and host processes, not embedded into HTML, so preserving +// URL query separators makes the output easier to copy and parse. +func Marshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// MarshalIndent matches json.MarshalIndent with HTML escaping disabled. +func MarshalIndent(v any, prefix, indent string) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/output/filter.go b/internal/output/filter.go index 15160a05..7fc40533 100644 --- a/internal/output/filter.go +++ b/internal/output/filter.go @@ -20,6 +20,7 @@ import ( "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/itchyny/gojq" ) @@ -171,7 +172,7 @@ func ApplyJQ(w io.Writer, payload any, expr string) error { } first = false - data, marshalErr := json.MarshalIndent(value, "", " ") + data, marshalErr := jsonutil.MarshalIndent(value, "", " ") if marshalErr != nil { return apperrors.NewInternal("failed to encode --jq result") } diff --git a/internal/output/filter_test.go b/internal/output/filter_test.go index ca2f6f75..65532db2 100644 --- a/internal/output/filter_test.go +++ b/internal/output/filter_test.go @@ -219,6 +219,20 @@ func TestApplyJQIdentity(t *testing.T) { } } +func TestApplyJQDoesNotEscapeAmpersands(t *testing.T) { + payload := map[string]any{"url": "https://example.test/auth?a=1&b=2"} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".url"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("ApplyJQ escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("ApplyJQ output missing literal ampersand: %s", buf.String()) + } +} + func TestApplyJQFieldAccess(t *testing.T) { payload := map[string]any{"name": "Alice", "age": float64(30)} var buf bytes.Buffer diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 27072978..417af44d 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -22,6 +22,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/jsonutil" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -257,7 +258,7 @@ func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet { // WriteJSON marshals payload as indented JSON and writes it to w. func WriteJSON(w io.Writer, payload any) error { - data, err := json.MarshalIndent(payload, "", " ") + data, err := jsonutil.MarshalIndent(payload, "", " ") if err != nil { return apperrors.NewInternal("failed to encode command output as JSON") } @@ -270,7 +271,7 @@ func writeRaw(w io.Writer, payload any) error { _, err := fmt.Fprintln(w, SanitizeForTerminal(text)) return err } - data, err := json.Marshal(payload) + data, err := jsonutil.Marshal(payload) if err != nil { return apperrors.NewInternal("failed to encode raw command output") } @@ -544,7 +545,7 @@ func formatValue(value any) string { case string: return SanitizeForTerminal(typed) default: - data, err := json.Marshal(typed) + data, err := jsonutil.Marshal(typed) if err != nil { return SanitizeForTerminal(fmt.Sprintf("%v", typed)) } diff --git a/internal/output/formatter_extra_test.go b/internal/output/formatter_extra_test.go index 93776677..8e111e99 100644 --- a/internal/output/formatter_extra_test.go +++ b/internal/output/formatter_extra_test.go @@ -22,6 +22,21 @@ func TestWriteJSON_Map(t *testing.T) { } } +func TestWriteJSON_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + url := "https://open-dev.dingtalk.com/fe/old#/personalAuthorization?flowId=flow&userCode=CODE" + if err := WriteJSON(&buf, map[string]any{"authorizationUrl": url}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("WriteJSON escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "&userCode=CODE") { + t.Fatalf("WriteJSON output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteJSON_Nil(t *testing.T) { t.Parallel() var buf bytes.Buffer @@ -150,6 +165,20 @@ func TestWriteRaw_NonString(t *testing.T) { } } +func TestWriteRaw_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeRaw(&buf, map[string]any{"url": "https://example.test/auth?a=1&b=2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("writeRaw escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("writeRaw output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteTableish_Map(t *testing.T) { t.Parallel() var buf bytes.Buffer diff --git a/internal/output/ndjson.go b/internal/output/ndjson.go index d86eb445..909f0089 100644 --- a/internal/output/ndjson.go +++ b/internal/output/ndjson.go @@ -41,6 +41,7 @@ func writeNDJSON(w io.Writer, payload any) error { bw := bufio.NewWriter(w) enc := json.NewEncoder(bw) + enc.SetEscapeHTML(false) // json.Encoder.Encode already appends a trailing newline per call. switch v := normalized.(type) { diff --git a/internal/output/ndjson_csv_test.go b/internal/output/ndjson_csv_test.go index a44bd138..b2aefd71 100644 --- a/internal/output/ndjson_csv_test.go +++ b/internal/output/ndjson_csv_test.go @@ -49,6 +49,11 @@ func TestWriteNDJSON(t *testing.T) { payload: map[string]any{"ok": true}, wantLines: []string{`{"ok":true}`}, }, + { + name: "url keeps ampersand", + payload: map[string]any{"url": "https://example.test/auth?a=1&b=2"}, + wantLines: []string{`{"url":"https://example.test/auth?a=1&b=2"}`}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index e8e42e33..b74d2835 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -18,9 +18,11 @@ import ( "encoding/json" stderrors "errors" "fmt" + "io" "os" "regexp" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -170,10 +172,13 @@ const ( // expose only the legacy Chinese display name. patGrantToolNameLegacyAlias = "个人授权" - patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" - patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" - patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" - patForgedIdentityCodeLower = "pat_forged_identity_field" + patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" + patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" + grantTypePermanent = "permanent" + patCallerAuthLoginRecommend = "auth_login_recommend" + patCallerBatchChmodInteractive = "batch_chmod_interactive" ) var patBatchMetadataContractCodes = map[string]bool{ @@ -224,7 +229,9 @@ grantType 规则: 批量授权: 产品线批量授权推荐使用 --recommend --product 。 --products / --domain / --domains 保持兼容;裸 --recommend 保持可用, - 但会按推荐集合规划,可能跨产品。`, + 但会按推荐集合规划,可能跨产品。 + 批量授权未传 --yes 时会打开授权确认页;传 --yes 时才直接写入服务端选中的权限。 + 批量授权未显式指定 --grant-type 且没有 session-id 时,会以 permanent 作为本次页面确认后的授权时长。`, Args: func(cmd *cobra.Command, args []string) error { productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) if len(args) > 0 || recommend || len(productCodes) > 0 { @@ -246,12 +253,16 @@ grantType 规则: } scopes := args productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag) - usesPlan := recommend || len(productCodes) > 0 + usesPlan := recommend || len(productCodes) > 0 || len(scopes) > 1 grantType, _ := cmd.Flags().GetString("grant-type") sessionID, _ := cmd.Flags().GetString("session-id") if sessionID == "" { sessionID = resolveSessionIDFromEnv() } + interactiveBatchFlow := usesPlan && !batchAuthorizationConfirmed(cmd) + if interactiveBatchFlow && grantType == "session" && sessionID == "" && !cmd.Flags().Changed("grant-type") { + grantType = grantTypePermanent + } if !validGrantTypes[grantType] { return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType) @@ -291,20 +302,11 @@ grantType 规则: return fmt.Errorf("internal error: tool runtime not initialized") } - if usesPlan && !batchAuthorizationConfirmed(cmd) { - return apperrors.NewValidation( - "batch PAT authorization requires explicit --yes before granting", - apperrors.WithReason("batch_auth_requires_yes"), - apperrors.WithHint("rerun with --dry-run to preview scopes, then add --yes to grant"), - apperrors.WithActions( - "dws pat chmod --recommend --product --grant-type --dry-run", - "dws pat chmod --recommend --product --grant-type --yes", - ), - ) - } - if usesPlan { planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true) + if interactiveBatchFlow { + planArgs["caller"] = patCallerBatchChmodInteractive + } planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs) if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) @@ -336,6 +338,12 @@ grantType 规则: batchArgs["sessionId"] = sessionID toolArgs["sessionId"] = sessionID } + if interactiveBatchFlow { + batchArgs["startFlow"] = true + batchArgs["noWait"] = true + batchArgs["caller"] = patCallerBatchChmodInteractive + batchArgs["clientRequestId"] = newBatchClientRequestID("chmod") + } // Legacy server schema accepted singular "scope"; clone the // canonical argv and rename the key so the two payloads stay // in lock-step on every other field. @@ -349,15 +357,27 @@ grantType 规则: } ctx := context.Background() - result, err := callPATBatchGrantWithLegacyFallback( - ctx, - c, - agentCode, - sessionID, - batchArgs, - toolArgs, - legacyToolArgs, - ) + var result *edition.ToolResult + if interactiveBatchFlow { + result, err = callPATBatchToolWithIdentityFallback( + ctx, + c, + agentCode, + sessionID, + patBatchGrantToolName, + batchArgs, + ) + } else { + result, err = callPATBatchGrantWithLegacyFallback( + ctx, + c, + agentCode, + sessionID, + batchArgs, + toolArgs, + legacyToolArgs, + ) + } if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } @@ -523,6 +543,57 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, return args } +// RunLoginRecommendAuthorization plans the server-owned recommended scope set +// after a successful dws auth login, then starts a batch Device Code Flow for +// the server-selected grantable scopes. The actual browser open, polling, and +// retry are handled by the shared PAT runner once PAT_BATCH_AUTH_PENDING is +// classified as a PAT auth check. +func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, output io.Writer) error { + if c == nil { + return fmt.Errorf("internal error: tool runtime not initialized") + } + planArgs := buildBatchPlanArgs(nil, nil, true, grantTypePermanent, "", "", true) + planArgs["caller"] = patCallerAuthLoginRecommend + planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) + if err != nil { + return fmt.Errorf("pat login recommend plan failed: %w", err) + } + if err := classifyToolResultText(planResult); err != nil { + return err + } + scopes, err := extractSelectedScopesAllowEmpty(planResult) + if err != nil { + return err + } + if len(scopes) == 0 { + if output != nil { + _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") + } + return nil + } + grantArgs := map[string]any{ + "scopes": scopes, + "grantType": grantTypePermanent, + "startFlow": true, + "noWait": true, + "caller": patCallerAuthLoginRecommend, + "clientRequestId": newBatchClientRequestID("auth-login-recommend"), + } + result, err := callPATBatchToolWithIdentityFallback(ctx, c, "", "", patBatchGrantToolName, grantArgs) + if err != nil { + return fmt.Errorf("pat login recommend grant failed: %w", err) + } + return handleToolResultForWriter(output, result, c) +} + +func newBatchClientRequestID(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "batch" + } + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { text := firstToolResultText(result) if text == "" { @@ -559,6 +630,30 @@ func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { return scopes, nil } +func extractSelectedScopesAllowEmpty(result *edition.ToolResult) ([]string, error) { + text := firstToolResultText(result) + if text == "" { + return nil, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return nil, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return nil, fmt.Errorf("PAT batch plan result missing data.selectedScopes") + } + rawScopes, _ := data["selectedScopes"].([]any) + scopes := make([]string, 0, len(rawScopes)) + for _, raw := range rawScopes { + scope, ok := raw.(string) + if ok && strings.TrimSpace(scope) != "" { + scopes = append(scopes, scope) + } + } + return scopes, nil +} + func firstToolResultText(result *edition.ToolResult) string { if result == nil { return "" @@ -908,6 +1003,44 @@ func handleToolResult(cmd *cobra.Command, caller edition.ToolCaller, result *edi return fmt.Errorf("empty PAT authorization result: %s", string(data)) } +func handleToolResultForWriter(w io.Writer, result *edition.ToolResult, caller edition.ToolCaller) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil { + return respErr + } + if w == nil { + return nil + } + if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" { + _, _ = fmt.Fprint(w, summary) + return nil + } + _, _ = fmt.Fprintln(w, c.Text) + return nil + } + data, _ := json.Marshal(result) + return fmt.Errorf("empty PAT authorization result: %s", string(data)) +} + +func classifyToolResultText(result *edition.ToolResult) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + return apperrors.ClassifyMCPResponseText(c.Text) + } + return nil +} + func shouldEmitRawPATResult(cmd *cobra.Command) bool { if commandBoolFlag(cmd, "verbose") { return true diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 7e962c6a..db426f04 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -344,24 +344,79 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } -func TestChmod_productsFlagRequiresYesBeforeGranting(t *testing.T) { +func TestChmod_productsFlagWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"pendingScopes":[{"scope":"calendar.event:read"}]}}`, }} cmd := newChmodCommand(fake) - _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar") - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("chmod RunE error = nil, want --yes validation error") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) } - if !strings.Contains(err.Error(), "requires explicit --yes") { - t.Fatalf("chmod RunE error = %v, want --yes requirement", err) + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want plan + batch flow", len(fake.calls)) } - if len(fake.calls) != 0 { - t.Fatalf("CallTool count = %d, want 0 before explicit --yes", len(fake.calls)) + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["caller"]; got != patCallerBatchChmodInteractive { + t.Fatalf("plan caller = %#v, want %q", got, patCallerBatchChmodInteractive) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[1].args["startFlow"]; got != true { + t.Fatalf("grant startFlow = %#v, want true", got) + } + if got := fake.calls[1].args["noWait"]; got != true { + t.Fatalf("grant noWait = %#v, want true", got) + } + if got := fake.calls[1].args["caller"]; got != patCallerBatchChmodInteractive { + t.Fatalf("grant caller = %#v, want %q", got, patCallerBatchChmodInteractive) + } + if got := fake.calls[1].args["grantType"]; got != grantTypePermanent { + t.Fatalf("grantType = %#v, want %q for default interactive flow", got, grantTypePermanent) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) { + t.Fatalf("grant scopes = %#v, want selected scope", got) + } + if got, ok := fake.calls[1].args["clientRequestId"].(string); !ok || strings.TrimSpace(got) == "" { + t.Fatalf("grant clientRequestId = %#v, want non-empty string", got) + } +} + +func TestChmod_multipleScopesWithoutYesStartsBatchAuthorizationFlow(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:write"]}}`, + `{"success":true,"data":{"pendingScopes":[{"scope":"calendar.event:read"},{"scope":"aitable.record:write"}]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + if err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want plan + batch flow", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { + t.Fatalf("plan scopes = %#v, want explicit scopes", got) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[1].args["startFlow"]; got != true { + t.Fatalf("grant startFlow = %#v, want true", got) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:write"}) { + t.Fatalf("grant scopes = %#v, want selected scopes", got) } }